**⭐ 如果这个项目对你有帮助,请给一个 Star!**
## Star History
[](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)
Made with ❤️ by Huobao Team
================================================
FILE: README-JA.md
================================================
# 🎬 Huobao Drama - AI ショートドラマ制作プラットフォーム
**⭐ このプロジェクトが役に立ったら、Star をお願いします!**
## Star 履歴
[](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)
Made with ❤️ by Huobao Team
================================================
FILE: README.md
================================================
# 🎬 Huobao Drama - AI Short Drama Production Platform
**Full-stack AI Short Drama Automation Platform Based on Go + Vue3**
[](https://golang.org)
[](https://vuejs.org)
[](https://creativecommons.org/licenses/by-nc-sa/4.0/)
[Features](#features) • [Quick Start](#quick-start) • [Deployment](#deployment)
[简体中文](README-CN.md) | [English](README.md) | [日本語](README-JA.md)
---
## 📖 About
Huobao Drama is an AI-powered short drama production platform that automates the entire workflow from script generation, character design, storyboarding to video composition.
火宝短剧商业版地址:[火宝短剧商业版](https://drama.chatfire.site/shortvideo)
火宝小说生成:[火宝小说生成](https://marketing.chatfire.site/huobao-novel/)
### 🎯 Core Features
- **🤖 AI-Driven**: Parse scripts using large language models to extract characters, scenes, and storyboards
- **🎨 Intelligent Creation**: AI-generated character portraits and scene backgrounds
- **📹 Video Generation**: Automatic storyboard video generation using text-to-video and image-to-video models
- **🔄 Complete Workflow**: End-to-end production workflow from idea to final video。
### 🛠️ Technical Architecture
Based on **DDD (Domain-Driven Design)** with clear layering:
```
├── API Layer (Gin HTTP)
├── Application Service Layer (Business Logic)
├── Domain Layer (Domain Models)
└── Infrastructure Layer (Database, External Services)
```
### 🎥 Demo Videos
Experience AI short drama generation:
**Sample Work 1**
**Sample Work 2**
[Watch Video 1](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [Watch Video 2](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)
---
## ✨ Features
### 🎭 Character Management
- ✅ AI-generated character portraits
- ✅ Batch character generation
- ✅ Character image upload and management
### 🎬 Storyboard Production
- ✅ Automatic storyboard script generation
- ✅ Scene descriptions and shot design
- ✅ Storyboard image generation (text-to-image)
- ✅ Frame type selection (first frame/key frame/last frame/panel)
### 🎥 Video Generation
- ✅ Automatic image-to-video generation
- ✅ Video composition and editing
- ✅ Transition effects
### 📦 Asset Management
- ✅ Unified asset library management
- ✅ Local storage support
- ✅ Asset import/export
- ✅ Task progress tracking
---
## 🚀 Quick Start
### 📋 Prerequisites
| Software | Version | Description |
| ----------- | ------- | ------------------------------- |
| **Go** | 1.23+ | Backend runtime |
| **Node.js** | 18+ | Frontend build environment |
| **npm** | 9+ | Package manager |
| **FFmpeg** | 4.0+ | Video processing (**Required**) |
| **SQLite** | 3.x | Database (built-in) |
#### Installing FFmpeg
**macOS:**
```bash
brew install ffmpeg
```
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install ffmpeg
```
**Windows:**
Download from [FFmpeg Official Site](https://ffmpeg.org/download.html) and configure environment variables
Verify installation:
```bash
ffmpeg -version
```
### ⚙️ Configuration
Copy and edit the configuration file:
```bash
cp configs/config.example.yaml configs/config.yaml
vim configs/config.yaml
```
Configuration file format (`configs/config.yaml`):
```yaml
app:
name: "Huobao Drama API"
version: "1.0.0"
debug: true # Set to true for development, false for production
server:
port: 5678
host: "0.0.0.0"
cors_origins:
- "http://localhost:3012"
read_timeout: 600
write_timeout: 600
database:
type: "sqlite"
path: "./data/drama_generator.db"
max_idle: 10
max_open: 100
storage:
type: "local"
local_path: "./data/storage"
base_url: "http://localhost:5678/static"
ai:
default_text_provider: "openai"
default_image_provider: "openai"
default_video_provider: "doubao"
```
**Key Configuration Items:**
- `app.debug`: Debug mode switch (recommended true for development)
- `server.port`: Service port
- `server.cors_origins`: Allowed CORS origins for frontend
- `database.path`: SQLite database file path
- `storage.local_path`: Local file storage path
- `storage.base_url`: Static resource access URL
- `ai.default_*_provider`: AI service provider configuration (API keys configured in Web UI)
### 📥 Installation
```bash
# Clone the project
git clone https://github.com/chatfire-AI/huobao-drama.git
cd huobao-drama
# Install Go dependencies
go mod download
# Install frontend dependencies
cd web
npm install
cd ..
```
### 🎯 Starting the Project
#### Method 1: Development Mode (Recommended)
**Frontend and backend separation with hot reload**
```bash
# Terminal 1: Start backend service
go run main.go
# Terminal 2: Start frontend dev server
cd web
npm run dev
```
- Frontend: `http://localhost:3012`
- Backend API: `http://localhost:5678/api/v1`
- Frontend automatically proxies API requests to backend
#### Method 2: Single Service Mode
**Backend serves both API and frontend static files**
```bash
# 1. Build frontend
cd web
npm run build
cd ..
# 2. Start service
go run main.go
```
Access: `http://localhost:5678`
### 🗄️ Database Initialization
Database tables are automatically created on first startup (using GORM AutoMigrate), no manual migration needed.
---
## 📦 Deployment
### ☁️ Cloud One-Click Deployment (Recommended 3080Ti)
👉 [优云智算,一键部署](https://www.compshare.cn/images/fScvzK95NUk5?referral_code=8hUJOaWz3YzG64FI2OlCiB&ytag=GPU_YY_YX_GitHub_huobaoai)
> ⚠️ **Note**: Please save your data to local storage promptly when using cloud deployment
---
### 🐳 Docker Deployment (Recommended)
#### Method 1: Docker Compose (Recommended)
#### 🚀 China Network Acceleration (Optional)
If you are in China, pulling Docker images and installing dependencies may be slow. You can speed up the build process by configuring mirror sources.
**Step 1: Create environment variable file**
```bash
cp .env.example .env
```
**Step 2: Edit `.env` file and uncomment the mirror sources you need**
```bash
# Enable Docker Hub mirror (recommended)
DOCKER_REGISTRY=docker.1ms.run/
# Enable npm mirror
NPM_REGISTRY=https://registry.npmmirror.com/
# Enable Go proxy
GO_PROXY=https://goproxy.cn,direct
# Enable Alpine mirror
ALPINE_MIRROR=mirrors.aliyun.com
```
**Step 3: Build with docker compose (required)**
```bash
docker compose build
```
> **Important Note**:
>
> - ⚠️ You must use `docker compose build` to automatically load mirror source configurations from the `.env` file
> - ❌ If using `docker build` command, you need to manually pass `--build-arg` parameters
> - ✅ Always recommended to use `docker compose build` for building
**Performance Comparison**:
| Operation | Without Mirrors | With Mirrors |
| ---------------- | --------------- | ------------ |
| Pull base images | 5-30 minutes | 1-5 minutes |
| Install npm deps | May fail | Fast success |
| Download Go deps | 5-10 minutes | 30s-1 minute |
> **Note**: Users outside China should not configure mirror sources, use default settings.
```bash
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
#### Method 2: Docker Command
> **Note**: Linux users need to add `--add-host=host.docker.internal:host-gateway` to access host services
```bash
# Run from Docker Hub
docker run -d \
--name huobao-drama \
-p 5678:5678 \
-v $(pwd)/data:/app/data \
--restart unless-stopped \
huobao/huobao-drama:latest
# View logs
docker logs -f huobao-drama
```
**Local Build** (optional):
```bash
docker build -t huobao-drama:latest .
docker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest
```
**Docker Deployment Advantages:**
- ✅ Ready to use with default configuration
- ✅ Environment consistency, avoiding dependency issues
- ✅ One-click start, no need to install Go, Node.js, FFmpeg
- ✅ Easy to migrate and scale
- ✅ Automatic health checks and restarts
- ✅ Automatic file permission handling
#### 🔗 Accessing Host Services (Ollama/Local Models)
The container is configured to access host services using `http://host.docker.internal:PORT`.
**Configuration Steps:**
1. **Start service on host (listen on all interfaces)**
```bash
export OLLAMA_HOST=0.0.0.0:11434 && ollama serve
```
2. **Frontend AI Service Configuration**
- Base URL: `http://host.docker.internal:11434/v1`
- Provider: `openai`
- Model: `qwen2.5:latest`
---
### 🏭 Traditional Deployment
#### 1. Build
```bash
# 1. Build frontend
cd web
npm run build
cd ..
# 2. Compile backend
go build -o huobao-drama .
```
Generated files:
- `huobao-drama` - Backend executable
- `web/dist/` - Frontend static files (embedded in backend)
#### 2. Prepare Deployment Files
Files to upload to server:
```
huobao-drama # Backend executable
configs/config.yaml # Configuration file
data/ # Data directory (optional, auto-created on first run)
```
#### 3. Server Configuration
```bash
# Upload files to server
scp huobao-drama user@server:/opt/huobao-drama/
scp configs/config.yaml user@server:/opt/huobao-drama/configs/
# SSH to server
ssh user@server
# Modify configuration file
cd /opt/huobao-drama
vim configs/config.yaml
# Set mode to production
# Configure domain and storage path
# Create data directory and set permissions (Important!)
# Note: Replace YOUR_USER with actual user running the service (e.g., www-data, ubuntu, deploy)
sudo mkdir -p /opt/huobao-drama/data/storage
sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data
sudo chmod -R 755 /opt/huobao-drama/data
# Grant execute permission
chmod +x huobao-drama
# Start service
./huobao-drama
```
#### 4. Manage Service with systemd
Create service file `/etc/systemd/system/huobao-drama.service`:
```ini
[Unit]
Description=Huobao Drama Service
After=network.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=/opt/huobao-drama
ExecStart=/opt/huobao-drama/huobao-drama
Restart=on-failure
RestartSec=10
# Environment variables (optional)
# Environment="GIN_MODE=release"
[Install]
WantedBy=multi-user.target
```
Start service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable huobao-drama
sudo systemctl start huobao-drama
sudo systemctl status huobao-drama
```
**⚠️ Common Issue: SQLite Write Permission Error**
If you encounter `attempt to write a readonly database` error:
```bash
# 1. Check current user running the service
sudo systemctl status huobao-drama | grep "Main PID"
ps aux | grep huobao-drama
# 2. Fix permissions (replace YOUR_USER with actual username)
sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data
sudo chmod -R 755 /opt/huobao-drama/data
# 3. Verify permissions
ls -la /opt/huobao-drama/data
# Should show owner as the user running the service
# 4. Restart service
sudo systemctl restart huobao-drama
```
**Reason:**
- SQLite requires write permission on both the database file **and** its directory
- Needs to create temporary files in the directory (e.g., `-wal`, `-journal`)
- **Key**: Ensure systemd `User` matches data directory owner
**Common Usernames:**
- Ubuntu/Debian: `www-data`, `ubuntu`
- CentOS/RHEL: `nginx`, `apache`
- Custom deployment: `deploy`, `app`, current logged-in user
#### 5. Nginx Reverse Proxy
```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;
}
# Direct access to static files
location /static/ {
alias /opt/huobao-drama/data/storage/;
}
}
```
---
## 🎨 Tech Stack
### Backend
- **Language**: Go 1.23+
- **Web Framework**: Gin 1.9+
- **ORM**: GORM
- **Database**: SQLite
- **Logging**: Zap
- **Video Processing**: FFmpeg
- **AI Services**: OpenAI, Gemini, Doubao, etc.
### Frontend
- **Framework**: Vue 3.4+
- **Language**: TypeScript 5+
- **Build Tool**: Vite 5
- **UI Components**: Element Plus
- **CSS Framework**: TailwindCSS
- **State Management**: Pinia
- **Router**: Vue Router 4
### Development Tools
- **Package Management**: Go Modules, npm
- **Code Standards**: ESLint, Prettier
- **Version Control**: Git
---
## 📝 FAQ
### Q: How can Docker containers access Ollama on the host?
A: Use `http://host.docker.internal:11434/v1` as Base URL. Note two things:
1. Host Ollama needs to listen on `0.0.0.0`: `export OLLAMA_HOST=0.0.0.0:11434 && ollama serve`
2. Linux users using `docker run` need to add: `--add-host=host.docker.internal:host-gateway`
See: [DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md)
### Q: FFmpeg not installed or not found?
A: Ensure FFmpeg is installed and in the PATH environment variable. Verify with `ffmpeg -version`.
### Q: Frontend cannot connect to backend API?
A: Check if backend is running and port is correct. In development mode, frontend proxy config is in `web/vite.config.ts`.
### Q: Database tables not created?
A: GORM automatically creates tables on first startup, check logs to confirm migration success.
---
## 📋 Changelog
### v1.0.5 (2026-02-06)
#### 🎨 Major Features
- **🎭 Global Style System**: Introduced comprehensive style selection support across the entire project. Users can now define custom visual styles at the drama level, which automatically applies to all AI-generated content including characters, scenes, and storyboards, ensuring consistent artistic direction throughout the production.
- **✂️ Nine-Grid Sequence Image Cropping**: Added cropping tool for action sequence images. Users can now extract individual frames from 3x3 grid layouts and designate them as first frames, last frames, or keyframes for video generation, providing greater flexibility in shot composition and continuity.
#### 🚀 Enhancements
- **📐 Optimized Action Sequence Grid**: Enhanced the visual quality and layout of nine-grid action sequence images with improved spacing, alignment, and frame transitions.
- **🔧 Manual Grid Assembly**: Introduced manual grid composition tools supporting 2x2 (four-grid), 2x3 (six-grid), and 3x3 (nine-grid) layouts, allowing users to create custom action sequences from individual frames.
- **🗑️ Content Management**: Added delete functionality for both generated images and videos, enabling better asset organization and storage management.
### v1.0.4 (2026-01-27)
#### 🚀 Major Updates
- Introduced local storage strategy for generated content caching, effectively mitigating external resource link expiration risks
- Implemented Base64 encoding for embedded reference image transmission
- Fixed issue where shot image prompt state was not reset when switching shots
- Fixed issue where video duration displayed as 0 when adding library videos
- Added scene migration to episodes
#### Historical Data Migration
- Added migration script for processing historical data. For detailed instructions, please refer to [MIGRATE_README.md](MIGRATE_README.md)
### v1.0.3 (2026-01-16)
#### 🚀 Major Updates
- Pure Go SQLite driver (`modernc.org/sqlite`), supports `CGO_ENABLED=0` cross-platform compilation
- Optimized concurrency performance (WAL mode), resolved "database is locked" errors
- Docker cross-platform support for `host.docker.internal` to access host services
- Streamlined documentation and deployment guides
### v1.0.2 (2026-01-14)
#### 🐛 Bug Fixes / 🔧 Improvements
- Fixed video generation API response parsing issues
- Added OpenAI Sora video endpoint configuration
- Optimized error handling and logging
---
## 🤝 Contributing
Issues and Pull Requests are welcome!
1. Fork this project
2. Create a feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
---
## API Configuration Site
Configure in 2 minutes: [API Aggregation Site](https://api.chatfire.site/models)
---
## 👨💻 About Us
**AI Huobao - AI Studio Startup**
- 🏠 **Location**: Nanjing, China
- 🚀 **Status**: Startup in Progress
- 📧 **Email**: [18550175439@163.com](mailto:18550175439@163.com)
- 🐙 **GitHub**: [https://github.com/chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama)
> _"Let AI help us do more creative things"_
## Community Group

- Submit [Issue](../../issues)
- Email project maintainers
---
**⭐ If this project helps you, please give it a Star!**
## Star History
[](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left)
Made with ❤️ by Huobao Team
================================================
FILE: api/handlers/ai_config.go
================================================
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": "连接测试成功"})
}
================================================
FILE: api/handlers/asset.go
================================================
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)
}
================================================
FILE: api/handlers/audio_extraction.go
================================================
package handlers
import (
"net/http"
"github.com/drama-generator/backend/application/services"
"github.com/drama-generator/backend/pkg/logger"
"github.com/gin-gonic/gin"
)
type AudioExtractionHandler struct {
service *services.AudioExtractionService
log *logger.Logger
dataDir string
}
func NewAudioExtractionHandler(log *logger.Logger, dataDir string) *AudioExtractionHandler {
return &AudioExtractionHandler{
service: services.NewAudioExtractionService(log),
log: log,
dataDir: dataDir,
}
}
// ExtractAudio 提取单个视频的音频
// @Summary 提取视频音频
// @Description 从视频URL中提取音频轨道
// @Tags Audio
// @Accept json
// @Produce json
// @Param request body services.ExtractAudioRequest true "提取请求"
// @Success 200 {object} services.ExtractAudioResponse
// @Router /api/audio/extract [post]
func (h *AudioExtractionHandler) ExtractAudio(c *gin.Context) {
var req services.ExtractAudioRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.log.Errorw("Invalid request body", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
h.log.Infow("Received audio extraction request", "video_url", req.VideoURL)
result, err := h.service.ExtractAudio(req.VideoURL, h.dataDir)
if err != nil {
h.log.Errorw("Failed to extract audio", "error", err, "video_url", req.VideoURL)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
type BatchExtractAudioRequest struct {
VideoURLs []string `json:"video_urls" binding:"required,min=1"`
}
// BatchExtractAudio 批量提取音频
// @Summary 批量提取视频音频
// @Description 从多个视频URL中提取音频轨道
// @Tags Audio
// @Accept json
// @Produce json
// @Param request body BatchExtractAudioRequest true "批量提取请求"
// @Success 200 {array} services.ExtractAudioResponse
// @Router /api/audio/extract/batch [post]
func (h *AudioExtractionHandler) BatchExtractAudio(c *gin.Context) {
var req BatchExtractAudioRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.log.Errorw("Invalid request body", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
h.log.Infow("Received batch audio extraction request", "count", len(req.VideoURLs))
results, err := h.service.BatchExtractAudio(req.VideoURLs, h.dataDir)
if err != nil {
h.log.Errorw("Failed to batch extract audio", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"total": len(results),
})
}
================================================
FILE: api/handlers/character_batch.go
================================================
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),
})
}
================================================
FILE: api/handlers/character_library.go
================================================
package handlers
import (
"strconv"
services2 "github.com/drama-generator/backend/application/services"
"github.com/drama-generator/backend/infrastructure/storage"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type CharacterLibraryHandler struct {
libraryService *services2.CharacterLibraryService
imageService *services2.ImageGenerationService
log *logger.Logger
}
func NewCharacterLibraryHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services2.ResourceTransferService, localStorage *storage.LocalStorage) *CharacterLibraryHandler {
return &CharacterLibraryHandler{
libraryService: services2.NewCharacterLibraryService(db, log, cfg),
imageService: services2.NewImageGenerationService(db, cfg, transferService, localStorage, log),
log: log,
}
}
// ListLibraryItems 获取角色库列表
func (h *CharacterLibraryHandler) ListLibraryItems(c *gin.Context) {
var query services2.CharacterLibraryQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.BadRequest(c, err.Error())
return
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 || query.PageSize > 100 {
query.PageSize = 20
}
items, total, err := h.libraryService.ListLibraryItems(&query)
if err != nil {
h.log.Errorw("Failed to list library items", "error", err)
response.InternalError(c, "获取角色库失败")
return
}
response.SuccessWithPagination(c, items, total, query.Page, query.PageSize)
}
// CreateLibraryItem 添加到角色库
func (h *CharacterLibraryHandler) CreateLibraryItem(c *gin.Context) {
var req services2.CreateLibraryItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
item, err := h.libraryService.CreateLibraryItem(&req)
if err != nil {
h.log.Errorw("Failed to create library item", "error", err)
response.InternalError(c, "添加到角色库失败")
return
}
response.Created(c, item)
}
// GetLibraryItem 获取角色库项详情
func (h *CharacterLibraryHandler) GetLibraryItem(c *gin.Context) {
itemID := c.Param("id")
item, err := h.libraryService.GetLibraryItem(itemID)
if err != nil {
if err.Error() == "library item not found" {
response.NotFound(c, "角色库项不存在")
return
}
h.log.Errorw("Failed to get library item", "error", err)
response.InternalError(c, "获取失败")
return
}
response.Success(c, item)
}
// DeleteLibraryItem 删除角色库项
func (h *CharacterLibraryHandler) DeleteLibraryItem(c *gin.Context) {
itemID := c.Param("id")
if err := h.libraryService.DeleteLibraryItem(itemID); err != nil {
if err.Error() == "library item not found" {
response.NotFound(c, "角色库项不存在")
return
}
h.log.Errorw("Failed to delete library item", "error", err)
response.InternalError(c, "删除失败")
return
}
response.Success(c, gin.H{"message": "删除成功"})
}
// UploadCharacterImage 上传角色图片
func (h *CharacterLibraryHandler) UploadCharacterImage(c *gin.Context) {
characterID := c.Param("id")
// TODO: 处理文件上传
// 这里需要实现文件上传逻辑,保存到OSS或本地
// 暂时使用简单的实现
var req struct {
ImageURL string `json:"image_url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.libraryService.UploadCharacterImage(characterID, req.ImageURL); err != nil {
if err.Error() == "character not found" {
response.NotFound(c, "角色不存在")
return
}
if err.Error() == "unauthorized" {
response.Forbidden(c, "无权限")
return
}
h.log.Errorw("Failed to upload character image", "error", err)
response.InternalError(c, "上传失败")
return
}
response.Success(c, gin.H{"message": "上传成功"})
}
// ApplyLibraryItemToCharacter 从角色库应用形象
func (h *CharacterLibraryHandler) ApplyLibraryItemToCharacter(c *gin.Context) {
characterID := c.Param("id")
var req struct {
LibraryItemID string `json:"library_item_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.libraryService.ApplyLibraryItemToCharacter(characterID, req.LibraryItemID); err != nil {
if err.Error() == "library item not found" {
response.NotFound(c, "角色库项不存在")
return
}
if err.Error() == "character not found" {
response.NotFound(c, "角色不存在")
return
}
if err.Error() == "unauthorized" {
response.Forbidden(c, "无权限")
return
}
h.log.Errorw("Failed to apply library item", "error", err)
response.InternalError(c, "应用失败")
return
}
response.Success(c, gin.H{"message": "应用成功"})
}
// AddCharacterToLibrary 将角色添加到角色库
func (h *CharacterLibraryHandler) AddCharacterToLibrary(c *gin.Context) {
characterID := c.Param("id")
var req struct {
Category *string `json:"category"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// 允许空body
req.Category = nil
}
item, err := h.libraryService.AddCharacterToLibrary(characterID, req.Category)
if err != nil {
if err.Error() == "character not found" {
response.NotFound(c, "角色不存在")
return
}
if err.Error() == "unauthorized" {
response.Forbidden(c, "无权限")
return
}
if err.Error() == "character has no image" {
response.BadRequest(c, "角色还没有形象图片")
return
}
h.log.Errorw("Failed to add character to library", "error", err)
response.InternalError(c, "添加失败")
return
}
response.Created(c, item)
}
// UpdateCharacter 更新角色信息
func (h *CharacterLibraryHandler) UpdateCharacter(c *gin.Context) {
characterID := c.Param("id")
var req services2.UpdateCharacterRequest
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": "角色已删除"})
}
// ExtractCharacters 从剧本提取角色
func (h *CharacterLibraryHandler) ExtractCharacters(c *gin.Context) {
episodeIDStr := c.Param("episode_id")
episodeID, err := strconv.ParseUint(episodeIDStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid episode_id")
return
}
taskID, err := h.libraryService.ExtractCharactersFromScript(uint(episodeID))
if err != nil {
h.log.Errorw("Failed to extract characters", "error", err)
response.InternalError(c, err.Error())
return
}
response.Success(c, gin.H{"task_id": taskID, "message": "角色提取任务已提交"})
}
================================================
FILE: api/handlers/character_library_gen.go
================================================
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和style参数
var req struct {
Model string `json:"model"`
Style string `json:"style"`
}
c.ShouldBindJSON(&req)
imageGen, err := h.libraryService.GenerateCharacterImage(characterID, h.imageService, req.Model, req.Style)
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,
})
}
================================================
FILE: api/handlers/drama.go
================================================
package handlers
import (
"encoding/json"
"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, cfg, 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
// 先尝试正常绑定JSON
if err := c.ShouldBindJSON(&req); err != nil {
// 如果绑定失败,检查是否是因为characters字段是字符串而不是数组
var rawReq map[string]interface{}
if err := c.ShouldBindJSON(&rawReq); err != nil {
// 如果连rawReq都绑定失败,直接返回错误
response.BadRequest(c, err.Error())
return
}
// 检查characters字段类型
if charField, ok := rawReq["characters"]; ok {
if charStr, ok := charField.(string); ok {
// 如果characters是字符串,尝试解析为JSON数组
var characters []models.Character
if err := json.Unmarshal([]byte(charStr), &characters); err != nil {
// 解析失败,返回错误
response.BadRequest(c, "characters字段格式错误,需要JSON数组或字符串格式的JSON数组")
return
}
// 手动构造请求对象
req.Characters = characters
// 处理episode_id字段
if epID, ok := rawReq["episode_id"]; ok {
if epIDStr, ok := epID.(float64); ok {
epIDUint := uint(epIDStr)
req.EpisodeID = &epIDUint
}
}
} else {
// 如果characters不是字符串,直接返回原始错误
response.BadRequest(c, err.Error())
return
}
} else {
// 如果没有characters字段,返回原始错误
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,
})
}
================================================
FILE: api/handlers/frame_prompt.go
================================================
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"`
PanelCount int `json:"panel_count"`
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
serviceReq := services.GenerateFramePromptRequest{
StoryboardID: storyboardID,
FrameType: services.FrameType(req.FrameType),
PanelCount: req.PanelCount,
}
// 直接调用服务层的异步方法,该方法会创建任务并返回任务ID
taskID, err := h.framePromptService.GenerateFramePrompt(serviceReq, req.Model)
if err != nil {
h.log.Errorw("Failed to generate frame prompt", "error", err)
response.InternalError(c, err.Error())
return
}
// 立即返回任务ID
response.Success(c, gin.H{
"task_id": taskID,
"status": "pending",
"message": "帧提示词生成任务已创建,正在后台处理...",
})
}
================================================
FILE: api/handlers/frame_prompt_query.go
================================================
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,
})
}
}
================================================
FILE: api/handlers/image_generation.go
================================================
package handlers
import (
"strconv"
"github.com/drama-generator/backend/application/services"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/infrastructure/storage"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ImageGenerationHandler struct {
imageService *services.ImageGenerationService
taskService *services.TaskService
log *logger.Logger
config *config.Config
db *gorm.DB
}
func NewImageGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage) *ImageGenerationHandler {
return &ImageGenerationHandler{
imageService: services.NewImageGenerationService(db, cfg, transferService, localStorage, log),
taskService: services.NewTaskService(db, log),
log: log,
config: cfg,
db: db,
}
}
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")
// 接收可选的 model 和 style 参数
var req struct {
Model string `json:"model"`
Style string `json:"style"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// 如果没有提供body或者解析失败,使用空字符串(使用默认模型和风格)
req.Model = ""
req.Style = ""
}
// 如果style为空,从episode获取drama的style
if req.Style == "" {
var episode models.Episode
if err := h.db.Preload("Drama").First(&episode, episodeID).Error; err == nil {
req.Style = episode.Drama.Style
}
}
// 直接调用服务层的异步方法,该方法会创建任务并返回任务ID
taskID, err := h.imageService.ExtractBackgroundsForEpisode(episodeID, req.Model, req.Style)
if err != nil {
h.log.Errorw("Failed to extract backgrounds", "error", err, "episode_id", episodeID)
response.InternalError(c, err.Error())
return
}
// 立即返回任务ID
response.Success(c, gin.H{
"task_id": taskID,
"status": "pending",
"message": "场景提取任务已创建,正在后台处理...",
})
}
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)
}
// UploadImage 上传图片并创建图片生成记录
func (h *ImageGenerationHandler) UploadImage(c *gin.Context) {
var req struct {
StoryboardID uint `json:"storyboard_id" binding:"required"`
DramaID uint `json:"drama_id" binding:"required"`
FrameType string `json:"frame_type" binding:"required"`
ImageURL string `json:"image_url" binding:"required"`
Prompt string `json:"prompt"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
imageGen, err := h.imageService.CreateImageFromUpload(&services.UploadImageRequest{
StoryboardID: req.StoryboardID,
DramaID: req.DramaID,
FrameType: req.FrameType,
ImageURL: req.ImageURL,
Prompt: req.Prompt,
})
if err != nil {
h.log.Errorw("Failed to create image from upload", "error", err)
response.InternalError(c, err.Error())
return
}
response.Success(c, imageGen)
}
================================================
FILE: api/handlers/prop.go
================================================
package handlers
import (
"strconv"
"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 PropHandler struct {
propService *services.PropService
log *logger.Logger
}
func NewPropHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, aiService *services.AIService, imageGenerationService *services.ImageGenerationService) *PropHandler {
return &PropHandler{
propService: services.NewPropService(db, aiService, services.NewTaskService(db, log), imageGenerationService, log, cfg),
log: log,
}
}
// ListProps 获取道具列表
func (h *PropHandler) ListProps(c *gin.Context) {
dramaIDStr := c.Query("drama_id")
if dramaIDStr == "" {
response.BadRequest(c, "drama_id is required")
return
}
dramaID, err := strconv.ParseUint(dramaIDStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid drama_id")
return
}
props, err := h.propService.ListProps(uint(dramaID))
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, props)
}
// CreateProp 创建道具
func (h *PropHandler) CreateProp(c *gin.Context) {
var prop models.Prop
if err := c.ShouldBindJSON(&prop); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.propService.CreateProp(&prop); err != nil {
response.InternalError(c, err.Error())
return
}
response.Created(c, prop)
}
// UpdateProp 更新道具
func (h *PropHandler) UpdateProp(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid ID")
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.propService.UpdateProp(uint(id), updates); err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, nil)
}
// DeleteProp 删除道具
func (h *PropHandler) DeleteProp(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid ID")
return
}
if err := h.propService.DeleteProp(uint(id)); err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, nil)
}
// ExtractProps 提取道具
func (h *PropHandler) ExtractProps(c *gin.Context) {
episodeIDStr := c.Param("episode_id")
episodeID, err := strconv.ParseUint(episodeIDStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid episode_id")
return
}
taskID, err := h.propService.ExtractPropsFromScript(uint(episodeID))
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, gin.H{"task_id": taskID})
}
// GenerateImage 生成道具图片
func (h *PropHandler) GenerateImage(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid ID")
return
}
taskID, err := h.propService.GeneratePropImage(uint(id))
if err != nil {
h.log.Errorw("Failed to generate prop image", "error", err)
response.InternalError(c, err.Error())
return
}
response.Success(c, gin.H{"task_id": taskID, "message": "图片生成任务已提交"})
}
// AssociateProps 关联道具
func (h *PropHandler) AssociateProps(c *gin.Context) {
storyboardIDStr := c.Param("id")
storyboardID, err := strconv.ParseUint(storyboardIDStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid storyboard_id")
return
}
var req struct {
PropIDs []uint `json:"prop_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.propService.AssociatePropsWithStoryboard(uint(storyboardID), req.PropIDs); err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, nil)
}
================================================
FILE: api/handlers/scene.go
================================================
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.UpdateSceneInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request")
return
}
if err := h.sceneService.UpdateSceneInfo(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,
})
}
func (h *SceneHandler) UpdateScenePrompt(c *gin.Context) {
sceneID := c.Param("scene_id")
var req services2.UpdateScenePromptRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request")
return
}
if err := h.sceneService.UpdateScenePrompt(sceneID, &req); err != nil {
h.log.Errorw("Failed to update scene prompt", "error", err, "scene_id", sceneID)
if err.Error() == "scene not found" {
response.NotFound(c, "场景不存在")
return
}
response.InternalError(c, err.Error())
return
}
response.Success(c, gin.H{"message": "场景提示词已更新"})
}
func (h *SceneHandler) DeleteScene(c *gin.Context) {
sceneID := c.Param("scene_id")
if err := h.sceneService.DeleteScene(sceneID); err != nil {
h.log.Errorw("Failed to delete scene", "error", err, "scene_id", sceneID)
if err.Error() == "scene not found" {
response.NotFound(c, "场景不存在")
return
}
response.InternalError(c, err.Error())
return
}
response.Success(c, gin.H{"message": "场景已删除"})
}
func (h *SceneHandler) CreateScene(c *gin.Context) {
var req services2.CreateSceneRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request")
return
}
if req.DramaID == 0 {
response.BadRequest(c, "drama_id is required")
return
}
scene, err := h.sceneService.CreateScene(&req)
if err != nil {
h.log.Errorw("Failed to create scene", "error", err)
response.InternalError(c, err.Error())
return
}
response.Success(c, scene)
}
================================================
FILE: api/handlers/script_generation.go
================================================
package handlers
import (
"github.com/drama-generator/backend/application/services"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ScriptGenerationHandler struct {
scriptService *services.ScriptGenerationService
taskService *services.TaskService
log *logger.Logger
}
func NewScriptGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationHandler {
return &ScriptGenerationHandler{
scriptService: services.NewScriptGenerationService(db, cfg, log),
taskService: services.NewTaskService(db, log),
log: log,
}
}
func (h *ScriptGenerationHandler) GenerateCharacters(c *gin.Context) {
var req services.GenerateCharactersRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
// 直接调用服务层的异步方法,该方法会创建任务并返回任务ID
taskID, err := h.scriptService.GenerateCharacters(&req)
if err != nil {
h.log.Errorw("Failed to generate characters", "error", err, "drama_id", req.DramaID)
response.InternalError(c, err.Error())
return
}
// 立即返回任务ID
response.Success(c, gin.H{
"task_id": taskID,
"status": "pending",
"message": "角色生成任务已创建,正在后台处理...",
})
}
================================================
FILE: api/handlers/settings.go
================================================
package handlers
import (
"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"
"github.com/spf13/viper"
)
type SettingsHandler struct {
config *config.Config
log *logger.Logger
}
func NewSettingsHandler(cfg *config.Config, log *logger.Logger) *SettingsHandler {
return &SettingsHandler{
config: cfg,
log: log,
}
}
// GetLanguage 获取当前系统语言
func (h *SettingsHandler) GetLanguage(c *gin.Context) {
language := h.config.App.Language
if language == "" {
language = "zh" // 默认中文
}
response.Success(c, gin.H{
"language": language,
})
}
// UpdateLanguage 更新系统语言
func (h *SettingsHandler) UpdateLanguage(c *gin.Context) {
var req struct {
Language string `json:"language" binding:"required,oneof=zh en"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "语言参数错误,只支持 zh 或 en")
return
}
// 更新内存中的配置
h.config.App.Language = req.Language
// 更新配置文件
viper.Set("app.language", req.Language)
if err := viper.WriteConfig(); err != nil {
h.log.Warnw("Failed to write config file", "error", err)
// 即使写入文件失败,内存配置也已更新,仍然可用
}
h.log.Infow("System language updated", "language", req.Language)
message := "语言已切换为中文"
if req.Language == "en" {
message = "Language switched to English"
}
response.Success(c, gin.H{
"message": message,
"language": req.Language,
})
}
================================================
FILE: api/handlers/storyboard.go
================================================
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 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, cfg, log),
taskService: services.NewTaskService(db, log),
log: log,
}
}
// GenerateStoryboard 生成分镜头(异步)
func (h *StoryboardHandler) GenerateStoryboard(c *gin.Context) {
episodeID := c.Param("episode_id")
// 接收可选的 model 参数
var req struct {
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// 如果没有提供body或者解析失败,使用空字符串(使用默认模型)
req.Model = ""
}
// 调用生成服务,该服务已经是异步的,会返回任务ID
taskID, err := h.storyboardService.GenerateStoryboard(episodeID, req.Model)
if err != nil {
h.log.Errorw("Failed to generate storyboard", "error", err, "episode_id", episodeID)
response.InternalError(c, err.Error())
return
}
// 立即返回任务ID
response.Success(c, gin.H{
"task_id": taskID,
"status": "pending",
"message": "分镜头生成任务已创建,正在后台处理...",
})
}
// 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"})
}
// CreateStoryboard 创建分镜
func (h *StoryboardHandler) CreateStoryboard(c *gin.Context) {
var req services.CreateStoryboardRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
sb, err := h.storyboardService.CreateStoryboard(&req)
if err != nil {
h.log.Errorw("Failed to create storyboard", "error", err)
response.InternalError(c, err.Error())
return
}
response.Created(c, sb)
}
// DeleteStoryboard 删除分镜
func (h *StoryboardHandler) DeleteStoryboard(c *gin.Context) {
storyboardIDStr := c.Param("id")
storyboardID, err := strconv.ParseUint(storyboardIDStr, 10, 32)
if err != nil {
response.BadRequest(c, "Invalid ID")
return
}
if err := h.storyboardService.DeleteStoryboard(uint(storyboardID)); err != nil {
h.log.Errorw("Failed to delete storyboard", "error", err)
response.InternalError(c, err.Error())
return
}
response.Success(c, nil)
}
================================================
FILE: api/handlers/task.go
================================================
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)
}
================================================
FILE: api/handlers/upload.go
================================================
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
}
// 上传到本地存储
result, 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": result.URL,
"local_path": result.LocalPath,
"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
}
// 上传到本地存储
result, 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, result.URL)
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", result.URL, "local_path", result.LocalPath)
response.Success(c, gin.H{
"url": result.URL,
"local_path": result.LocalPath,
"filename": header.Filename,
"size": header.Size,
})
}
================================================
FILE: api/handlers/video_generation.go
================================================
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, promptI18n *services.PromptI18n) *VideoGenerationHandler {
return &VideoGenerationHandler{
videoService: services.NewVideoGenerationService(db, transferService, localStorage, aiService, log, promptI18n),
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)
}
================================================
FILE: api/handlers/video_merge.go
================================================
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"})
}
================================================
FILE: api/middlewares/cors.go
================================================
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")
path := c.Request.URL.Path
// 检查是否是静态文件路径(/static 或 /assets)
isStaticPath := len(path) >= 7 && (path[:7] == "/static" || path[:7] == "/assets")
allowed := false
for _, o := range allowedOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
// 对于静态文件,如果有 Origin 头,总是允许跨域访问
if isStaticPath && origin != "" {
allowed = true
}
if allowed && origin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
} else if allowed && origin == "" {
// 如果没有 Origin 头但是允许的请求,设置为 *
c.Writer.Header().Set("Access-Control-Allow-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")
c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
================================================
FILE: api/middlewares/logger.go
================================================
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(),
)
}
}
================================================
FILE: api/middlewares/ratelimit.go
================================================
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: 2000, // 每分钟最多 2000 次请求
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()
}
}
================================================
FILE: api/routes/routes.go
================================================
package routes
import (
handlers2 "github.com/drama-generator/backend/api/handlers"
middlewares2 "github.com/drama-generator/backend/api/middlewares"
services2 "github.com/drama-generator/backend/application/services"
storage2 "github.com/drama-generator/backend/infrastructure/storage"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStorage interface{}) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.Use(middlewares2.LoggerMiddleware(log))
r.Use(middlewares2.CORSMiddleware(cfg.Server.CORSOrigins))
// 静态文件服务(用户上传的文件)
r.Static("/static", cfg.Storage.LocalPath)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"app": cfg.App.Name,
"version": cfg.App.Version,
})
})
aiService := services2.NewAIService(db, log)
localStoragePtr := localStorage.(*storage2.LocalStorage)
transferService := services2.NewResourceTransferService(db, log)
promptI18n := services2.NewPromptI18n(cfg)
dramaHandler := handlers2.NewDramaHandler(db, cfg, log, nil)
aiConfigHandler := handlers2.NewAIConfigHandler(db, cfg, log)
scriptGenHandler := handlers2.NewScriptGenerationHandler(db, cfg, log)
imageGenService := services2.NewImageGenerationService(db, cfg, transferService, localStoragePtr, log)
imageGenHandler := handlers2.NewImageGenerationHandler(db, cfg, log, transferService, localStoragePtr)
videoGenHandler := handlers2.NewVideoGenerationHandler(db, transferService, localStoragePtr, aiService, log, promptI18n)
videoMergeHandler := handlers2.NewVideoMergeHandler(db, nil, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log)
assetHandler := handlers2.NewAssetHandler(db, cfg, log)
characterLibraryService := services2.NewCharacterLibraryService(db, log, cfg)
characterLibraryHandler := handlers2.NewCharacterLibraryHandler(db, cfg, log, transferService, localStoragePtr)
uploadHandler, err := handlers2.NewUploadHandler(cfg, log, characterLibraryService)
if err != nil {
log.Fatalw("Failed to create upload handler", "error", err)
}
storyboardHandler := handlers2.NewStoryboardHandler(db, cfg, log)
sceneHandler := handlers2.NewSceneHandler(db, log, imageGenService)
taskHandler := handlers2.NewTaskHandler(db, log)
framePromptService := services2.NewFramePromptService(db, cfg, log)
framePromptHandler := handlers2.NewFramePromptHandler(framePromptService, log)
audioExtractionHandler := handlers2.NewAudioExtractionHandler(log, cfg.Storage.LocalPath)
settingsHandler := handlers2.NewSettingsHandler(cfg, log)
propHandler := handlers2.NewPropHandler(db, cfg, log, aiService, imageGenService)
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) // 统计接口放在/:id之前
dramas.GET("/:id", dramaHandler.GetDrama)
dramas.PUT("/:id", dramaHandler.UpdateDrama)
dramas.DELETE("/:id", dramaHandler.DeleteDrama)
dramas.PUT("/:id/outline", dramaHandler.SaveOutline)
dramas.GET("/:id/characters", dramaHandler.GetCharacters)
dramas.PUT("/:id/characters", dramaHandler.SaveCharacters)
dramas.PUT("/:id/episodes", dramaHandler.SaveEpisodes)
dramas.PUT("/:id/progress", dramaHandler.SaveProgress)
dramas.GET("/:id/props", propHandler.ListProps) // Added prop list route
}
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("/characters", scriptGenHandler.GenerateCharacters)
}
// 角色库路由
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)
}
props := api.Group("/props")
{
props.POST("", propHandler.CreateProp)
props.PUT("/:id", propHandler.UpdateProp)
props.DELETE("/:id", propHandler.DeleteProp)
props.POST("/:id/generate", propHandler.GenerateImage)
}
// 文件上传路由
upload := api.Group("/upload")
{
upload.POST("/image", uploadHandler.UploadImage)
}
// 分镜头路由
episodes := api.Group("/episodes")
{
// 分镜头
episodes.POST("/:episode_id/storyboards", storyboardHandler.GenerateStoryboard)
episodes.POST("/:episode_id/props/extract", propHandler.ExtractProps)
episodes.POST("/:episode_id/characters/extract", characterLibraryHandler.ExtractCharacters)
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.PUT("/:scene_id/prompt", sceneHandler.UpdateScenePrompt)
scenes.DELETE("/:scene_id", sceneHandler.DeleteScene)
scenes.POST("/generate-image", sceneHandler.GenerateSceneImage)
scenes.POST("", sceneHandler.CreateScene)
}
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.POST("/upload", imageGenHandler.UploadImage)
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.GET("/episode/:episode_id/generate", storyboardHandler.GenerateStoryboard)
storyboards.POST("", storyboardHandler.CreateStoryboard)
storyboards.PUT("/:id", storyboardHandler.UpdateStoryboard)
storyboards.DELETE("/:id", storyboardHandler.DeleteStoryboard)
storyboards.POST("/:id/props", propHandler.AssociateProps)
storyboards.POST("/:id/frame-prompt", framePromptHandler.GenerateFramePrompt)
storyboards.GET("/:id/frame-prompts", handlers2.GetStoryboardFramePrompts(db, log))
}
audio := api.Group("/audio")
{
audio.POST("/extract", audioExtractionHandler.ExtractAudio)
audio.POST("/extract/batch", audioExtractionHandler.BatchExtractAudio)
}
settings := api.Group("/settings")
{
settings.GET("/language", settingsHandler.GetLanguage)
settings.PUT("/language", settingsHandler.UpdateLanguage)
}
}
// 前端静态文件服务(放在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
}
================================================
FILE: application/services/ai_service.go
================================================
package services
import (
"errors"
"fmt"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/ai"
"github.com/drama-generator/backend/pkg/logger"
"gorm.io/gorm"
)
type AIService struct {
db *gorm.DB
log *logger.Logger
}
func NewAIService(db *gorm.DB, log *logger.Logger) *AIService {
return &AIService{
db: db,
log: log,
}
}
type CreateAIConfigRequest struct {
ServiceType string `json:"service_type" binding:"required,oneof=text image video"`
Name string `json:"name" binding:"required,min=1,max=100"`
Provider string `json:"provider" binding:"required"`
BaseURL string `json:"base_url" binding:"required,url"`
APIKey string `json:"api_key" binding:"required"`
Model models.ModelField `json:"model" binding:"required"`
Endpoint string `json:"endpoint"`
QueryEndpoint string `json:"query_endpoint"`
Priority int `json:"priority"`
IsDefault bool `json:"is_default"`
Settings string `json:"settings"`
}
type UpdateAIConfigRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=100"`
Provider string `json:"provider"`
BaseURL string `json:"base_url" binding:"omitempty,url"`
APIKey string `json:"api_key"`
Model *models.ModelField `json:"model"`
Endpoint string `json:"endpoint"`
QueryEndpoint string `json:"query_endpoint"`
Priority *int `json:"priority"`
IsDefault bool `json:"is_default"`
IsActive bool `json:"is_active"`
Settings string `json:"settings"`
}
type TestConnectionRequest struct {
BaseURL string `json:"base_url" binding:"required,url"`
APIKey string `json:"api_key" binding:"required"`
Model models.ModelField `json:"model" binding:"required"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
}
func (s *AIService) CreateConfig(req *CreateAIConfigRequest) (*models.AIServiceConfig, error) {
// 根据 provider 和 service_type 自动设置 endpoint
endpoint := req.Endpoint
queryEndpoint := req.QueryEndpoint
if endpoint == "" {
switch req.Provider {
case "gemini", "google":
if req.ServiceType == "text" {
endpoint = "/v1beta/models/{model}:generateContent"
} else if req.ServiceType == "image" {
endpoint = "/v1beta/models/{model}:generateContent"
}
case "openai":
if req.ServiceType == "text" {
endpoint = "/chat/completions"
} else if req.ServiceType == "image" {
endpoint = "/images/generations"
} else if req.ServiceType == "video" {
endpoint = "/videos"
if queryEndpoint == "" {
queryEndpoint = "/videos/{taskId}"
}
}
case "chatfire":
if req.ServiceType == "text" {
endpoint = "/chat/completions"
} else if req.ServiceType == "image" {
endpoint = "/images/generations"
} else if req.ServiceType == "video" {
endpoint = "/video/generations"
if queryEndpoint == "" {
queryEndpoint = "/video/task/{taskId}"
}
}
case "doubao", "volcengine", "volces":
if req.ServiceType == "video" {
endpoint = "/contents/generations/tasks"
if queryEndpoint == "" {
queryEndpoint = "/generations/tasks/{taskId}"
}
}
default:
// 默认使用 OpenAI 格式
if req.ServiceType == "text" {
endpoint = "/chat/completions"
} else if req.ServiceType == "image" {
endpoint = "/images/generations"
}
}
}
config := &models.AIServiceConfig{
ServiceType: req.ServiceType,
Name: req.Name,
Provider: req.Provider,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
Model: req.Model,
Endpoint: endpoint,
QueryEndpoint: queryEndpoint,
Priority: req.Priority,
IsDefault: req.IsDefault,
IsActive: true,
Settings: req.Settings,
}
if err := s.db.Create(config).Error; err != nil {
s.log.Errorw("Failed to create AI config", "error", err)
return nil, err
}
s.log.Infow("AI config created", "config_id", config.ID, "provider", req.Provider, "endpoint", endpoint)
return config, nil
}
func (s *AIService) GetConfig(configID uint) (*models.AIServiceConfig, error) {
var config models.AIServiceConfig
err := s.db.Where("id = ? ", configID).First(&config).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("config not found")
}
return nil, err
}
return &config, nil
}
func (s *AIService) ListConfigs(serviceType string) ([]models.AIServiceConfig, error) {
var configs []models.AIServiceConfig
query := s.db
if serviceType != "" {
query = query.Where("service_type = ?", serviceType)
}
err := query.Order("priority DESC, created_at DESC").Find(&configs).Error
if err != nil {
s.log.Errorw("Failed to list AI configs", "error", err)
return nil, err
}
return configs, nil
}
func (s *AIService) UpdateConfig(configID uint, req *UpdateAIConfigRequest) (*models.AIServiceConfig, error) {
var config models.AIServiceConfig
if err := s.db.Where("id = ? ", configID).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("config not found")
}
return nil, err
}
tx := s.db.Begin()
// 不再需要is_default独占逻辑
updates := make(map[string]interface{})
if req.Name != "" {
updates["name"] = req.Name
}
if req.Provider != "" {
updates["provider"] = req.Provider
}
if req.BaseURL != "" {
updates["base_url"] = req.BaseURL
}
if req.APIKey != "" {
updates["api_key"] = req.APIKey
}
if req.Model != nil && len(*req.Model) > 0 {
updates["model"] = *req.Model
}
if req.Priority != nil {
updates["priority"] = *req.Priority
}
// 如果提供了 provider,根据 provider 和 service_type 自动设置 endpoint
if req.Provider != "" && req.Endpoint == "" {
provider := req.Provider
serviceType := config.ServiceType
switch provider {
case "gemini", "google":
if serviceType == "text" || serviceType == "image" {
updates["endpoint"] = "/v1beta/models/{model}:generateContent"
}
case "openai":
if serviceType == "text" {
updates["endpoint"] = "/chat/completions"
} else if serviceType == "image" {
updates["endpoint"] = "/images/generations"
} else if serviceType == "video" {
updates["endpoint"] = "/videos"
updates["query_endpoint"] = "/videos/{taskId}"
}
case "chatfire":
if serviceType == "text" {
updates["endpoint"] = "/chat/completions"
} else if serviceType == "image" {
updates["endpoint"] = "/images/generations"
} else if serviceType == "video" {
updates["endpoint"] = "/video/generations"
updates["query_endpoint"] = "/video/task/{taskId}"
}
}
} else if req.Endpoint != "" {
updates["endpoint"] = req.Endpoint
}
// 允许清空query_endpoint,所以不检查是否为空
updates["query_endpoint"] = req.QueryEndpoint
if req.Settings != "" {
updates["settings"] = req.Settings
}
updates["is_default"] = req.IsDefault
updates["is_active"] = req.IsActive
if err := tx.Model(&config).Updates(updates).Error; err != nil {
tx.Rollback()
s.log.Errorw("Failed to update AI config", "error", err)
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
s.log.Infow("AI config updated", "config_id", configID)
return &config, nil
}
func (s *AIService) DeleteConfig(configID uint) error {
result := s.db.Where("id = ? ", configID).Delete(&models.AIServiceConfig{})
if result.Error != nil {
s.log.Errorw("Failed to delete AI config", "error", result.Error)
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("config not found")
}
s.log.Infow("AI config deleted", "config_id", configID)
return nil
}
func (s *AIService) TestConnection(req *TestConnectionRequest) error {
s.log.Infow("TestConnection called", "baseURL", req.BaseURL, "provider", req.Provider, "endpoint", req.Endpoint, "modelCount", len(req.Model))
// 使用第一个模型进行测试
model := ""
if len(req.Model) > 0 {
model = req.Model[0]
}
s.log.Infow("Using model for test", "model", model, "provider", req.Provider)
// 根据 provider 参数选择客户端
var client ai.AIClient
var endpoint string
switch req.Provider {
case "gemini", "google":
// Gemini
s.log.Infow("Using Gemini client", "baseURL", req.BaseURL)
endpoint = "/v1beta/models/{model}:generateContent"
client = ai.NewGeminiClient(req.BaseURL, req.APIKey, model, endpoint)
case "openai", "chatfire":
// OpenAI 格式(包括 chatfire 等)
s.log.Infow("Using OpenAI-compatible client", "baseURL", req.BaseURL, "provider", req.Provider)
endpoint = req.Endpoint
if endpoint == "" {
endpoint = "/chat/completions"
}
client = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint)
default:
// 默认使用 OpenAI 格式
s.log.Infow("Using default OpenAI-compatible client", "baseURL", req.BaseURL)
endpoint = req.Endpoint
if endpoint == "" {
endpoint = "/chat/completions"
}
client = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint)
}
s.log.Infow("Calling TestConnection on client", "endpoint", endpoint)
err := client.TestConnection()
if err != nil {
s.log.Errorw("TestConnection failed", "error", err)
} else {
s.log.Infow("TestConnection succeeded")
}
return err
}
func (s *AIService) GetDefaultConfig(serviceType string) (*models.AIServiceConfig, error) {
var config models.AIServiceConfig
// 按优先级降序获取第一个激活的配置
err := s.db.Where("service_type = ? 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 active config found for model: " + modelName)
}
func (s *AIService) GetAIClient(serviceType string) (ai.AIClient, error) {
config, err := s.GetDefaultConfig(serviceType)
if err != nil {
return nil, err
}
// 使用第一个模型
model := ""
if len(config.Model) > 0 {
model = config.Model[0]
}
// 使用数据库配置中的 endpoint,如果为空则根据 provider 设置默认值
endpoint := config.Endpoint
if endpoint == "" {
switch config.Provider {
case "gemini", "google":
endpoint = "/v1beta/models/{model}:generateContent"
default:
endpoint = "/chat/completions"
}
}
// 根据 provider 创建对应的客户端
switch config.Provider {
case "gemini", "google":
return ai.NewGeminiClient(config.BaseURL, config.APIKey, model, endpoint), nil
default:
// openai, chatfire 等其他厂商都使用 OpenAI 格式
return ai.NewOpenAIClient(config.BaseURL, config.APIKey, model, endpoint), nil
}
}
// GetAIClientForModel 根据服务类型和模型名称获取对应的AI客户端
func (s *AIService) GetAIClientForModel(serviceType string, modelName string) (ai.AIClient, error) {
config, err := s.GetConfigForModel(serviceType, modelName)
if err != nil {
return nil, err
}
// 使用数据库配置中的 endpoint,如果为空则根据 provider 设置默认值
endpoint := config.Endpoint
if endpoint == "" {
switch config.Provider {
case "gemini", "google":
endpoint = "/v1beta/models/{model}:generateContent"
default:
endpoint = "/chat/completions"
}
}
// 根据 provider 创建对应的客户端
switch config.Provider {
case "gemini", "google":
return ai.NewGeminiClient(config.BaseURL, config.APIKey, modelName, endpoint), nil
default:
// openai, chatfire 等其他厂商都使用 OpenAI 格式
return ai.NewOpenAIClient(config.BaseURL, config.APIKey, modelName, 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...)
}
func (s *AIService) GenerateImage(prompt string, size string, n int) ([]string, error) {
client, err := s.GetAIClient("image")
if err != nil {
return nil, fmt.Errorf("failed to get AI client for image: %w", err)
}
return client.GenerateImage(prompt, size, n)
}
================================================
FILE: application/services/asset_duration_update.go
================================================
package services
import (
"fmt"
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/infrastructure/storage"
)
// UpdateAssetDurationFromFile 从本地文件探测并更新视频Asset的时长
func (s *AssetService) UpdateAssetDurationFromFile(assetID uint, localFilePath string) error {
var asset models.Asset
if err := s.db.Where("id = ?", assetID).First(&asset).Error; err != nil {
return fmt.Errorf("asset not found")
}
if asset.Type != models.AssetTypeVideo {
return fmt.Errorf("asset is not a video")
}
if s.ffmpeg == nil {
return fmt.Errorf("ffmpeg not available")
}
duration, err := s.ffmpeg.GetVideoDuration(localFilePath)
if err != nil {
return fmt.Errorf("failed to probe video duration: %w", err)
}
durationInt := int(duration + 0.5)
if err := s.db.Model(&asset).Update("duration", durationInt).Error; err != nil {
return fmt.Errorf("failed to update duration: %w", err)
}
s.log.Infow("Updated asset duration from file",
"asset_id", assetID,
"duration", durationInt,
"file", localFilePath)
return nil
}
// UpdateAssetDurationFromURL 下载视频并探测时长
func (s *AssetService) UpdateAssetDurationFromURL(assetID uint, localStorage *storage.LocalStorage) error {
var asset models.Asset
if err := s.db.Where("id = ?", assetID).First(&asset).Error; err != nil {
return fmt.Errorf("asset not found")
}
if asset.Type != models.AssetTypeVideo {
return fmt.Errorf("asset is not a video")
}
if localStorage == nil {
return fmt.Errorf("local storage not available")
}
// 下载视频到本地
localPath, err := localStorage.DownloadFromURL(asset.URL, "videos")
if err != nil {
return fmt.Errorf("failed to download video: %w", err)
}
// 探测时长
return s.UpdateAssetDurationFromFile(assetID, localPath)
}
================================================
FILE: application/services/asset_service.go
================================================
package services
import (
"fmt"
"strconv"
"strings"
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/infrastructure/external/ffmpeg"
"github.com/drama-generator/backend/pkg/logger"
"gorm.io/gorm"
)
type AssetService struct {
db *gorm.DB
log *logger.Logger
ffmpeg *ffmpeg.FFmpeg
}
func NewAssetService(db *gorm.DB, log *logger.Logger) *AssetService {
return &AssetService{
db: db,
log: log,
ffmpeg: ffmpeg.NewFFmpeg(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,
LocalPath: videoGen.LocalPath, // 同步 local_path 到 assets 表
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
}
================================================
FILE: application/services/audio_extraction_service.go
================================================
package services
import (
"fmt"
"path/filepath"
"time"
"github.com/drama-generator/backend/infrastructure/external/ffmpeg"
"github.com/drama-generator/backend/pkg/logger"
)
type AudioExtractionService struct {
ffmpeg *ffmpeg.FFmpeg
log *logger.Logger
}
func NewAudioExtractionService(log *logger.Logger) *AudioExtractionService {
return &AudioExtractionService{
ffmpeg: ffmpeg.NewFFmpeg(log),
log: log,
}
}
type ExtractAudioRequest struct {
VideoURL string `json:"video_url" binding:"required"`
}
type ExtractAudioResponse struct {
AudioURL string `json:"audio_url"`
Duration float64 `json:"duration"`
}
// ExtractAudio 从视频URL提取音频并返回音频文件URL
func (s *AudioExtractionService) ExtractAudio(videoURL string, dataDir string) (*ExtractAudioResponse, error) {
s.log.Infow("Starting audio extraction", "video_url", videoURL)
// 生成输出文件名
timestamp := time.Now().Unix()
audioFileName := fmt.Sprintf("audio_%d.aac", timestamp)
audioOutputPath := filepath.Join(dataDir, "audios", audioFileName)
// 提取音频
extractedPath, err := s.ffmpeg.ExtractAudio(videoURL, audioOutputPath)
if err != nil {
s.log.Errorw("Failed to extract audio", "error", err, "video_url", videoURL)
return nil, fmt.Errorf("failed to extract audio: %w", err)
}
// 获取音频时长(使用提取后的本地文件路径)
duration, err := s.ffmpeg.GetVideoDuration(extractedPath)
if err != nil {
s.log.Errorw("Failed to get audio duration", "error", err, "path", extractedPath)
return nil, fmt.Errorf("failed to get audio duration: %w", err)
}
if duration <= 0 {
s.log.Errorw("Invalid audio duration", "duration", duration, "path", extractedPath)
return nil, fmt.Errorf("invalid audio duration: %.2f", duration)
}
// 构建音频URL(相对于data目录)
audioURL := fmt.Sprintf("/data/audios/%s", audioFileName)
s.log.Infow("Audio extraction completed",
"video_url", videoURL,
"audio_url", audioURL,
"duration", duration,
"local_path", extractedPath)
return &ExtractAudioResponse{
AudioURL: audioURL,
Duration: duration,
}, nil
}
// BatchExtractAudio 批量提取音频
func (s *AudioExtractionService) BatchExtractAudio(videoURLs []string, dataDir string) ([]*ExtractAudioResponse, error) {
s.log.Infow("Starting batch audio extraction", "count", len(videoURLs))
results := make([]*ExtractAudioResponse, 0, len(videoURLs))
for i, videoURL := range videoURLs {
s.log.Infow("Extracting audio", "index", i+1, "total", len(videoURLs), "video_url", videoURL)
result, err := s.ExtractAudio(videoURL, dataDir)
if err != nil {
s.log.Errorw("Failed to extract audio in batch", "index", i, "video_url", videoURL, "error", err)
// 继续处理其他视频,但记录错误
return nil, fmt.Errorf("failed to extract audio at index %d: %w", i, err)
}
results = append(results, result)
}
s.log.Infow("Batch audio extraction completed", "successful_count", len(results))
return results, nil
}
================================================
FILE: application/services/character_library_service.go
================================================
package services
import (
"errors"
"fmt"
"time"
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/ai"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/utils"
"gorm.io/gorm"
)
type CharacterLibraryService struct {
db *gorm.DB
log *logger.Logger
config *config.Config
aiService *AIService
taskService *TaskService
promptI18n *PromptI18n
}
func NewCharacterLibraryService(db *gorm.DB, log *logger.Logger, cfg *config.Config) *CharacterLibraryService {
return &CharacterLibraryService{
db: db,
log: log,
config: cfg,
aiService: NewAIService(db, log),
taskService: NewTaskService(db, log),
promptI18n: NewPromptI18n(cfg),
}
}
type CreateLibraryItemRequest struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Category *string `json:"category"`
ImageURL string `json:"image_url" binding:"required"`
LocalPath *string `json:"local_path"`
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,
LocalPath: req.LocalPath,
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
}
// 更新角色的 local_path 和 image_url
updates := map[string]interface{}{}
if libraryItem.LocalPath != nil && *libraryItem.LocalPath != "" {
updates["local_path"] = libraryItem.LocalPath
}
if libraryItem.ImageURL != "" {
updates["image_url"] = libraryItem.ImageURL
}
if len(updates) > 0 {
if err := s.db.Model(&character).Updates(updates).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,
LocalPath: character.LocalPath,
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, style 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
}
// 使用已经加载的 drama 的 style 信息
if drama.Style != "" && drama.Style != "realistic" {
prompt += ", " + drama.Style
}
// 调用图片生成服务
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)
}
type UpdateCharacterRequest struct {
Name *string `json:"name"`
Role *string `json:"role"`
Appearance *string `json:"appearance"`
Personality *string `json:"personality"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
LocalPath *string `json:"local_path"`
}
// UpdateCharacter 更新角色信息
func (s *CharacterLibraryService) UpdateCharacter(characterID string, req *UpdateCharacterRequest) 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 req.Name != nil && *req.Name != "" {
updates["name"] = *req.Name
}
if req.Role != nil {
updates["role"] = *req.Role
}
if req.Appearance != nil {
updates["appearance"] = *req.Appearance
}
if req.Personality != nil {
updates["personality"] = *req.Personality
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.ImageURL != nil {
updates["image_url"] = *req.ImageURL
}
if req.LocalPath != nil {
updates["local_path"] = *req.LocalPath
}
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, "updates", updates)
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))
}
// ExtractCharactersFromScript 从分集剧本中提取角色
func (s *CharacterLibraryService) ExtractCharactersFromScript(episodeID uint) (string, error) {
var episode models.Episode
if err := s.db.First(&episode, episodeID).Error; err != nil {
return "", fmt.Errorf("episode not found")
}
if episode.ScriptContent == nil || *episode.ScriptContent == "" {
return "", fmt.Errorf("剧本内容为空")
}
task, err := s.taskService.CreateTask("character_extraction", fmt.Sprintf("%d", episode.DramaID))
if err != nil {
return "", fmt.Errorf("创建任务失败: %w", err)
}
go s.processCharacterExtraction(task.ID, episode)
return task.ID, nil
}
func (s *CharacterLibraryService) processCharacterExtraction(taskID string, episode models.Episode) {
s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在分析剧本...")
script := ""
if episode.ScriptContent != nil {
script = *episode.ScriptContent
}
// 获取 drama 的 style 信息
var drama models.Drama
if err := s.db.First(&drama, episode.DramaID).Error; err != nil {
s.log.Warnw("Failed to load drama", "error", err, "drama_id", episode.DramaID)
}
prompt := s.promptI18n.GetCharacterExtractionPrompt(drama.Style)
userPrompt := fmt.Sprintf("【剧本内容】\n%s", script)
response, err := s.aiService.GenerateText(userPrompt, prompt, ai.WithMaxTokens(3000))
if err != nil {
s.taskService.UpdateTaskError(taskID, err)
return
}
s.taskService.UpdateTaskStatus(taskID, "processing", 50, "正在整理角色数据...")
var extractedCharacters []struct {
Name string `json:"name"`
Role string `json:"role"`
Appearance string `json:"appearance"`
Personality string `json:"personality"`
Description string `json:"description"`
}
if err := utils.SafeParseAIJSON(response, &extractedCharacters); err != nil {
s.log.Errorw("Failed to parse AI response for characters", "error", err, "response", response)
s.taskService.UpdateTaskError(taskID, fmt.Errorf("解析AI响应失败"))
return
}
var savedCharacters []models.Character
for _, charData := range extractedCharacters {
// 检查是否已存在同名角色
var existingCharacter models.Character
err := s.db.Where("drama_id = ? AND name = ?", episode.DramaID, charData.Name).First(&existingCharacter).Error
if err == nil {
// 如果存在,只关联,不更新(或者可以选更新,这里暂不更新)
if err := s.db.Model(&episode).Association("Characters").Append(&existingCharacter); err != nil {
s.log.Warnw("Failed to associate existing character", "error", err)
}
savedCharacters = append(savedCharacters, existingCharacter)
} else {
// 创建新角色
newCharacter := models.Character{
DramaID: episode.DramaID,
Name: charData.Name,
Role: &charData.Role,
Appearance: &charData.Appearance,
Personality: &charData.Personality,
Description: &charData.Description,
}
if err := s.db.Create(&newCharacter).Error; err != nil {
s.log.Errorw("Failed to create extracted character", "error", err)
continue
}
// 关联到分集
if err := s.db.Model(&episode).Association("Characters").Append(&newCharacter); err != nil {
s.log.Warnw("Failed to associate new character", "error", err)
}
savedCharacters = append(savedCharacters, newCharacter)
}
}
s.taskService.UpdateTaskResult(taskID, map[string]interface{}{
"characters": savedCharacters,
"count": len(savedCharacters),
})
}
================================================
FILE: application/services/data_migration_service.go
================================================
package services
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/logger"
"gorm.io/gorm"
)
type DataMigrationService struct {
db *gorm.DB
log *logger.Logger
storageRoot string
urlMapping map[string]string // 原始URL -> 本地路径的映射
}
func NewDataMigrationService(db *gorm.DB, log *logger.Logger) *DataMigrationService {
return &DataMigrationService{
db: db,
log: log,
storageRoot: "data/storage",
urlMapping: make(map[string]string),
}
}
// MigrateLocalPaths 迁移所有表中 local_path 为空的数据
func (s *DataMigrationService) MigrateLocalPaths() error {
s.log.Info("开始数据清洗:迁移 local_path 为空的数据")
startTime := time.Now()
// 确保存储目录存在
if err := s.ensureStorageDirectories(); err != nil {
return fmt.Errorf("创建存储目录失败: %w", err)
}
// 迁移各个表的数据(按指定顺序)
stats := &MigrationStats{}
// 1. 迁移 assets 表
if err := s.migrateAssets(stats); err != nil {
s.log.Errorw("迁移 assets 数据失败", "error", err)
}
// 2. 迁移 character_libraries 表
if err := s.migrateCharacterLibraries(stats); err != nil {
s.log.Errorw("迁移 character_libraries 数据失败", "error", err)
}
// 3. 迁移 characters 表
if err := s.migrateCharacters(stats); err != nil {
s.log.Errorw("迁移 characters 数据失败", "error", err)
}
// 4. 迁移 image_generations 表
if err := s.migrateImageGenerations(stats); err != nil {
s.log.Errorw("迁移 image_generations 数据失败", "error", err)
}
// 5. 迁移 scenes 表
if err := s.migrateScenes(stats); err != nil {
s.log.Errorw("迁移 scenes 数据失败", "error", err)
}
// 6. 迁移 video_generations 表
if err := s.migrateVideoGenerations(stats); err != nil {
s.log.Errorw("迁移 video_generations 数据失败", "error", err)
}
duration := time.Since(startTime)
s.log.Infow("数据清洗完成",
"总耗时", duration.String(),
"URL映射缓存数", len(s.urlMapping),
"Assets成功", stats.AssetsSuccess,
"Assets失败", stats.AssetsFailed,
"角色库成功", stats.CharacterLibrariesSuccess,
"角色库失败", stats.CharacterLibrariesFailed,
"角色成功", stats.CharactersSuccess,
"角色失败", stats.CharactersFailed,
"图片生成成功", stats.ImageGenerationsSuccess,
"图片生成失败", stats.ImageGenerationsFailed,
"场景成功", stats.ScenesSuccess,
"场景失败", stats.ScenesFailed,
"视频成功", stats.VideosSuccess,
"视频失败", stats.VideosFailed,
)
return nil
}
// MigrationStats 迁移统计信息
type MigrationStats struct {
AssetsSuccess int
AssetsFailed int
CharacterLibrariesSuccess int
CharacterLibrariesFailed int
CharactersSuccess int
CharactersFailed int
ImageGenerationsSuccess int
ImageGenerationsFailed int
ScenesSuccess int
ScenesFailed int
VideosSuccess int
VideosFailed int
}
// ensureStorageDirectories 确保存储目录存在
func (s *DataMigrationService) ensureStorageDirectories() error {
dirs := []string{
filepath.Join(s.storageRoot, "images"),
filepath.Join(s.storageRoot, "characters"),
filepath.Join(s.storageRoot, "videos"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", dir, err)
}
}
s.log.Infow("存储目录创建成功", "root", s.storageRoot)
return nil
}
// migrateAssets 迁移 assets 表数据
func (s *DataMigrationService) migrateAssets(stats *MigrationStats) error {
s.log.Info("开始迁移 assets 数据...")
var assets []models.Asset
// 查询 local_path 为空但 url 不为空的资源
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND url IS NOT NULL AND url != ''").Find(&assets).Error; err != nil {
return fmt.Errorf("查询 assets 数据失败: %w", err)
}
s.log.Infow("找到需要迁移的 assets", "数量", len(assets))
for _, asset := range assets {
s.log.Infow("处理 asset", "id", asset.ID, "name", asset.Name, "type", asset.Type, "url", asset.URL)
// 根据类型选择存储目录
subDir := "images"
if asset.Type == models.AssetTypeVideo {
subDir = "videos"
}
localPath, err := s.downloadOrGetCached(asset.URL, subDir, fmt.Sprintf("asset_%d", asset.ID))
if err != nil {
s.log.Errorw("下载 asset 失败", "asset_id", asset.ID, "error", err)
stats.AssetsFailed++
continue
}
// 更新 local_path
if err := s.db.Model(&asset).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新 asset local_path 失败", "asset_id", asset.ID, "error", err)
stats.AssetsFailed++
continue
}
s.log.Infow("asset 迁移成功", "asset_id", asset.ID, "local_path", localPath)
stats.AssetsSuccess++
}
return nil
}
// migrateCharacterLibraries 迁移 character_libraries 表数据
func (s *DataMigrationService) migrateCharacterLibraries(stats *MigrationStats) error {
s.log.Info("开始迁移 character_libraries 数据...")
var charLibs []models.CharacterLibrary
// 查询 local_path 为空但 image_url 不为空的角色库
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&charLibs).Error; err != nil {
return fmt.Errorf("查询 character_libraries 数据失败: %w", err)
}
s.log.Infow("找到需要迁移的 character_libraries", "数量", len(charLibs))
for _, charLib := range charLibs {
s.log.Infow("处理 character_library", "id", charLib.ID, "name", charLib.Name, "image_url", charLib.ImageURL)
localPath, err := s.downloadOrGetCached(charLib.ImageURL, "characters", fmt.Sprintf("charlib_%d", charLib.ID))
if err != nil {
s.log.Errorw("下载 character_library 图片失败", "charlib_id", charLib.ID, "error", err)
stats.CharacterLibrariesFailed++
continue
}
// 更新 local_path
if err := s.db.Model(&charLib).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新 character_library local_path 失败", "charlib_id", charLib.ID, "error", err)
stats.CharacterLibrariesFailed++
continue
}
s.log.Infow("character_library 迁移成功", "charlib_id", charLib.ID, "local_path", localPath)
stats.CharacterLibrariesSuccess++
}
return nil
}
// migrateImageGenerations 迁移 image_generations 表数据
func (s *DataMigrationService) migrateImageGenerations(stats *MigrationStats) error {
s.log.Info("开始迁移 image_generations 数据...")
var imageGens []models.ImageGeneration
// 查询 local_path 为空但 image_url 不为空的图片生成记录
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&imageGens).Error; err != nil {
return fmt.Errorf("查询 image_generations 数据失败: %w", err)
}
s.log.Infow("找到需要迁移的 image_generations", "数量", len(imageGens))
for _, imageGen := range imageGens {
if imageGen.ImageURL == nil {
continue
}
imageTypeStr := string(imageGen.ImageType)
s.log.Infow("处理 image_generation", "id", imageGen.ID, "image_type", imageTypeStr, "image_url", *imageGen.ImageURL)
// 根据图片类型选择存储目录
subDir := "images"
if imageGen.ImageType == "character" {
subDir = "characters"
}
localPath, err := s.downloadOrGetCached(*imageGen.ImageURL, subDir, fmt.Sprintf("imggen_%d", imageGen.ID))
if err != nil {
s.log.Errorw("下载 image_generation 图片失败", "imggen_id", imageGen.ID, "error", err)
stats.ImageGenerationsFailed++
continue
}
// 更新 local_path
if err := s.db.Model(&imageGen).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新 image_generation local_path 失败", "imggen_id", imageGen.ID, "error", err)
stats.ImageGenerationsFailed++
continue
}
s.log.Infow("image_generation 迁移成功", "imggen_id", imageGen.ID, "local_path", localPath)
stats.ImageGenerationsSuccess++
}
return nil
}
// migrateScenes 迁移场景数据
func (s *DataMigrationService) migrateScenes(stats *MigrationStats) error {
s.log.Info("开始迁移场景数据...")
var scenes []models.Scene
// 查询 local_path 为空但 image_url 不为空的场景
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&scenes).Error; err != nil {
return fmt.Errorf("查询场景数据失败: %w", err)
}
s.log.Infow("找到需要迁移的场景", "数量", len(scenes))
for _, scene := range scenes {
if scene.ImageURL == nil {
continue
}
s.log.Infow("处理场景", "id", scene.ID, "location", scene.Location, "image_url", *scene.ImageURL)
localPath, err := s.downloadOrGetCached(*scene.ImageURL, "images", fmt.Sprintf("scene_%d", scene.ID))
if err != nil {
s.log.Errorw("下载场景图片失败", "scene_id", scene.ID, "error", err)
stats.ScenesFailed++
continue
}
// 更新 local_path
if err := s.db.Model(&scene).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新场景 local_path 失败", "scene_id", scene.ID, "error", err)
stats.ScenesFailed++
continue
}
s.log.Infow("场景迁移成功", "scene_id", scene.ID, "local_path", localPath)
stats.ScenesSuccess++
}
return nil
}
// migrateCharacters 迁移角色数据
func (s *DataMigrationService) migrateCharacters(stats *MigrationStats) error {
s.log.Info("开始迁移角色数据...")
var characters []models.Character
// 查询 local_path 为空但 image_url 不为空的角色
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&characters).Error; err != nil {
return fmt.Errorf("查询角色数据失败: %w", err)
}
s.log.Infow("找到需要迁移的角色", "数量", len(characters))
for _, character := range characters {
if character.ImageURL == nil {
continue
}
s.log.Infow("处理角色", "id", character.ID, "name", character.Name, "image_url", *character.ImageURL)
localPath, err := s.downloadOrGetCached(*character.ImageURL, "characters", fmt.Sprintf("character_%d", character.ID))
if err != nil {
s.log.Errorw("下载角色图片失败", "character_id", character.ID, "error", err)
stats.CharactersFailed++
continue
}
// 更新 local_path
if err := s.db.Model(&character).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新角色 local_path 失败", "character_id", character.ID, "error", err)
stats.CharactersFailed++
continue
}
s.log.Infow("角色迁移成功", "character_id", character.ID, "local_path", localPath)
stats.CharactersSuccess++
}
return nil
}
// migrateVideoGenerations 迁移视频生成数据
func (s *DataMigrationService) migrateVideoGenerations(stats *MigrationStats) error {
s.log.Info("开始迁移视频生成数据...")
var videoGens []models.VideoGeneration
// 查询 local_path 为空但 video_url 不为空的视频
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND video_url IS NOT NULL AND video_url != ''").Find(&videoGens).Error; err != nil {
return fmt.Errorf("查询视频生成数据失败: %w", err)
}
s.log.Infow("找到需要迁移的视频", "数量", len(videoGens))
for _, videoGen := range videoGens {
if videoGen.VideoURL == nil {
continue
}
s.log.Infow("处理视频", "id", videoGen.ID, "video_url", *videoGen.VideoURL)
localPath, err := s.downloadOrGetCached(*videoGen.VideoURL, "videos", fmt.Sprintf("video_%d", videoGen.ID))
if err != nil {
s.log.Errorw("下载视频失败", "video_gen_id", videoGen.ID, "error", err)
stats.VideosFailed++
continue
}
// 更新 local_path
if err := s.db.Model(&videoGen).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新视频 local_path 失败", "video_gen_id", videoGen.ID, "error", err)
stats.VideosFailed++
continue
}
s.log.Infow("视频迁移成功", "video_gen_id", videoGen.ID, "local_path", localPath)
stats.VideosSuccess++
}
return nil
}
// downloadOrGetCached 下载文件或从缓存获取本地路径
func (s *DataMigrationService) downloadOrGetCached(url, subDir, prefix string) (string, error) {
// 1. 检查 URL 映射缓存
if localPath, exists := s.urlMapping[url]; exists {
s.log.Infow("使用缓存的本地路径", "url", url, "local_path", localPath)
return localPath, nil
}
// 2. 如果缓存中没有,则下载文件
var localPath string
var err error
// 根据子目录判断是图片还是视频
if subDir == "videos" {
localPath, err = s.downloadAndSaveVideo(url, subDir, prefix)
} else {
localPath, err = s.downloadAndSaveImage(url, subDir, prefix)
}
if err != nil {
return "", err
}
// 3. 将 URL 和本地路径的映射关系存入缓存
s.urlMapping[url] = localPath
s.log.Infow("已缓存 URL 映射", "url", url, "local_path", localPath)
return localPath, nil
}
// downloadAndSaveImage 下载并保存图片
func (s *DataMigrationService) downloadAndSaveImage(imageURL, subDir, prefix string) (string, error) {
if imageURL == "" {
return "", fmt.Errorf("图片 URL 为空")
}
// 如果已经是本地路径,直接返回
if strings.HasPrefix(imageURL, "/static/") || strings.HasPrefix(imageURL, "data/") {
return imageURL, nil
}
// 从 URL 中提取文件扩展名(去掉查询参数)
ext := s.extractFileExtension(imageURL)
// 生成文件名
timestamp := time.Now().Unix()
filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext)
relativePath := filepath.Join(subDir, filename)
fullPath := filepath.Join(s.storageRoot, relativePath)
// 下载文件
if err := s.downloadFile(imageURL, fullPath); err != nil {
return "", fmt.Errorf("下载文件失败: %w", err)
}
// 返回相对路径(用于存储到数据库)
return relativePath, nil
}
// downloadAndSaveVideo 下载并保存视频
func (s *DataMigrationService) downloadAndSaveVideo(videoURL, subDir, prefix string) (string, error) {
if videoURL == "" {
return "", fmt.Errorf("视频 URL 为空")
}
// 如果已经是本地路径,直接返回
if strings.HasPrefix(videoURL, "/static/") || strings.HasPrefix(videoURL, "data/") {
return videoURL, nil
}
// 从 URL 中提取文件扩展名(去掉查询参数)
ext := s.extractFileExtension(videoURL)
if ext == "" || ext == ".jpeg" || ext == ".jpg" || ext == ".png" {
ext = ".mp4" // 视频默认扩展名
}
// 生成文件名
timestamp := time.Now().Unix()
filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext)
relativePath := filepath.Join(subDir, filename)
fullPath := filepath.Join(s.storageRoot, relativePath)
// 下载文件
if err := s.downloadFile(videoURL, fullPath); err != nil {
return "", fmt.Errorf("下载文件失败: %w", err)
}
// 返回相对路径(用于存储到数据库)
return relativePath, nil
}
// extractFileExtension 从 URL 中提取文件扩展名(去掉查询参数)
func (s *DataMigrationService) extractFileExtension(url string) string {
// 去掉查询参数
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}
// 去掉 fragment
if idx := strings.Index(url, "#"); idx != -1 {
url = url[:idx]
}
// 获取文件扩展名
ext := filepath.Ext(url)
if ext == "" {
// 如果没有扩展名,默认返回 .jpg
return ".jpg"
}
// 转换为小写
ext = strings.ToLower(ext)
// 验证扩展名是否合理(限制长度)
if len(ext) > 10 {
return ".jpg"
}
return ext
}
// downloadFile 下载文件到指定路径
func (s *DataMigrationService) downloadFile(url, filepath string) error {
s.log.Infow("开始下载文件", "url", url, "filepath", filepath)
// 创建 HTTP 请求
client := &http.Client{
Timeout: 60 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP 状态码错误: %d", resp.StatusCode)
}
// 创建文件
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
// 复制内容
written, err := io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
s.log.Infow("文件下载成功", "filepath", filepath, "size", written)
return nil
}
================================================
FILE: application/services/drama_service.go
================================================
package services
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"gorm.io/gorm"
)
type DramaService struct {
db *gorm.DB
log *logger.Logger
baseURL string
}
func NewDramaService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *DramaService {
return &DramaService{
db: db,
log: log,
baseURL: cfg.Storage.BaseURL,
}
}
type CreateDramaRequest struct {
Title string `json:"title" binding:"required,min=1,max=100"`
Description string `json:"description"`
Genre string `json:"genre"`
Style string `json:"style"`
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"`
Style string `json:"style"`
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",
Style: "ghibli", // 默认风格
}
if req.Description != "" {
drama.Description = &req.Description
}
if req.Genre != "" {
drama.Genre = &req.Genre
}
if req.Style != "" {
drama.Style = req.Style
}
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("Props"). // 加载Drama级别的道具
Preload("Episodes.Characters"). // 加载每个章节关联的角色
Preload("Episodes.Scenes"). // 加载每个章节关联的场景
Preload("Episodes.Storyboards", func(db *gorm.DB) *gorm.DB {
return db.Order("storyboards.storyboard_number ASC")
}).
Preload("Episodes.Storyboards.Props"). // 加载分镜关联的道具
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)
}
// 为所有场景的 local_path 添加 base_url 前缀
// s.addBaseURLToScenes(&drama)
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.Style != "" {
updates["style"] = req.Style
}
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 {
// 1. 如果提供了ID,尝试更新已有角色
if char.ID > 0 {
var existing models.Character
if err := s.db.Where("id = ? AND drama_id = ?", char.ID, dramaIDUint).First(&existing).Error; err == nil {
// 更新角色信息
updates := map[string]interface{}{
"name": char.Name,
"role": char.Role,
"description": char.Description,
"personality": char.Personality,
"appearance": char.Appearance,
"image_url": char.ImageURL,
}
if err := s.db.Model(&existing).Updates(updates).Error; err != nil {
s.log.Errorw("Failed to update character", "error", err, "id", char.ID)
}
characterIDs = append(characterIDs, existing.ID)
continue
}
}
// 2. 如果没有ID但名字已存在,直接复用(可选:也可以选择更新)
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
}
// 3. 角色不存在,创建新角色
character := models.Character{
DramaID: dramaIDUint,
Name: char.Name,
Role: char.Role,
Description: char.Description,
Personality: char.Personality,
Appearance: char.Appearance,
ImageURL: char.ImageURL,
}
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
}
// addBaseURLToScenes 为剧本中所有场景的 local_path 添加 base_url 前缀
func (s *DramaService) addBaseURLToScenes(drama *models.Drama) {
// 处理 drama.Scenes
for i := range drama.Scenes {
if drama.Scenes[i].LocalPath != nil && *drama.Scenes[i].LocalPath != "" {
fullPath := fmt.Sprintf("%s/%s", s.baseURL, *drama.Scenes[i].LocalPath)
drama.Scenes[i].LocalPath = &fullPath
}
}
// 处理 drama.Episodes[].Scenes
for i := range drama.Episodes {
for j := range drama.Episodes[i].Scenes {
if drama.Episodes[i].Scenes[j].LocalPath != nil && *drama.Episodes[i].Scenes[j].LocalPath != "" {
fullPath := fmt.Sprintf("%s/%s", s.baseURL, *drama.Episodes[i].Scenes[j].LocalPath)
drama.Episodes[i].Scenes[j].LocalPath = &fullPath
}
}
}
}
================================================
FILE: application/services/frame_prompt_helper.go
================================================
package services
import (
"encoding/json"
"regexp"
"strings"
)
// parseFramePromptJSON 解析AI返回的JSON格式提示词
func (s *FramePromptService) parseFramePromptJSON(aiResponse string) *SingleFramePrompt {
// 清理可能的markdown代码块标记
cleaned := strings.TrimSpace(aiResponse)
// 移除 ```json 和 ``` 标记
re := regexp.MustCompile("(?s)```json\\s*(.+?)\\s*```")
if matches := re.FindStringSubmatch(cleaned); len(matches) > 1 {
cleaned = strings.TrimSpace(matches[1])
} else {
// 移除单独的 ``` 标记
cleaned = strings.Trim(cleaned, "`")
cleaned = strings.TrimSpace(cleaned)
}
// 尝试解析JSON
var result SingleFramePrompt
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
s.log.Warnw("Failed to parse JSON", "error", err, "cleaned_response", cleaned)
return nil
}
// 验证必需字段
if result.Prompt == "" {
s.log.Warnw("Parsed JSON missing prompt field", "response", cleaned)
return nil
}
return &result
}
================================================
FILE: application/services/frame_prompt_service.go
================================================
package services
import (
"fmt"
"strings"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"gorm.io/gorm"
)
// FramePromptService 处理帧提示词生成
type FramePromptService struct {
db *gorm.DB
aiService *AIService
log *logger.Logger
config *config.Config
promptI18n *PromptI18n
taskService *TaskService
}
// NewFramePromptService 创建帧提示词服务
func NewFramePromptService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *FramePromptService {
return &FramePromptService{
db: db,
aiService: NewAIService(db, log),
log: log,
config: cfg,
promptI18n: NewPromptI18n(cfg),
taskService: NewTaskService(db, 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, model string) (string, error) {
// 查询分镜信息
var storyboard models.Storyboard
if err := s.db.Preload("Characters").First(&storyboard, req.StoryboardID).Error; err != nil {
return "", fmt.Errorf("storyboard not found: %w", err)
}
// 创建任务
task, err := s.taskService.CreateTask("frame_prompt_generation", req.StoryboardID)
if err != nil {
s.log.Errorw("Failed to create frame prompt generation task", "error", err, "storyboard_id", req.StoryboardID)
return "", fmt.Errorf("创建任务失败: %w", err)
}
// 异步处理帧提示词生成
go s.processFramePromptGeneration(task.ID, req, model)
s.log.Infow("Frame prompt generation task created", "task_id", task.ID, "storyboard_id", req.StoryboardID, "frame_type", req.FrameType)
return task.ID, nil
}
// processFramePromptGeneration 异步处理帧提示词生成
func (s *FramePromptService) processFramePromptGeneration(taskID string, req GenerateFramePromptRequest, model string) {
// 更新任务状态为处理中
s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在生成帧提示词...")
// 查询分镜信息
var storyboard models.Storyboard
if err := s.db.Preload("Characters").First(&storyboard, req.StoryboardID).Error; err != nil {
s.log.Errorw("Storyboard not found during frame prompt generation", "error", err, "storyboard_id", req.StoryboardID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "分镜信息不存在")
return
}
// 获取场景信息
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 during frame prompt generation", "scene_id", *storyboard.SceneID, "task_id", taskID)
scene = nil
}
}
// 获取 drama 的 style 信息
var episode models.Episode
if err := s.db.Preload("Drama").First(&episode, storyboard.EpisodeID).Error; err != nil {
s.log.Warnw("Failed to load episode and drama", "error", err, "episode_id", storyboard.EpisodeID)
}
dramaStyle := episode.Drama.Style
response := &FramePromptResponse{
FrameType: req.FrameType,
}
// 生成提示词
switch req.FrameType {
case FrameTypeFirst:
response.SingleFrame = s.generateFirstFrame(storyboard, scene, dramaStyle, model)
// 保存单帧提示词
s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "")
case FrameTypeKey:
response.SingleFrame = s.generateKeyFrame(storyboard, scene, dramaStyle, model)
s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "")
case FrameTypeLast:
response.SingleFrame = s.generateLastFrame(storyboard, scene, dramaStyle, model)
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, dramaStyle, model)
// 保存多帧提示词(合并为一条记录)
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, dramaStyle, model)
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:
s.log.Errorw("Unsupported frame type during frame prompt generation", "frame_type", req.FrameType, "task_id", taskID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "不支持的帧类型")
return
}
// 更新任务状态为完成
s.taskService.UpdateTaskResult(taskID, map[string]interface{}{
"response": response,
"storyboard_id": req.StoryboardID,
"frame_type": string(req.FrameType),
})
s.log.Infow("Frame prompt generation completed", "task_id", taskID, "storyboard_id", req.StoryboardID, "frame_type", req.FrameType)
}
// 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, dramaStyle string, model string) *SingleFramePrompt {
// 构建上下文信息
contextInfo := s.buildStoryboardContext(sb, scene)
// 使用国际化提示词
systemPrompt := s.promptI18n.GetFirstFramePrompt(dramaStyle)
userPrompt := s.promptI18n.FormatUserPrompt("frame_info", contextInfo)
// 调用AI生成(如果指定了模型则使用指定的模型)
var aiResponse string
var err error
if model != "" {
client, getErr := s.aiService.GetAIClientForModel("text", model)
if getErr != nil {
s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr)
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
} else {
aiResponse, err = client.GenerateText(userPrompt, systemPrompt)
}
} else {
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
}
if err != nil {
s.log.Warnw("AI generation failed, using fallback", "error", err)
// 降级方案:使用简单拼接
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "first frame, static shot")
return &SingleFramePrompt{
Prompt: fallbackPrompt,
Description: "镜头开始的静态画面,展示初始状态",
}
}
// 解析AI返回的JSON
result := s.parseFramePromptJSON(aiResponse)
if result == nil {
// JSON解析失败,使用降级方案
s.log.Warnw("Failed to parse AI JSON response, using fallback", "storyboard_id", sb.ID, "response", aiResponse)
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "first frame, static shot")
return &SingleFramePrompt{
Prompt: fallbackPrompt,
Description: "镜头开始的静态画面,展示初始状态",
}
}
return result
}
// generateKeyFrame 生成关键帧提示词
func (s *FramePromptService) generateKeyFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt {
// 构建上下文信息
contextInfo := s.buildStoryboardContext(sb, scene)
// 使用国际化提示词
systemPrompt := s.promptI18n.GetKeyFramePrompt(dramaStyle)
userPrompt := s.promptI18n.FormatUserPrompt("key_frame_info", contextInfo)
// 调用AI生成(如果指定了模型则使用指定的模型)
var aiResponse string
var err error
if model != "" {
client, getErr := s.aiService.GetAIClientForModel("text", model)
if getErr != nil {
s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr)
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
} else {
aiResponse, err = client.GenerateText(userPrompt, systemPrompt)
}
} else {
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
}
if err != nil {
s.log.Warnw("AI generation failed, using fallback", "error", err)
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "key frame, dynamic action")
return &SingleFramePrompt{
Prompt: fallbackPrompt,
Description: "动作高潮瞬间,展示关键动作",
}
}
// 解析AI返回的JSON
result := s.parseFramePromptJSON(aiResponse)
if result == nil {
// JSON解析失败,使用降级方案
s.log.Warnw("Failed to parse AI JSON response, using fallback", "storyboard_id", sb.ID, "response", aiResponse)
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "key frame, dynamic action")
return &SingleFramePrompt{
Prompt: fallbackPrompt,
Description: "动作高潮瞬间,展示关键动作",
}
}
return result
}
// generateLastFrame 生成尾帧提示词
func (s *FramePromptService) generateLastFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt {
// 构建上下文信息
contextInfo := s.buildStoryboardContext(sb, scene)
// 使用国际化提示词
systemPrompt := s.promptI18n.GetLastFramePrompt(dramaStyle)
userPrompt := s.promptI18n.FormatUserPrompt("last_frame_info", contextInfo)
// 调用AI生成(如果指定了模型则使用指定的模型)
var aiResponse string
var err error
if model != "" {
client, getErr := s.aiService.GetAIClientForModel("text", model)
if getErr != nil {
s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr)
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
} else {
aiResponse, err = client.GenerateText(userPrompt, systemPrompt)
}
} else {
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
}
if err != nil {
s.log.Warnw("AI generation failed, using fallback", "error", err)
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "last frame, final state")
return &SingleFramePrompt{
Prompt: fallbackPrompt,
Description: "镜头结束画面,展示最终状态和结果",
}
}
// 解析AI返回的JSON
result := s.parseFramePromptJSON(aiResponse)
if result == nil {
// JSON解析失败,使用降级方案
s.log.Warnw("Failed to parse AI JSON response, using fallback", "storyboard_id", sb.ID, "response", aiResponse)
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "last frame, final state")
return &SingleFramePrompt{
Prompt: fallbackPrompt,
Description: "镜头结束画面,展示最终状态和结果",
}
}
return result
}
// generatePanelFrames 生成分镜板提示词(多格组合)
func (s *FramePromptService) generatePanelFrames(sb models.Storyboard, scene *models.Scene, count int, dramaStyle string, model string) *MultiFramePrompt {
layout := fmt.Sprintf("horizontal_%d", count)
frames := make([]SingleFramePrompt, count)
// 固定生成:首帧 -> 关键帧 -> 尾帧
if count == 3 {
frames[0] = *s.generateFirstFrame(sb, scene, dramaStyle, model)
frames[0].Description = "第1格:初始状态"
frames[1] = *s.generateKeyFrame(sb, scene, dramaStyle, model)
frames[1].Description = "第2格:动作高潮"
frames[2] = *s.generateLastFrame(sb, scene, dramaStyle, model)
frames[2].Description = "第3格:最终状态"
} else if count == 4 {
// 4格:首帧 -> 中间帧1 -> 中间帧2 -> 尾帧
frames[0] = *s.generateFirstFrame(sb, scene, dramaStyle, model)
frames[1] = *s.generateKeyFrame(sb, scene, dramaStyle, model)
frames[2] = *s.generateKeyFrame(sb, scene, dramaStyle, model)
frames[3] = *s.generateLastFrame(sb, scene, dramaStyle, model)
}
return &MultiFramePrompt{
Layout: layout,
Frames: frames,
}
}
// generateActionSequence 生成动作序列提示词(3x3宫格)
func (s *FramePromptService) generateActionSequence(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *MultiFramePrompt {
// 构建上下文信息
contextInfo := s.buildStoryboardContext(sb, scene)
// 使用国际化提示词 - 专门为动作序列设计的提示词
systemPrompt := s.promptI18n.GetActionSequenceFramePrompt(dramaStyle)
userPrompt := s.promptI18n.FormatUserPrompt("frame_info", contextInfo)
// 调用AI生成(如果指定了模型则使用指定的模型)
var aiResponse string
var err error
if model != "" {
client, getErr := s.aiService.GetAIClientForModel("text", model)
if getErr != nil {
s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr)
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
} else {
aiResponse, err = client.GenerateText(userPrompt, systemPrompt)
}
} else {
aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt)
}
if err != nil {
s.log.Warnw("AI generation failed for action sequence, using fallback", "error", err)
// 降级方案:使用简单拼接
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "3x3 storyboard grid action sequence, character consistency, continuous movement progression")
return &MultiFramePrompt{
Layout: "grid_3x3",
Frames: []SingleFramePrompt{
{
Prompt: fallbackPrompt,
Description: "3x3宫格动作序列,展示连贯的动作演进",
},
},
}
}
// 解析AI返回的JSON
result := s.parseFramePromptJSON(aiResponse)
if result == nil {
// JSON解析失败,使用降级方案
s.log.Warnw("Failed to parse AI JSON response for action sequence, using fallback", "storyboard_id", sb.ID, "response", aiResponse)
fallbackPrompt := s.buildFallbackPrompt(sb, scene, "3x3 storyboard grid action sequence, character consistency, continuous movement progression")
return &MultiFramePrompt{
Layout: "grid_3x3",
Frames: []SingleFramePrompt{
{
Prompt: fallbackPrompt,
Description: "3x3宫格动作序列,展示连贯的动作演进",
},
},
}
}
// 动作序列是一个整体的3x3宫格图片,所以只返回一个prompt
return &MultiFramePrompt{
Layout: "grid_3x3",
Frames: []SingleFramePrompt{*result},
}
}
// buildStoryboardContext 构建镜头上下文信息
func (s *FramePromptService) buildStoryboardContext(sb models.Storyboard, scene *models.Scene) string {
var parts []string
// 镜头描述(最重要)
if sb.Description != nil && *sb.Description != "" {
parts = append(parts, s.promptI18n.FormatUserPrompt("shot_description_label", *sb.Description))
}
// 场景信息
if scene != nil {
parts = append(parts, s.promptI18n.FormatUserPrompt("scene_label", scene.Location, scene.Time))
} else if sb.Location != nil && sb.Time != nil {
parts = append(parts, s.promptI18n.FormatUserPrompt("scene_label", *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, s.promptI18n.FormatUserPrompt("characters_label", strings.Join(charNames, ", ")))
}
// 动作
if sb.Action != nil && *sb.Action != "" {
parts = append(parts, s.promptI18n.FormatUserPrompt("action_label", *sb.Action))
}
// 结果
if sb.Result != nil && *sb.Result != "" {
parts = append(parts, s.promptI18n.FormatUserPrompt("result_label", *sb.Result))
}
// 对白
if sb.Dialogue != nil && *sb.Dialogue != "" {
parts = append(parts, s.promptI18n.FormatUserPrompt("dialogue_label", *sb.Dialogue))
}
// 氛围
if sb.Atmosphere != nil && *sb.Atmosphere != "" {
parts = append(parts, s.promptI18n.FormatUserPrompt("atmosphere_label", *sb.Atmosphere))
}
// 镜头参数
if sb.ShotType != nil {
parts = append(parts, s.promptI18n.FormatUserPrompt("shot_type_label", *sb.ShotType))
}
if sb.Angle != nil {
parts = append(parts, s.promptI18n.FormatUserPrompt("angle_label", *sb.Angle))
}
if sb.Movement != nil {
parts = append(parts, s.promptI18n.FormatUserPrompt("movement_label", *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, ", ")
}
================================================
FILE: application/services/image_generation_service.go
================================================
package services
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/infrastructure/storage"
"github.com/drama-generator/backend/pkg/ai"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/image"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/utils"
"gorm.io/gorm"
)
type ImageGenerationService struct {
db *gorm.DB
aiService *AIService
transferService *ResourceTransferService
localStorage *storage.LocalStorage
log *logger.Logger
config *config.Config
promptI18n *PromptI18n
taskService *TaskService
}
// truncateImageURL 截断图片 URL,避免 base64 格式的 URL 占满日志
func truncateImageURL(url string) string {
if url == "" {
return ""
}
// 如果是 data URI 格式(base64),只显示前缀
if strings.HasPrefix(url, "data:") {
if len(url) > 50 {
return url[:50] + "...[base64 data]"
}
}
// 普通 URL 如果过长也截断
if len(url) > 100 {
return url[:100] + "..."
}
return url
}
func NewImageGenerationService(db *gorm.DB, cfg *config.Config, transferService *ResourceTransferService, localStorage *storage.LocalStorage, log *logger.Logger) *ImageGenerationService {
return &ImageGenerationService{
db: db,
aiService: NewAIService(db, log),
transferService: transferService,
localStorage: localStorage,
config: cfg,
promptI18n: NewPromptI18n(cfg),
log: log,
taskService: NewTaskService(db, 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"`
PropID *uint `json:"prop_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"`
ImageLocalPath *string `json:"image_local_path"` // 本地图片路径,用于图生图
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,
PropID: request.PropID,
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,
LocalPath: request.ImageLocalPath,
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
imageRatio := "16:9"
if err := s.db.First(&imageGen, imageGenID).Error; err != nil {
s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID)
return
}
// 获取drama的style信息
var drama models.Drama
if err := s.db.First(&drama, imageGen.DramaID).Error; err != nil {
s.log.Warnw("Failed to load drama for style", "error", err, "drama_id", imageGen.DramaID)
}
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 referenceImagePaths []string
if len(imageGen.ReferenceImages) > 0 {
if err := json.Unmarshal(imageGen.ReferenceImages, &referenceImagePaths); err == nil {
s.log.Infow("Using reference images for generation",
"id", imageGenID,
"reference_count", len(referenceImagePaths),
"references", referenceImagePaths)
}
}
// 如果有 local_path,添加到参考图片列表的开头
if imageGen.LocalPath != nil && *imageGen.LocalPath != "" {
referenceImagePaths = append([]string{*imageGen.LocalPath}, referenceImagePaths...)
}
// 将所有参考图片路径转换为 base64(如果是本地路径)或保持原样(如果是 URL)
var referenceImages []string
for _, imgPath := range referenceImagePaths {
// 判断是否为 HTTP/HTTPS URL
if strings.HasPrefix(imgPath, "http://") || strings.HasPrefix(imgPath, "https://") {
// 保持 URL 原样
referenceImages = append(referenceImages, imgPath)
} else {
// 视为本地路径,转换为 base64
base64Image, err := s.loadImageAsBase64(imgPath)
if err != nil {
s.log.Warnw("Failed to load local image as base64",
"error", err,
"id", imageGenID,
"local_path", imgPath)
} else {
referenceImages = append(referenceImages, base64Image)
s.log.Infow("Loaded local image for generation",
"id", imageGenID,
"local_path", imgPath)
}
}
}
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))
}
// 构建完整的提示词:风格提示词 + 用户提示词
prompt := imageGen.Prompt
// 如果drama有风格设置,添加风格提示词
if drama.Style != "" && drama.Style != "realistic" {
stylePrompt := s.promptI18n.GetStylePrompt(drama.Style)
if stylePrompt != "" {
// 将风格提示词作为系统级约束添加到提示词前面
prompt = stylePrompt + "\n\n" + prompt
s.log.Infow("Added style prompt to image generation",
"id", imageGenID,
"style", drama.Style,
"style_prompt_length", len(stylePrompt))
}
}
prompt += ", imageRatio:" + imageRatio
// 如果有参考图,在提示词末尾添加参考图一致性说明
if len(referenceImages) > 0 {
prompt += "\n\n**重要:**\n**必须严格**遵守参考图内的内容元素,保持场景和角色的**一致性**"
s.log.Infow("Added reference image consistency instruction to prompt",
"id", imageGenID,
"reference_count", len(referenceImages))
}
result, err := client.GenerateImage(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()
// 下载图片到本地存储并保存相对路径到数据库
var localPath *string
if s.localStorage != nil && result.ImageURL != "" &&
(strings.HasPrefix(result.ImageURL, "http://") || strings.HasPrefix(result.ImageURL, "https://")) {
downloadResult, err := s.localStorage.DownloadFromURLWithPath(result.ImageURL, "images")
if err != nil {
errStr := err.Error()
if len(errStr) > 200 {
errStr = errStr[:200] + "..."
}
s.log.Warnw("Failed to download image to local storage",
"error", errStr,
"id", imageGenID,
"original_url", truncateImageURL(result.ImageURL))
} else {
localPath = &downloadResult.RelativePath
s.log.Infow("Image downloaded to local storage",
"id", imageGenID,
"original_url", truncateImageURL(result.ImageURL),
"local_path", downloadResult.RelativePath)
}
}
// 数据库中保存原始URL和本地路径
updates := map[string]interface{}{
"status": models.ImageStatusCompleted,
"image_url": result.ImageURL,
"local_path": localPath,
"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
}
// 使用 Updates 更新基本字段
if err := s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Updates(updates).Error; err != nil {
s.log.Errorw("Failed to update image generation", "error", err, "id", imageGenID)
return
}
// 单独更新 local_path 字段(即使为 nil 也要更新)
if err := s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("Failed to update local_path", "error", err, "id", imageGenID)
}
s.log.Infow("Image generation completed", "id", imageGenID)
// 如果关联了storyboard,同步更新storyboard的composed_image
if imageGen.StoryboardID != nil {
if err := s.db.Model(&models.Storyboard{}).Where("id = ?", *imageGen.StoryboardID).Update("composed_image", result.ImageURL).Error; err != nil {
s.log.Errorw("Failed to update storyboard composed_image", "error", err, "storyboard_id", *imageGen.StoryboardID)
} else {
s.log.Infow("Storyboard updated with composed image",
"storyboard_id", *imageGen.StoryboardID,
"composed_image", truncateImageURL(result.ImageURL))
}
}
// 如果关联了scene,同步更新scene的image_url、local_path和status(仅当ImageType是scene时)
if imageGen.SceneID != nil && imageGen.ImageType == string(models.ImageTypeScene) {
sceneUpdates := map[string]interface{}{
"status": "generated",
"image_url": result.ImageURL,
}
if localPath != nil {
sceneUpdates["local_path"] = localPath
}
if err := s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.SceneID).Updates(sceneUpdates).Error; err != nil {
s.log.Errorw("Failed to update scene", "error", err, "scene_id", *imageGen.SceneID)
} else {
s.log.Infow("Scene updated with generated image",
"scene_id", *imageGen.SceneID,
"image_url", truncateImageURL(result.ImageURL),
"local_path", localPath)
}
}
// 如果关联了角色,同步更新角色的image_url和local_path
if imageGen.CharacterID != nil {
characterUpdates := map[string]interface{}{
"image_url": result.ImageURL,
}
if localPath != nil {
characterUpdates["local_path"] = localPath
}
if err := s.db.Model(&models.Character{}).Where("id = ?", *imageGen.CharacterID).Updates(characterUpdates).Error; err != nil {
s.log.Errorw("Failed to update character", "error", err, "character_id", *imageGen.CharacterID)
} else {
s.log.Infow("Character updated with generated image",
"character_id", *imageGen.CharacterID,
"image_url", truncateImageURL(result.ImageURL),
"local_path", localPath)
}
}
// 如果关联了道具,同步更新道具的image_url和local_path
if imageGen.PropID != nil {
propUpdates := map[string]interface{}{
"image_url": result.ImageURL,
}
if localPath != nil {
propUpdates["local_path"] = localPath
}
if err := s.db.Model(&models.Prop{}).Where("id = ?", *imageGen.PropID).Updates(propUpdates).Error; err != nil {
s.log.Errorw("Failed to update prop", "error", err, "prop_id", *imageGen.PropID)
} else {
s.log.Infow("Prop updated with generated image",
"prop_id", *imageGen.PropID,
"image_url", truncateImageURL(result.ImageURL),
"local_path", localPath)
}
}
}
func (s *ImageGenerationService) updateImageGenError(imageGenID uint, errorMsg string) {
// 先获取image_generation记录
var imageGen models.ImageGeneration
if err := s.db.Where("id = ?", imageGenID).First(&imageGen).Error; err != nil {
s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID)
return
}
// 更新image_generation状态
s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Updates(map[string]interface{}{
"status": models.ImageStatusFailed,
"error_msg": errorMsg,
})
s.log.Errorw("Image generation failed", "id", imageGenID, "error", errorMsg)
// 如果关联了scene,同步更新scene为失败状态
if imageGen.SceneID != nil {
s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.SceneID).Update("status", "failed")
s.log.Warnw("Scene marked as failed", "scene_id", *imageGen.SceneID)
}
}
func (s *ImageGenerationService) getImageClient(provider string) (image.ImageClient, error) {
config, err := s.aiService.GetDefaultConfig("image")
if err != nil {
return nil, fmt.Errorf("no image AI config found: %w", err)
}
// 使用第一个模型
model := ""
if len(config.Model) > 0 {
model = config.Model[0]
}
// 使用配置中的 provider,如果没有则使用传入的 provider
actualProvider := config.Provider
if actualProvider == "" {
actualProvider = provider
}
// 根据 provider 自动设置默认端点
var endpoint string
var queryEndpoint string
switch actualProvider {
case "openai", "dalle":
endpoint = "/images/generations"
return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
case "chatfire":
endpoint = "/images/generations"
return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
case "volcengine", "volces", "doubao":
endpoint = "/images/generations"
queryEndpoint = ""
return image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
case "gemini", "google":
endpoint = "/v1beta/models/{model}:generateContent"
return image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
default:
endpoint = "/images/generations"
return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
}
}
// getImageClientWithModel 根据模型名称获取图片客户端
func (s *ImageGenerationService) getImageClientWithModel(provider string, modelName string) (image.ImageClient, error) {
var config *models.AIServiceConfig
var err error
// 如果指定了模型,尝试获取对应的配置
if modelName != "" {
config, err = s.aiService.GetConfigForModel("image", modelName)
if err != nil {
s.log.Warnw("Failed to get config for model, using default", "model", modelName, "error", err)
config, err = s.aiService.GetDefaultConfig("image")
if err != nil {
return nil, fmt.Errorf("no image AI config found: %w", err)
}
}
} else {
config, err = s.aiService.GetDefaultConfig("image")
if err != nil {
return nil, fmt.Errorf("no image AI config found: %w", err)
}
}
// 使用指定的模型或配置中的第一个模型
model := modelName
if model == "" && len(config.Model) > 0 {
model = config.Model[0]
}
// 使用配置中的 provider,如果没有则使用传入的 provider
actualProvider := config.Provider
if actualProvider == "" {
actualProvider = provider
}
// 根据 provider 自动设置默认端点
var endpoint string
var queryEndpoint string
switch actualProvider {
case "openai", "dalle":
endpoint = "/images/generations"
return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
case "chatfire":
endpoint = "/images/generations"
return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
case "volcengine", "volces", "doubao":
endpoint = "/images/generations"
queryEndpoint = ""
return image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
case "gemini", "google":
endpoint = "/v1beta/models/{model}:generateContent"
return image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
default:
endpoint = "/images/generations"
return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil
}
}
func (s *ImageGenerationService) GetImageGeneration(imageGenID uint) (*models.ImageGeneration, error) {
var imageGen models.ImageGeneration
if err := s.db.Where("id = ? ", imageGenID).First(&imageGen).Error; err != nil {
return nil, err
}
return &imageGen, nil
}
func (s *ImageGenerationService) ListImageGenerations(dramaID *uint, sceneID *uint, storyboardID *uint, frameType string, status string, page, pageSize int) ([]models.ImageGeneration, int64, error) {
query := s.db.Model(&models.ImageGeneration{})
if dramaID != nil {
query = query.Where("drama_id = ?", *dramaID)
}
if sceneID != nil {
query = query.Where("scene_id = ?", *sceneID)
}
if storyboardID != nil {
query = query.Where("storyboard_id = ?", *storyboardID)
}
if frameType != "" {
query = query.Where("frame_type = ?", frameType)
}
if status != "" {
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var images []models.ImageGeneration
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&images).Error; err != nil {
return nil, 0, err
}
return images, total, nil
}
func (s *ImageGenerationService) DeleteImageGeneration(imageGenID uint) error {
result := s.db.Where("id = ? ", imageGenID).Delete(&models.ImageGeneration{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("image generation not found")
}
return nil
}
// UploadImageRequest 上传图片请求
type UploadImageRequest struct {
StoryboardID uint `json:"storyboard_id"`
DramaID uint `json:"drama_id"`
FrameType string `json:"frame_type"`
ImageURL string `json:"image_url"`
Prompt string `json:"prompt"`
}
// CreateImageFromUpload 从上传的图片URL创建图片生成记录
func (s *ImageGenerationService) CreateImageFromUpload(req *UploadImageRequest) (*models.ImageGeneration, error) {
// 验证storyboard存在
var storyboard models.Storyboard
if err := s.db.First(&storyboard, req.StoryboardID).Error; err != nil {
return nil, fmt.Errorf("storyboard not found")
}
// 验证drama存在
var drama models.Drama
if err := s.db.First(&drama, req.DramaID).Error; err != nil {
return nil, fmt.Errorf("drama not found")
}
prompt := req.Prompt
if prompt == "" {
prompt = "用户上传图片"
}
now := time.Now()
imageGen := &models.ImageGeneration{
StoryboardID: &req.StoryboardID,
DramaID: req.DramaID,
ImageType: string(models.ImageTypeStoryboard),
FrameType: &req.FrameType,
Provider: "upload",
Prompt: prompt,
Model: "upload",
ImageURL: &req.ImageURL,
Status: models.ImageStatusCompleted,
CompletedAt: &now,
}
if err := s.db.Create(imageGen).Error; err != nil {
return nil, fmt.Errorf("failed to create image record: %w", err)
}
s.log.Infow("Image created from upload",
"id", imageGen.ID,
"storyboard_id", req.StoryboardID,
"frame_type", req.FrameType)
return imageGen, 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, model string, style string) (string, error) {
var episode models.Episode
if err := s.db.Preload("Storyboards").First(&episode, episodeID).Error; err != nil {
return "", fmt.Errorf("episode not found")
}
// 如果没有剧本内容,无法提取场景
if episode.ScriptContent == nil || *episode.ScriptContent == "" {
return "", fmt.Errorf("episode has no script content")
}
// 创建任务
task, err := s.taskService.CreateTask("background_extraction", episodeID)
if err != nil {
s.log.Errorw("Failed to create background extraction task", "error", err, "episode_id", episodeID)
return "", fmt.Errorf("创建任务失败: %w", err)
}
// 异步处理场景提取
go s.processBackgroundExtraction(task.ID, episodeID, model, style)
s.log.Infow("Background extraction task created", "task_id", task.ID, "episode_id", episodeID)
return task.ID, nil
}
// processBackgroundExtraction 异步处理场景提取
func (s *ImageGenerationService) processBackgroundExtraction(taskID string, episodeID string, model string, style string) {
// 更新任务状态为处理中
s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在提取场景信息...")
var episode models.Episode
if err := s.db.Preload("Storyboards").First(&episode, episodeID).Error; err != nil {
s.log.Errorw("Episode not found during background extraction", "error", err, "episode_id", episodeID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "剧集信息不存在")
return
}
if episode.ScriptContent == nil || *episode.ScriptContent == "" {
s.log.Errorw("Episode has no script content during background extraction", "episode_id", episodeID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "剧本内容为空")
return
}
s.log.Infow("Extracting backgrounds from script", "episode_id", episodeID, "model", model, "task_id", taskID)
dramaID := episode.DramaID
// 使用AI从剧本内容中提取场景
backgroundsInfo, err := s.extractBackgroundsFromScript(*episode.ScriptContent, dramaID, model, style)
if err != nil {
s.log.Errorw("Failed to extract backgrounds from script", "error", err, "task_id", taskID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "AI提取场景失败: "+err.Error())
return
}
// 保存到数据库(不涉及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, "task_id", taskID)
return err
}
s.log.Infow("Deleted old scenes for re-extraction", "episode_id", episode.ID, "task_id", taskID)
// 创建新提取的场景
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,
"task_id", taskID)
}
return nil
})
if err != nil {
s.log.Errorw("Failed to save scenes to database", "error", err, "task_id", taskID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "保存场景信息失败: "+err.Error())
return
}
// 更新任务状态为完成
resultData := map[string]interface{}{
"scenes": scenes,
"count": len(scenes),
"episode_id": episodeID,
"drama_id": dramaID,
}
s.taskService.UpdateTaskResult(taskID, resultData)
s.log.Infow("Background extraction completed",
"task_id", taskID,
"episode_id", episodeID,
"total_storyboards", len(episode.Storyboards),
"unique_scenes", len(scenes))
}
// extractBackgroundsFromScript 从剧本内容中使用AI提取场景信息
func (s *ImageGenerationService) extractBackgroundsFromScript(scriptContent string, dramaID uint, model string, style string) ([]BackgroundInfo, error) {
if scriptContent == "" {
return []BackgroundInfo{}, nil
}
// 获取AI客户端(如果指定了模型则使用指定的模型)
var client ai.AIClient
var err error
if model != "" {
s.log.Infow("Using specified model for background extraction", "model", model)
client, err = s.aiService.GetAIClientForModel("text", model)
if err != nil {
s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", err)
client, err = s.aiService.GetAIClient("text")
}
} else {
client, err = s.aiService.GetAIClient("text")
}
if err != nil {
return nil, fmt.Errorf("failed to get AI client: %w", err)
}
// 使用国际化提示词
systemPrompt := s.promptI18n.GetSceneExtractionPrompt(style)
contentLabel := s.promptI18n.FormatUserPrompt("script_content_label")
// 根据语言构建不同的格式说明
var formatInstructions string
if s.promptI18n.IsEnglish() {
formatInstructions = `[Output JSON Format]
{
"backgrounds": [
{
"location": "Location name (English)",
"time": "Time description (English)",
"atmosphere": "Atmosphere description (English)",
"prompt": "A cinematic anime-style pure background scene depicting [location description] at [time]. The scene shows [environment details, architecture, objects, lighting, no characters]. Style: rich details, high quality, atmospheric lighting. Mood: [environment mood description]."
}
]
}
[Example]
Correct example (note: no characters):
{
"backgrounds": [
{
"location": "Repair Shop Interior",
"time": "Late Night",
"atmosphere": "Dim, lonely, industrial",
"prompt": "A cinematic anime-style pure background scene depicting a messy repair shop interior at late night. Under dim fluorescent lights, the workbench is scattered with various wrenches, screwdrivers and mechanical parts, oil-stained tool boards and faded posters hang on walls, oil stains on the floor, used tires piled in corners. Style: rich details, high quality, dim atmosphere. Mood: lonely, industrial."
},
{
"location": "City Street",
"time": "Dusk",
"atmosphere": "Warm, busy, lively",
"prompt": "A cinematic anime-style pure background scene depicting a bustling city street at dusk. Sunset afterglow shines on the asphalt road, neon lights of shops on both sides begin to light up, bicycle racks and bus stops on the street, high-rise buildings in the distance, sky showing orange-red gradient. Style: rich details, high quality, warm atmosphere. Mood: lively, busy."
}
]
}
[Wrong Examples (containing characters, forbidden)]:
❌ "Depicting protagonist standing on the street" - contains character
❌ "People hurrying by" - contains characters
❌ "Character moving in the room" - contains character
Please strictly follow the JSON format and ensure all fields use English.`
} else {
formatInstructions = `【输出JSON格式】
{
"backgrounds": [
{
"location": "地点名称(中文)",
"time": "时间描述(中文)",
"atmosphere": "氛围描述(中文)",
"prompt": "一个电影感的动漫风格纯背景场景,展现[地点描述]在[时间]的环境。画面呈现[环境细节、建筑、物品、光线等,不包含人物]。风格:细节丰富,高质量,氛围光照。情绪:[环境情绪描述]。"
}
]
}
【示例】
正确示例(注意:不包含人物):
{
"backgrounds": [
{
"location": "维修店内部",
"time": "深夜",
"atmosphere": "昏暗、孤独、工业感",
"prompt": "一个电影感的动漫风格纯背景场景,展现凌乱的维修店内部在深夜的环境。昏暗的日光灯照射下,工作台上散落着各种扳手、螺丝刀和机械零件,墙上挂着油污斑斑的工具挂板和褪色海报,地面有油渍痕迹,角落堆放着废旧轮胎。风格:细节丰富,高质量,昏暗氛围。情绪:孤独、工业感。"
},
{
"location": "城市街道",
"time": "黄昏",
"atmosphere": "温暖、繁忙、生活气息",
"prompt": "一个电影感的动漫风格纯背景场景,展现繁华的城市街道在黄昏时分的环境。夕阳的余晖洒在街道的沥青路面上,两旁的商铺霓虹灯开始点亮,街边有自行车停靠架和公交站牌,远处高楼林立,天空呈现橙红色渐变。风格:细节丰富,高质量,温暖氛围。情绪:生活气息、繁忙。"
}
]
}
【错误示例(包含人物,禁止)】:
❌ "展现主角站在街道上的场景" - 包含人物
❌ "人们匆匆而过" - 包含人物
❌ "角色在房间里活动" - 包含人物
请严格按照JSON格式输出,确保所有字段都使用中文。`
}
prompt := fmt.Sprintf(`%s
%s
%s
%s`, systemPrompt, contentLabel, scriptContent, formatInstructions)
// 打印完整提示词用于调试
s.log.Infow("=== AI Prompt for Background Extraction (extractBackgroundsFromScript) ===",
"language", s.promptI18n.GetLanguage(),
"prompt_length", len(prompt),
"full_prompt", prompt)
response, err := client.GenerateText(prompt, "", ai.WithTemperature(0.7))
if err != nil {
s.log.Errorw("Failed to extract backgrounds with AI", "error", err)
return nil, fmt.Errorf("AI提取场景失败: %w", err)
}
// 打印AI返回的原始响应
s.log.Infow("=== AI Response for Background Extraction (extractBackgroundsFromScript) ===",
"response_length", len(response),
"raw_response", response)
// 解析AI返回的JSON
var backgrounds []BackgroundInfo
// 先尝试解析为数组格式
if err := utils.SafeParseAIJSON(response, &backgrounds); err == nil {
s.log.Infow("Parsed backgrounds as array format", "count", len(backgrounds))
} else {
// 尝试解析为对象格式
var result struct {
Backgrounds []BackgroundInfo `json:"backgrounds"`
}
if err := utils.SafeParseAIJSON(response, &result); err != nil {
s.log.Errorw("Failed to parse AI response in both formats", "error", err, "response", response[:min(len(response), 500)])
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
backgrounds = result.Backgrounds
s.log.Infow("Parsed backgrounds as object format", "count", len(backgrounds))
}
s.log.Infow("Extracted backgrounds from script",
"drama_id", dramaID,
"backgrounds_count", len(backgrounds))
return backgrounds, nil
}
// extractBackgroundsWithAI 使用AI智能分析场景并提取唯一背景
func (s *ImageGenerationService) extractBackgroundsWithAI(storyboards []models.Storyboard, style string) ([]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)
}
// 使用国际化提示词
systemPrompt := s.promptI18n.GetSceneExtractionPrompt(style)
storyboardLabel := s.promptI18n.FormatUserPrompt("storyboard_list_label")
// 根据语言构建不同的提示词
var formatInstructions string
if s.promptI18n.IsEnglish() {
formatInstructions = `[Output JSON Format]
{
"backgrounds": [
{
"location": "Location name (English)",
"time": "Time description (English)",
"prompt": "A cinematic anime-style background depicting [location description] at [time]. The scene shows [detail description]. Style: rich details, high quality, atmospheric lighting. Mood: [mood description].",
"scene_numbers": [1, 2, 3]
}
]
}
[Example]
Correct example:
{
"backgrounds": [
{
"location": "Repair Shop",
"time": "Late Night",
"prompt": "A cinematic anime-style background depicting a messy repair shop interior at late night. Under dim lighting, the workbench is scattered with various tools and parts, with greasy posters hanging on the walls. Style: rich details, high quality, dim atmosphere. Mood: lonely, industrial.",
"scene_numbers": [1, 5, 6, 10, 15]
},
{
"location": "City Panorama",
"time": "Late Night with Acid Rain",
"prompt": "A cinematic anime-style background depicting a coastal city panorama in late night acid rain. Neon lights blur in the rain, skyscrapers shrouded in gray-green rain curtain, streets reflecting colorful lights. Style: rich details, high quality, cyberpunk atmosphere. Mood: oppressive, sci-fi, apocalyptic.",
"scene_numbers": [2, 7]
}
]
}
Please strictly follow the JSON format and ensure:
1. prompt field uses English
2. scene_numbers includes all scene numbers using this background
3. All scenes are assigned to a background`
} else {
formatInstructions = `【输出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. 所有场景都被分配到某个背景`
}
prompt := fmt.Sprintf(`%s
%s
%s
%s`, systemPrompt, storyboardLabel, scenesText, formatInstructions)
// 打印完整提示词用于调试
s.log.Infow("=== AI Prompt for Background Extraction (extractBackgroundsWithAI) ===",
"language", s.promptI18n.GetLanguage(),
"prompt_length", len(prompt),
"full_prompt", prompt)
// 调用AI服务
text, err := s.aiService.GenerateText(prompt, "")
if err != nil {
return nil, fmt.Errorf("AI analysis failed: %w", err)
}
// 打印AI返回的原始响应
s.log.Infow("=== AI Response for Background Extraction ===",
"response_length", len(text),
"raw_response", text)
// 解析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
}
// loadImageAsBase64 读取本地图片文件并转换为 base64 格式的 data URI
func (s *ImageGenerationService) loadImageAsBase64(localPath string) (string, error) {
// 构建完整的文件路径
var fullPath string
if filepath.IsAbs(localPath) {
fullPath = localPath
} else {
// 如果是相对路径,拼接存储根目录
if s.localStorage != nil {
fullPath = s.localStorage.GetAbsolutePath(localPath)
} else {
fullPath = filepath.Join(s.config.Storage.LocalPath, localPath)
}
}
// 读取文件
fileData, err := os.ReadFile(fullPath)
if err != nil {
return "", fmt.Errorf("failed to read image file: %w", err)
}
// 根据文件扩展名确定 MIME 类型
ext := strings.ToLower(filepath.Ext(fullPath))
mimeType := "image/jpeg" // 默认
switch ext {
case ".png":
mimeType = "image/png"
case ".jpg", ".jpeg":
mimeType = "image/jpeg"
case ".gif":
mimeType = "image/gif"
case ".webp":
mimeType = "image/webp"
}
// 转换为 base64
base64Data := base64.StdEncoding.EncodeToString(fileData)
// 构建 data URI
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
return dataURI, nil
}
================================================
FILE: application/services/prompt_i18n.go
================================================
package services
import (
"fmt"
"github.com/drama-generator/backend/pkg/config"
)
// PromptI18n 提示词国际化工具
type PromptI18n struct {
config *config.Config
}
// NewPromptI18n 创建提示词国际化工具
func NewPromptI18n(cfg *config.Config) *PromptI18n {
return &PromptI18n{config: cfg}
}
// GetLanguage 获取当前语言设置
func (p *PromptI18n) GetLanguage() string {
lang := p.config.App.Language
if lang == "" {
return "zh" // 默认中文
}
return lang
}
// IsEnglish 判断是否为英文模式(动态读取配置)
func (p *PromptI18n) IsEnglish() bool {
return p.GetLanguage() == "en"
}
// GetStoryboardSystemPrompt 获取分镜生成系统提示词
func (p *PromptI18n) GetStoryboardSystemPrompt() string {
if p.IsEnglish() {
return `[Role] You are a senior film storyboard artist, proficient in Robert McKee's shot breakdown theory, skilled at building emotional rhythm.
[Task] Break down the novel script into storyboard shots based on **independent action units**.
[Shot Breakdown Principles]
1. **Action Unit Division**: Each shot must correspond to a complete and independent action
- One action = one shot (character stands up, walks over, speaks a line, reacts with an expression, etc.)
- Do NOT merge multiple actions (standing up + walking over should be split into 2 shots)
2. **Shot Type Standards** (choose based on storytelling needs):
- Extreme Long Shot (ELS): Environment, atmosphere building
- Long Shot (LS): Full body action, spatial relationships
- Medium Shot (MS): Interactive dialogue, emotional communication
- Close-Up (CU): Detail display, emotional expression
- Extreme Close-Up (ECU): Key props, intense emotions
3. **Camera Movement Requirements**:
- Fixed Shot: Stable focus on one subject
- Push In: Approaching subject, increasing tension
- Pull Out: Expanding field of view, revealing context
- Pan: Horizontal camera movement, spatial transitions
- Follow: Following subject movement
- Tracking: Linear movement with subject
4. **Emotion & Intensity Markers**:
- Emotion: Brief description (excited, sad, nervous, happy, etc.)
- Intensity: Emotion level using arrows
* Extremely strong ↑↑↑ (3): Emotional peak, high tension
* Strong ↑↑ (2): Significant emotional fluctuation
* Moderate ↑ (1): Noticeable emotional change
* Stable → (0): Emotion remains unchanged
* Weak ↓ (-1): Emotion subsiding
[Output Requirements]
1. Generate an array, each element is a shot containing:
- shot_number: Shot number
- scene_description: Scene (location + time, e.g., "bedroom interior, morning")
- shot_type: Shot type (extreme long shot/long shot/medium shot/close-up/extreme close-up)
- camera_angle: Camera angle (eye-level/low-angle/high-angle/side/back)
- camera_movement: Camera movement (fixed/push/pull/pan/follow/tracking)
- action: Action description
- result: Visual result of the action
- dialogue: Character dialogue or narration (if any)
- emotion: Current emotion
- emotion_intensity: Emotion intensity level (3/2/1/0/-1)
**CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].**
[Important Notes]
- Shot count must match number of independent actions in the script (not allowed to merge or reduce)
- Each shot must have clear action and result
- Shot types must match storytelling rhythm (don't use same shot type continuously)
- Emotion intensity must accurately reflect script atmosphere changes`
}
return `【角色】你是一位资深影视分镜师,精通罗伯特·麦基的镜头拆解理论,擅长构建情绪节奏。
【任务】将小说剧本按**独立动作单元**拆解为分镜头方案。
【分镜拆解原则】
1. **动作单元划分**:每个镜头必须对应一个完整且独立的动作
- 一个动作 = 一个镜头(角色站起来、走过去、说一句话、做一个反应表情等)
- 禁止合并多个动作(站起+走过去应拆分为2个镜头)
2. **景别标准**(根据叙事需要选择):
- 大远景:环境、氛围营造
- 远景:全身动作、空间关系
- 中景:交互对话、情感交流
- 近景:细节展示、情绪表达
- 特写:关键道具、强烈情绪
3. **运镜要求**:
- 固定镜头:稳定聚焦于一个主体
- 推镜:接近主体,增强紧张感
- 拉镜:扩大视野,交代环境
- 摇镜:水平移动摄像机,空间转换
- 跟镜:跟随主体移动
- 移镜:摄像机与主体同向移动
4. **情绪与强度标记**:
- emotion:简短描述(兴奋、悲伤、紧张、愉快等)
- emotion_intensity:用箭头表示情绪等级
* 极强 ↑↑↑ (3):情绪高峰、高度紧张
* 强 ↑↑ (2):情绪明显波动
* 中 ↑ (1):情绪有所变化
* 平稳 → (0):情绪不变
* 弱 ↓ (-1):情绪回落
【输出要求】
1. 生成一个数组,每个元素是一个镜头,包含:
- shot_number:镜头号
- scene_description:场景(地点+时间,如"卧室内,早晨")
- shot_type:景别(大远景/远景/中景/近景/特写)
- camera_angle:机位角度(平视/仰视/俯视/侧面/背面)
- camera_movement:运镜方式(固定/推镜/拉镜/摇镜/跟镜/移镜)
- action:动作描述
- result:动作完成后的画面结果
- dialogue:角色对话或旁白(如有)
- emotion:当前情绪
- emotion_intensity:情绪强度等级(3/2/1/0/-1)
**重要:必须只返回纯JSON数组,不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头,以 ] 结尾。**
【重要提示】
- 镜头数量必须与剧本中的独立动作数量匹配(不允许合并或减少)
- 每个镜头必须有明确的动作和结果
- 景别选择必须符合叙事节奏(不要连续使用同一景别)
- 情绪强度必须准确反映剧本氛围变化`
}
// GetSceneExtractionPrompt 获取场景提取提示词
func (p *PromptI18n) GetSceneExtractionPrompt(style string) string {
// 默认图片比例
imageRatio := "16:9"
if p.IsEnglish() {
return fmt.Sprintf(`[Task] Extract all unique scene backgrounds from the script
[Requirements]
1. Identify all different scenes (location + time combinations) in the script
2. Generate detailed **English** image generation prompts for each scene
3. **Important**: Scene descriptions must be **pure backgrounds** without any characters, people, or actions
4. Prompt requirements:
- Must use **English**, no Chinese characters
- Detailed description of scene, time, atmosphere, style
- Must explicitly specify "no people, no characters, empty scene"
- Must match the drama's genre and tone
- **Style Requirement**: %s
- **Image Ratio**: %s
[Output Format]
**CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].**
Each element containing:
- location: Location (e.g., "luxurious office")
- time: Time period (e.g., "afternoon")
- prompt: Complete English image generation prompt (pure background, explicitly stating no people)`, style, imageRatio)
}
return fmt.Sprintf(`【任务】从剧本中提取所有唯一的场景背景
【要求】
1. 识别剧本中所有不同的场景(地点+时间组合)
2. 为每个场景生成详细的**中文**图片生成提示词(Prompt)
3. **重要**:场景描述必须是**纯背景**,不能包含人物、角色、动作等元素
4. Prompt要求:
- **必须使用中文**,不能包含英文字符
- 详细描述场景、时间、氛围、风格
- 必须明确说明"无人物、无角色、空场景"
- 要符合剧本的题材和氛围
- **风格要求**:%s
- **图片比例**:%s
【输出格式】
**重要:必须只返回纯JSON数组,不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头,以 ] 结尾。**
每个元素包含:
- location:地点(如"豪华办公室")
- time:时间(如"下午")
- prompt:完整的中文图片生成提示词(纯背景,明确说明无人物)`, style, imageRatio)
}
// GetFirstFramePrompt 获取首帧提示词
func (p *PromptI18n) GetFirstFramePrompt(style string) string {
imageRatio := "16:9"
if p.IsEnglish() {
return fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information.
Important: This is the first frame of the shot - a completely static image showing the initial state before the action begins.
Key Points:
1. Focus on the initial static state - the moment before the action
2. Must NOT include any action or movement
3. Describe the character's initial posture, position, and expression
4. Can include scene atmosphere and environmental details
5. Shot type determines composition and framing
- **Style Requirement**: %s
- **Image Ratio**: %s
Output Format:
Return a JSON object containing:
- prompt: Complete English image generation prompt (detailed description, suitable for AI image generation)
- description: Simplified Chinese description (for reference)`, style, imageRatio)
}
return fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。
重要:这是镜头的首帧 - 一个完全静态的画面,展示动作发生之前的初始状态。
关键要点:
1. 聚焦初始静态状态 - 动作发生之前的那一瞬间
2. 必须不包含任何动作或运动
3. 描述角色的初始姿态、位置和表情
4. 可以包含场景氛围和环境细节
5. 景别决定构图和取景范围
- **风格要求**:%s
- **图片比例**:%s
输出格式:
返回一个JSON对象,包含:
- prompt:完整的中文图片生成提示词(详细描述,适合AI图像生成)
- description:简化的中文描述(供参考)`, style, imageRatio)
}
// GetKeyFramePrompt 获取关键帧提示词
func (p *PromptI18n) GetKeyFramePrompt(style string) string {
imageRatio := "16:9"
if p.IsEnglish() {
return fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information.
Important: This is the key frame of the shot - capturing the most intense and exciting moment of the action.
Key Points:
1. Focus on the most exciting moment of the action
2. Capture peak emotional expression
3. Emphasize dynamic tension
4. Show character actions and expressions at their climax
5. Can include motion blur or dynamic effects
- **Style Requirement**: %s
- **Image Ratio**: %s
Output Format:
Return a JSON object containing:
- prompt: Complete English image generation prompt (detailed description, suitable for AI image generation)
- description: Simplified Chinese description (for reference)`, style, imageRatio)
}
return fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。
重要:这是镜头的关键帧 - 捕捉动作最激烈、最精彩的瞬间。
关键要点:
1. 聚焦动作最精彩的时刻
2. 捕捉情绪表达的顶点
3. 强调动态张力
4. 展示角色动作和表情的高潮状态
5. 可以包含动作模糊或动态效果
- **风格要求**:%s
- **图片比例**:%s
输出格式:
返回一个JSON对象,包含:
- prompt:完整的中文图片生成提示词(详细描述,适合AI图像生成)
- description:简化的中文描述(供参考)`, style, imageRatio)
}
// GetActionSequenceFramePrompt 获取动作序列提示词
func (p *PromptI18n) GetActionSequenceFramePrompt(style string) string {
imageRatio := "16:9"
if p.IsEnglish() {
return fmt.Sprintf(`**Role:** You are an expert in visual storytelling and image generation prompting. You need to generate a single prompt that describes a 3x3 grid action sequence.
**Core Logic:**
1. **Holistic Integration:** This is a single, complete image containing a 3x3 grid layout, showcasing 9 sequential actions of the same subject.
2. **Visual Anchoring:** The subject, clothing, art style, and character consistency must be identical across all 9 frames.
3. **Action Evolution:** From Frame 1 to Frame 9, display a complete action sequence (e.g., Standing → Walking → Running → Jumping → Landing).
4. **Prompt Engineering:** Use high-quality visual vocabulary (lighting, textures, composition, depth of field).
**Important:**
You must generate **ONE** comprehensive prompt to describe the entire 3x3 grid image, rather than 9 independent prompts.
Each frame **must** follow these specific rules:
- **Frame 1:** Preparation/Initial stance
- **Frame 2:** Anticipation/Body adjustment
- **Frame 3:** Initiation/Beginning of movement
- **Frame 4:** Acceleration/Power building
- **Frame 5:** Peak of tension/Just before the burst
- **Frame 6:** Action burst/The climax moment
- **Frame 7:** Power release/Inertia continuation
- **Frame 8:** Deceleration/Follow-through
- **Frame 9:** Complete conclusion/Return to stillness
**Aspect Ratio:** * %s
**Output Specification:**
You must return a **JSON object** with the following structure:
- **prompt**: A **complete English image generation prompt** (describing the 3x3 grid layout, subject features, the evolution of the 9 actions, environment, and lighting details to ensure the AI generates one single image containing 9 frames).
- **description**: A **simplified English description** (summarizing the core content of the action sequence).
**Example Format:**
{
"prompt": "Action sequence layout, 3x3 grid composition\n [Frame 1]: [Subject] standing naturally in [Setting], feet shoulder-width apart...\n---\n [Frame 2]: [Subject] locking eyes forward, leaning body slightly...\n---\n [Frame 3]: [Subject's legs] bending slightly, center of gravity lowering...\n---\n [Frame 4]: [Subject] pushing off with back leg, body moving forward, dust rising from [Setting's ground]...\n---\n [Frame 5]: [Subject's clothing] fluttering, body leaning deep, fist charging power...\n---\n [Frame 6]: [Subject] sprinting at full speed, fist striking out...\n---\n [Frame 7]: [Subject] impact moment, body lunging forward...\n---\n [Frame 8]: [Subject] slowing down, pulling back the fist...\n---\n [Frame 9]: [Subject's full appearance] standing firm in [Setting], recovering original stance.",
"description": "Complete action sequence of a swordsman in black from drawing a blade to striking."
}
`, style, imageRatio)
}
return fmt.Sprintf(`**Role:** 你是一位精通视觉叙事与图像生成提示词的专家。你需要生成一个描述 3x3 九宫格动作序列的提示词。
**Core Logic:**
1. **整体性:** 这是一张完整的图片,包含 3x3 九宫格布局,展示同一主体的 9 个连续动作。
2. **视觉锚定:** 所有 9 个格子中的主体、服装、画风必须高度一致。
3. **动作演进:** 从格子 1 到格子 9,展示一个完整的动作序列(如:从站立→行走→奔跑→跳跃→落地)。
4. **提示词工程:** 使用高质量的视觉词汇(光影、材质、构图、景深)。
**重要:**
你需要生成 **一个** 完整的提示词来描述整个 3x3 九宫格图片,而不是 9 个独立的提示词。
每一格要求**必须**遵守如下规则:
- **第1格**:动作准备/初始姿态
- **第2格**:预备动作/身体调整
- **第3格**:动作启动/开始移动
- **第4格**:加速阶段/力量积蓄
- **第5格**:蓄力顶点/即将爆发
- **第6格**:动作爆发/高潮瞬间
- **第7格**:力量释放/惯性延续
- **第8格**:动作缓冲/逐渐收势
- **第9格**:完全收尾/回归静止
**Aspect Ratio:**
* %s
**Output Specification:**
必须返回一个 **JSON 对象**,其结构如下:
* prompt: **完整的中文图片生成提示词**(描述整个 3x3 九宫格的布局、主体特征、9 个动作的演进过程、环境、光影细节,确保 AI 能直接生成一张包含 9 个格子的完整图像)。
* description: **简化的中文描述**(概括这个动作序列的核心内容)。
**示例格式:**
{
"prompt": "动作序列布局,3x3方格布局\n [第1格]: [角色参考图2] 在 [场景参考图1] 中自然站立,双脚分开...\n---\n [第2格]: [角色参考图2] 眼神锁定,身体前倾...\n---\n [第3格]: [角色参考图2的腿部] 双腿微屈,重心下沉...\n---\n [第4格]: [角色参考图2] 后腿蹬地,身体前移,[场景参考图1的地面] 扬起尘土...\n---\n [第5格]: [角色参考图2的服装] 身体前倾,拳头蓄力...\n---\n [第6格]: [角色参考图2] 全速冲刺,拳头击出...\n---\n [第7格]: [角色参考图2] 拳头击中,身体前冲...\n---\n [第8格]: [角色参考图2] 减速收拳...\n---\n [第9格]: [角色参考图2的完整外观] 在 [场景参考图1] 中站稳,恢复姿态。\n",
"description": "黑衣剑客从拔剑到攻击的完整动作序列"
}`, imageRatio)
}
// GetLastFramePrompt 获取尾帧提示词
func (p *PromptI18n) GetLastFramePrompt(style string) string {
imageRatio := "16:9"
if p.IsEnglish() {
return fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information.
Important: This is the last frame of the shot - a static image showing the final state and result after the action ends.
Key Points:
1. Focus on the final state after action completion
2. Show the result of the action
3. Describe character's final posture and expression after action
4. Emphasize emotional state after action
5. Capture the calm moment after action ends
- **Style Requirement**: %s
- **Image Ratio**: %s
Output Format:
Return a JSON object containing:
- prompt: Complete English image generation prompt (detailed description, suitable for AI image generation)
- description: Simplified Chinese description (for reference)`, style, imageRatio)
}
return fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。
重要:这是镜头的尾帧 - 一个静态画面,展示动作结束后的最终状态和结果。
关键要点:
1. 聚焦动作完成后的最终状态
2. 展示动作的结果
3. 描述角色在动作完成后的姿态和表情
4. 强调动作后的情绪状态
5. 捕捉动作结束后的平静瞬间
- **风格要求**:%s
- **图片比例**:%s
输出格式:
返回一个JSON对象,包含:
- prompt:完整的中文图片生成提示词(详细描述,适合AI图像生成)
- description:简化的中文描述(供参考)`, style, imageRatio)
}
// GetOutlineGenerationPrompt 获取大纲生成提示词
func (p *PromptI18n) GetOutlineGenerationPrompt() string {
if p.IsEnglish() {
return `You are a professional short drama screenwriter. Based on the theme and number of episodes, create a complete short drama outline and plan the plot direction for each episode.
Requirements:
1. Compact plot with strong conflicts and fast pace
2. Each episode should have independent conflicts while connecting the main storyline
3. Clear character arcs and growth
4. Cliffhanger endings to hook viewers
5. Clear theme and emotional core
Output Format:
Return a JSON object containing:
- title: Drama title (creative and attractive)
- episodes: Episode list, each containing:
- episode_number: Episode number
- title: Episode title
- summary: Episode content summary (50-100 words)
- conflict: Main conflict point
- cliffhanger: Cliffhanger ending (if any)`
}
return `你是专业短剧编剧。根据主题和剧集数量,创作完整的短剧大纲,规划好每一集的剧情走向。
要求:
1. 剧情紧凑,矛盾冲突强烈,节奏快
2. 每集都有独立的矛盾冲突,同时推进主线
3. 角色弧光清晰,成长变化明显
4. 悬念设置合理,吸引观众继续观看
5. 主题明确,情感内核清晰
输出格式:
返回一个JSON对象,包含:
- title: 剧名(富有创意和吸引力)
- episodes: 分集列表,每集包含:
- episode_number: 集数
- title: 本集标题
- summary: 本集内容概要(50-100字)
- conflict: 主要矛盾点
- cliffhanger: 悬念结尾(如有)`
}
// GetCharacterExtractionPrompt 获取角色提取提示词
func (p *PromptI18n) GetCharacterExtractionPrompt(style string) string {
imageRatio := "16:9"
if p.IsEnglish() {
return fmt.Sprintf(`You are a professional character analyst, skilled at extracting and analyzing character information from scripts.
Your task is to extract and organize detailed character settings for all characters appearing in the script based on the provided script content.
Requirements:
1. Extract all characters with names (ignore unnamed passersby or background characters)
2. For each character, extract:
- name: Character name
- role: Character role (main/supporting/minor)
- appearance: Physical appearance description (150-300 words)
- personality: Personality traits (100-200 words)
- description: Background story and character relationships (100-200 words)
3. Appearance must be detailed enough for AI image generation, including: gender, age, body type, facial features, hairstyle, clothing style, etc. but do not include any scene, background, environment information
4. Main characters require more detailed descriptions, supporting characters can be simplified
- **Style Requirement**: %s
- **Image Ratio**: %s
Output Format:
**CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].**
Each element is a character object containing the above fields.`, style, imageRatio)
}
return fmt.Sprintf(`你是一个专业的角色分析师,擅长从剧本中提取和分析角色信息。
你的任务是根据提供的剧本内容,提取并整理剧中出现的所有角色的详细设定。
要求:
1. 提取所有有名字的角色(忽略无名路人或背景角色)
2. 对每个角色,提取以下信息:
- name: 角色名字
- role: 角色类型(main/supporting/minor)
- appearance: 外貌描述(150-300字)
- personality: 性格特点(100-200字)
- description: 背景故事和角色关系(100-200字)
3. 外貌描述要足够详细,适合AI生成图片,包括:性别、年龄、体型、面部特征、发型、服装风格等,但不要包含任何场景、背景、环境等信息
4. 主要角色需要更详细的描述,次要角色可以简化
- **风格要求**:%s
- **图片比例**:%s
输出格式:
**重要:必须只返回纯JSON数组,不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头,以 ] 结尾。**
每个元素是一个角色对象,包含上述字段。`, style, imageRatio)
}
// GetPropExtractionPrompt 获取道具提取提示词
func (p *PromptI18n) GetPropExtractionPrompt(style string) string {
imageRatio := "1:1"
if p.IsEnglish() {
return fmt.Sprintf(`Please extract key props from the following script.
[Script Content]
%%s
[Requirements]
1. Extract ONLY key props that are important to the plot or have special visual characteristics.
2. Do NOT extract common daily items (e.g., normal cups, pens) unless they have special plot significance.
3. If a prop has a clear owner, please note it in the description.
4. "image_prompt" field is for AI image generation, must describe the prop's appearance, material, color, and style in detail.
- **Style Requirement**: %s
- **Image Ratio**: %s
[Output Format]
JSON array, each object containing:
- name: Prop Name
- type: Type (e.g., Weapon/Key Item/Daily Item/Special Device)
- description: Role in the drama and visual description
- image_prompt: English image generation prompt (Focus on the object, isolated, detailed, cinematic lighting, high quality)
Please return JSON array directly.`, style, imageRatio)
}
return fmt.Sprintf(`请从以下剧本中提取关键道具。
【剧本内容】
%%s
【要求】
1. 只提取对剧情发展有重要作用、或有特殊视觉特征的关键道具。
2. 普通的生活用品(如普通的杯子、笔)如果无特殊剧情意义不需要提取。
3. 如果道具有明确的归属者,请在描述中注明。
4. "image_prompt"字段是用于AI生成图片的英文提示词,必须详细描述道具的外观、材质、颜色、风格。
- **风格要求**:%s
- **图片比例**:%s
【输出格式】
JSON数组,每个对象包含:
- name: 道具名称
- type: 类型 (如:武器/关键证物/日常用品/特殊装置)
- description: 在剧中的作用和中文外观描述
- image_prompt: 英文图片生成提示词 (Focus on the object, isolated, detailed, cinematic lighting, high quality)
请直接返回JSON数组。`, style, imageRatio)
}
// GetEpisodeScriptPrompt 获取分集剧本生成提示词
func (p *PromptI18n) GetEpisodeScriptPrompt() string {
if p.IsEnglish() {
return `You are a professional short drama screenwriter. You excel at creating detailed plot content based on episode plans.
Your task is to expand the summary in the outline into detailed plot narratives for each episode. Each episode is about 180 seconds (3 minutes) and requires substantial content.
Requirements:
1. Expand the outline summary into detailed plot development
2. Write character dialogue and actions, not just description
3. Highlight conflict progression and emotional changes
4. Add scene transitions and atmosphere descriptions
5. Control rhythm, with climax at 2/3 point, resolution at the end
6. Each episode 800-1200 words, dialogue-rich
7. Keep consistent with character settings
Output Format:
**CRITICAL: Return ONLY a valid JSON object. Do NOT include any markdown code blocks, explanations, or other text. Start directly with { and end with }.**
- episodes: Episode list, each containing:
- episode_number: Episode number
- title: Episode title
- script_content: Detailed script content (800-1200 words)`
}
return `你是一个专业的短剧编剧。你擅长根据分集规划创作详细的剧情内容。
你的任务是根据大纲中的分集规划,将每一集的概要扩展为详细的剧情叙述。每集约180秒(3分钟),需要充实的内容。
要求:
1. 将大纲中的概要扩展为具体的剧情发展
2. 写出角色的对话和动作,不是简单描述
3. 突出冲突的递进和情感的变化
4. 增加场景转换和氛围描写
5. 控制节奏,高潮在2/3处,结尾有收束
6. 每集800-1200字,对话丰富
7. 与角色设定保持一致
输出格式:
**重要:必须只返回纯JSON对象,不要包含任何markdown代码块、说明文字或其他内容。直接以 { 开头,以 } 结尾。**
- episodes: 分集列表,每集包含:
- episode_number: 集数
- title: 本集标题
- script_content: 详细剧本内容(800-1200字)`
}
// FormatUserPrompt 格式化用户提示词的通用文本
func (p *PromptI18n) FormatUserPrompt(key string, args ...interface{}) string {
templates := map[string]map[string]string{
"en": {
"outline_request": "Please create a short drama outline for the following theme:\n\nTheme: %s",
"genre_preference": "\nGenre preference: %s",
"style_requirement": "\nStyle requirement: %s",
"episode_count": "\nNumber of episodes: %d episodes",
"episode_importance": "\n\n**Important: Must plan complete storylines for all %d episodes in the episodes array, each with clear story content!**",
"character_request": "Script content:\n%s\n\nPlease extract and organize detailed character profiles for up to %d main characters from the script.",
"episode_script_request": "Drama outline:\n%s\n%s\nPlease create detailed scripts for %d episodes based on the above outline and characters.\n\n**Important requirements:**\n- Must generate all %d episodes, from episode 1 to episode %d, cannot skip any\n- Each episode is about 3-5 minutes (150-300 seconds)\n- The duration field for each episode should be set reasonably based on script content length, not all the same value\n- The episodes array in the returned JSON must contain %d elements",
"frame_info": "Shot information:\n%s\n\nPlease directly generate the image prompt for the first frame without any explanation:",
"key_frame_info": "Shot information:\n%s\n\nPlease directly generate the image prompt for the key frame without any explanation:",
"last_frame_info": "Shot information:\n%s\n\nPlease directly generate the image prompt for the last frame without any explanation:",
"script_content_label": "【Script Content】",
"storyboard_list_label": "【Storyboard List】",
"task_label": "【Task】",
"character_list_label": "【Available Character List】",
"scene_list_label": "【Extracted Scene Backgrounds】",
"task_instruction": "Break down the novel script into storyboard shots based on **independent action units**.",
"character_constraint": "**Important**: In the characters field, only use character IDs (numbers) from the above character list. Do not create new characters or use other IDs.",
"scene_constraint": "**Important**: In the scene_id field, select the most matching background ID (number) from the above background list. If no suitable background exists, use null.",
"shot_description_label": "Shot description: %s",
"scene_label": "Scene: %s, %s",
"characters_label": "Characters: %s",
"action_label": "Action: %s",
"result_label": "Result: %s",
"dialogue_label": "Dialogue: %s",
"atmosphere_label": "Atmosphere: %s",
"shot_type_label": "Shot type: %s",
"angle_label": "Angle: %s",
"movement_label": "Movement: %s",
"drama_info_template": "Title: %s\nSummary: %s\nGenre: %s",
},
"zh": {
"outline_request": "请为以下主题创作短剧大纲:\n\n主题:%s",
"genre_preference": "\n类型偏好:%s",
"style_requirement": "\n风格要求:%s",
"episode_count": "\n剧集数量:%d集",
"episode_importance": "\n\n**重要:必须在episodes数组中规划完整的%d集剧情,每集都要有明确的故事内容!**",
"character_request": "剧本内容:\n%s\n\n请从剧本中提取并整理最多 %d 个主要角色的详细设定。",
"episode_script_request": "剧本大纲:\n%s\n%s\n请基于以上大纲和角色,创作 %d 集的详细剧本。\n\n**重要要求:**\n- 必须生成完整的 %d 集,从第1集到第%d集,不能遗漏\n- 每集约3-5分钟(150-300秒)\n- 每集的duration字段要根据剧本内容长度合理设置,不要都设置为同一个值\n- 返回的JSON中episodes数组必须包含 %d 个元素",
"frame_info": "镜头信息:\n%s\n\n请直接生成首帧的图像提示词,不要任何解释:",
"key_frame_info": "镜头信息:\n%s\n\n请直接生成关键帧的图像提示词,不要任何解释:",
"last_frame_info": "镜头信息:\n%s\n\n请直接生成尾帧的图像提示词,不要任何解释:",
"script_content_label": "【剧本内容】",
"storyboard_list_label": "【分镜头列表】",
"task_label": "【任务】",
"character_list_label": "【本剧可用角色列表】",
"scene_list_label": "【本剧已提取的场景背景列表】",
"task_instruction": "将小说剧本按**独立动作单元**拆解为分镜头方案。",
"character_constraint": "**重要**:在characters字段中,只能使用上述角色列表中的角色ID(数字),不得自创角色或使用其他ID。",
"scene_constraint": "**重要**:在scene_id字段中,必须从上述背景列表中选择最匹配的背景ID(数字)。如果没有合适的背景,则填null。",
"shot_description_label": "镜头描述: %s",
"scene_label": "场景: %s, %s",
"characters_label": "角色: %s",
"action_label": "动作: %s",
"result_label": "结果: %s",
"dialogue_label": "对白: %s",
"atmosphere_label": "氛围: %s",
"shot_type_label": "景别: %s",
"angle_label": "角度: %s",
"movement_label": "运镜: %s",
"drama_info_template": "剧名:%s\n简介:%s\n类型:%s",
},
}
lang := "zh"
if p.IsEnglish() {
lang = "en"
}
template, ok := templates[lang][key]
if !ok {
return ""
}
if len(args) > 0 {
return fmt.Sprintf(template, args...)
}
return template
}
// GetStylePrompt 获取风格提示词
func (p *PromptI18n) GetStylePrompt(style string) string {
if style == "" {
return ""
}
stylePrompts := map[string]map[string]string{
"zh": {
"ghibli": `**[专家角色定位]**
你现在是一位吉卜力工作室顶级美术指导与背景画师,擅长捕捉"宏大自然与微观生活"的平衡感,深谙宫崎骏式的色彩心理学。
**[风格核心逻辑]**
- **视觉流派与质感**:采用经典的吉卜力风格。画面具有浓郁的水彩晕染质感(Watercolor texture),拒绝冰冷的3D渲染,强调温暖且有呼吸感的笔触。线条清晰且细腻,呈现出赛璐珞(Cel-shading)上色的明快感。
- **色彩与光影美学**:使用**"高调色彩美学"**。主色调明亮、通透、高饱和度但色相柔和。光影模拟"夏日午后"的自然采光,光线如同浸透在空气中,具有极佳的明度。阴影部分带有微妙的蓝紫色调,增加画面的通透感。
- **氛围意向**:怀旧、宁静、牧歌式的(Pastoral)、微风感。画面要传达出一种"世界依然美好"的宁静感和探索欲。`,
"guoman": `**[专家角色定位]**
你是一位顶尖的数字插画艺术家,擅长将传统东方韵味与现代游戏美术的华丽视觉特效(VFX)相结合,是"东方幻想主义"构图的大师。
**[风格核心逻辑]**
- **视觉流派与质感**:融合了**新国风数字艺术(Modern Zen Illustration)**与**史诗级奇幻渲染**。画面质感细腻且带有微微的丝滑感,类似高精度的2D数字绘画。强调光影的体积感,画面中包含大量微小的粒子效果和发光氛围。
- **核心色彩与发光美学**:使用**"撞色与内生光影"**。主色调通常是冷暖色调的剧烈碰撞(如靛青色与金橙色)。画面逻辑的核心在于**"局部发光"**:暗部点缀着发光的荧光元素(如荧光植物、灯火或水晶质感),这种对比营造了强烈的魔法感和神秘感。
- **装饰性元素逻辑**:强调**"线条的流动感"**。画面中充斥着优美的曲线,这些线条通常由发光带、飘带或自然界的纹理(如流水的走势)组成,增强了整体的装饰性和节奏感。`,
"wasteland": `**[专家角色定位]**
你是一位专注于"末世叙事"的视觉艺术家,擅长运用**硬核线条(Hard Line-art)**和**复古平面印刷感**来营造史诗般的荒凉氛围,深受让·吉罗(Moebius)和现代废土科幻插画的影响。
**[风格核心逻辑]**
- **视觉流派与笔触质感**:采用**硬缘线条绘图风格(Hard-edged Line Art)**。画面强调清晰的黑色轮廓线,具有强烈的漫画插图感。质感上呈现出一种**颗粒状的平面印刷感(Grainy textures)**或类似旧报纸、复古海报的纹理,拒绝平滑的渐变,倾向于使用排线或点阵来表现阴影。
- **色彩美学逻辑**:采用**"低频限色色调(Limited Palette)"**。画面通常被一种压抑且统一的色调统治(如灰土色、铁锈橙、荒漠黄)。核心视觉冲击力来自于**一个强烈的对比色点**(如此处巨大的红色落日),这种"单点高亮"的逻辑在灰暗的废土背景中能瞬间抓住视线。
- **光影表现手法**:使用**"高对比度强侧光(High-contrast Side Lighting)"**。模拟黄昏或黎明的低角度光线,产生极长的投影。光影逻辑极其简化,明暗交界线生硬且明确,营造出一种干枯、灼热且寂静的戏剧张力。`,
"nostalgia": `**[专家角色定位]**
你是一位专注于**"怀旧赛璐珞(Nostalgic Cel-shading)"**风格的视觉艺术家,擅长模拟20世纪80-90年代手绘动画的质感,利用色彩与噪点营造一种温和、感性且略带忧郁的都市氛围。
**[风格核心逻辑]**
- **视觉流派与画面质感**:采用经典的**90年代复古动画风格(90s Retro Anime Style)**。画面具有明显的**胶片颗粒感(Film grain)**和微弱的**色散效果(Chromatic aberration)**,模拟旧式电视或磁带的播放质感。质感上强调"不完美的细腻",即线条略显柔和,不像现代矢量图那样锐利,给人一种手工绘制的温度感。
- **色彩美学逻辑**:使用**"低对比度粉紫色调(Muted Pastel Palette)"**。画面被一种柔和的、如梦境般的暮色统治,通常以淡紫色、藕粉色或灰蓝色为主基调。色彩逻辑的核心在于**"弱化的黑场"**:没有纯黑,所有深色都带有紫色或蓝色的倾向。这种色调能瞬间勾勒出一种孤独但温馨的"都市黄昏"感。
- **光影表现手法**:强调**"弥散的点光源(Diffuse Point Lights)"**。光线不是硬性的投射,而是呈晕染状。例如,路灯、车灯或月亮周围有一圈柔和的朦胧光晕(Glow effect)。地面通常带有微弱的雨后反光或湿润感,增加光影的层次感和梦幻感。`,
"pixel": `**[专家角色定位]**
你是一位资深的**8位/16位像素艺术家 (Pixel Art Consultant)**,擅长利用受限的分辨率和调色盘来构建具有极强代入感的虚拟世界,模拟早期电子游戏(如《星露谷物语》或经典RPG)的视觉美学。
**[风格核心逻辑]**
- **视觉流派与画面质感**:采用纯正的**像素艺术风格 (Pixel Art)**。画面由清晰可见的方格(Pixels)组成,强调**"阶梯状线条 (Aliased lines)"**。质感上完全摒弃平滑的渐变和模糊,追求一种数码化的、网格化的块状美感。
- **色彩美学逻辑**:使用**"受限调色盘 (Limited Color Palette)"**。色彩选择极度精简,不追求自然的过渡,而是通过大面积的色块叠加。色彩逻辑的核心在于**"抖动算法思维 (Dithering logic)"**:通过不同颜色方格的交替排列来模拟明暗变化,色调通常饱和度中等,呈现出一种清爽、明快的电子游戏感。
- **光影表现手法**:强调**"色块式阴影 (Flat Shading)"**。光影表现不使用羽化或软光,而是通过增加一层更深的同色系像素块来表示投影。光线通常是恒定的,没有复杂的反射或折射,太阳或光源本身也被处理成一个规则的像素圆点。`,
"voxel": `**[专家角色定位]**
你是一位顶尖的**3D体素建模师 (Voxel Artist)**,擅长利用统一规格的立方体单位构建充满童趣、模块化且具有高度秩序感的微缩世界。你的视觉风格强调**低多边形(Low-poly)的纯粹性**与**现代实时光影渲染**的结合。
**[风格核心逻辑]**
- **视觉流派与质感**:采用**三维体素风格 (3D Voxel Style)**。画面由无数等比例的立方体单元(Voxels)堆叠而成,呈现出一种强烈的模块化感。质感上具有明显的**"方块化线条"**,物体表面是平整的色块,这种简化的几何语言创造了一种独特的数字美感。
- **色彩美学逻辑**:使用**"自然饱和度与渐变光影"**。色彩通常根据环境属性进行大块划分(如草地的绿、土地的褐),但关键在于**色彩的微小扰动 (Color Jitter)**:同一区域的方块颜色会有微妙的深浅差异,模拟真实环境的随机感。色调通常明亮、清新,充满活力感。
- **光影表现手法**:强调**"全局光照渲染 (Global Illumination)"**。这是体素艺术升华的关键:尽管物体是方块状的,但光影必须是**电影级的写实渲染**。光线具有温暖的体积感(如耶稣光),阴影边缘柔和且带有环境遮蔽(AO)效果,方块边缘会被高亮勾勒,使画面看起来像是一个精致的现实微缩模型。`,
"urban": `**[专家角色定位]**
你是一位顶尖的**网漫主笔(Lead Webtoon Artist)**,擅长创作具有现代都市感的人物立绘。你的视觉风格强调**锐利的轮廓线**、**利落的穿搭逻辑**以及**冷色调的都市氛围**,旨在营造一种"高冷、精致、工业化美感"的视觉冲击。
**[风格核心逻辑]**
- **视觉流派与画面质感**:采用**现代韩漫数字绘图风格 (Modern Webtoon Art Style)**。画面具有极干净的**矢量线条 (Crisp line art)**,没有任何多余的笔触。质感上呈现出一种平滑的数字皮肤质感,强调色彩的整洁度,避免了复杂的笔触叠加。
- **色彩美学逻辑**:使用**"冷调都市灰(Muted Urban Tones)"**。画面以黑、白、灰、深蓝等中性色为主色调。色彩逻辑的核心在于**"高对比度的荧光色反差"**:整体处于清冷的低饱和度环境下,但利用背景中的**霓虹灯(Neon glow)**或电子屏产生高亮的粉、蓝、紫偏色,营造出一种深夜都市的疏离感。
- **光影表现手法**:强调**"硬边赛璐珞阴影 (Hard Cel-shading)"**。阴影边缘极其干脆,没有渐变。光影逻辑模仿**"环境侧光"**:光线通常来自侧方的霓虹招牌,在人物一侧留下窄长的亮边(Rim lighting),增强了人物的轮廓感和立体感。`,
"guoman3d": `**[专家角色定位]**
你是一位顶级**次世代游戏美术总监 (Lead Technical Artist)**,擅长使用虚幻引擎 5 (UE5) 创作高精度的 3D 仙侠角色。你的风格以**物理渲染 (PBR)** 的极高真实度、复杂的服饰层次感以及极具东方美学的全局光照处理著称。
**[风格核心逻辑]**
- **视觉流派与画面质感**:采用**高精细 3D 写实渲染风格 (High-fidelity 3D Rendering)**。画面具有极强的**次世代游戏质感 (Next-gen game aesthetic)**,强调皮肤的次表面散射 (SSS) 效果和极其真实的服饰纹理(如丝绸的平滑感、皮革的磨损感、金属的拉丝质感)。整体呈现出一种细腻的数码雕琢美,边缘锐利且细节丰富。
- **色彩美学逻辑**:使用**"素雅沉稳的中性色调 (Sophisticated Neutral Palette)"**。不同于高饱和度的动漫风格,这种逻辑倾向于使用低饱和、高明度的色彩(如米白、石青、灰褐),并配以小面积的暗红色或金色作为高级感点缀。光影色彩通常偏向**清晨或傍晚的自然日光**,给人一种宁静、肃穆且大气的东方韵味。
- **光影表现手法**:强调**"电影级动态光影 (Cinematic Lighting)"**。光源方向明确(通常是明亮的侧逆光),在人物边缘勾勒出一层淡淡的金边 (Rim Light),将主体与背景完美分离。同时利用环境遮蔽 (AO) 增加细节深度,让服饰的每一个褶皱都清晰可见,呈现出一种沉浸式的戏剧张力。`,
"chibi3d": `**[专家角色定位]**
你是一位顶尖的 **3D 玩具设计师与灯光渲染师**,擅长创作高精细度的数字手办。你的视觉风格结合了 **Q 版二头身比例 (Chibi proportions)** 与 **超写实材质渲染 (PBR Rendering)**,旨在营造一种精致、可爱且具有高级触感的"数字潮流玩具"视觉效果。
**[风格核心逻辑]**
- **视觉流派与画面质感**:采用 **3D 盲盒艺术风格 (Blind Box / Toy Art Style)**。画面具有极强的 **类塑料与树脂质感 (Plastic and Resin texture)**,表面圆润、平滑,边缘带有微妙的倒角。主体呈现出明显的 **Q 版比例**(大头小身),增强了亲和力。
- **色彩美学逻辑**:使用 **"温和的高饱和调色盘 (Muted Vibrant Palette)"**。色彩鲜艳但并不刺眼。色彩分布遵循"主次分明"原则,利用大面积的自然底色(如森林绿、泥土褐)衬托主体鲜明的服饰色彩。
- **光影表现手法**:光源通常柔和且均匀。**顶光/面光**:均匀照亮主体正面,突出五官和服饰细节。**环境遮蔽 (Ambient Occlusion)**:在缝隙和接触面产生细腻的阴影,增强物体的重量感和真实感。`,
},
"en": {
"ghibli": `**[Expert Role]**
You are a top Art Director and Background Artist from Studio Ghibli. You excel at capturing the balance between "grand nature and microscopic life," and you possess a deep understanding of Hayao Miyazaki's color psychology.
**[Core Style Logic]**
- **Visual Genre & Texture**: Adopts the classic Ghibli style. The imagery features a rich **watercolor texture**, rejecting cold 3D rendering in favor of warm, "breathing" brushstrokes. Lines are clear yet delicate, presenting the vibrant feel of **cel-shading**.
- **Color & Lighting Aesthetics**: Utilizes **"High-key Color Aesthetics."** The palette is bright, transparent, and high-saturated but with soft hues. Lighting simulates the natural light of a "summer afternoon," where light feels soaked into the air with excellent luminosity. Shadows contain subtle blue-purple tones to enhance the transparency of the frame.
- **Atmospheric Intent**: Nostalgic, serene, **pastoral**, and breezy. The image should convey a sense of tranquility and a desire for exploration—a feeling that "the world is still beautiful."`,
"guoman": `**[Expert Role]**
You are a top-tier digital illustration artist, skilled at merging traditional Eastern charm with the magnificent Visual Effects (VFX) of modern game art. You are a master of "Oriental Fantasy" composition.
**[Core Style Logic]**
- **Visual Genre & Texture**: A fusion of **Modern Zen Illustration (New Guofeng)** and epic fantasy rendering. The texture is delicate with a silky feel, similar to high-precision 2D digital painting. It emphasizes volumetric lighting and includes a large amount of tiny particle effects and glowing atmospheres.
- **Core Color & Luminous Aesthetics**: Employs **"Contrasting Colors & Endogenous Lighting."** The main palette usually features intense collisions of cool and warm tones (e.g., indigo and golden orange). The core logic lies in **"Local Luminescence"**: dark areas are dotted with bioluminescent elements (like fluorescent plants, lanterns, or crystal textures), creating a strong sense of magic and mystery.
- **Decorative Element Logic**: Emphasizes the **"Flow of Lines."** The frame is filled with elegant curves, often composed of light trails, ribbons, or natural textures (like the flow of water), enhancing the overall decorativeness and rhythm.`,
"wasteland": `**[Expert Role]**
You are a visual artist focused on "Post-Apocalyptic Narrative," skilled at using **Hard Line-art** and a **retro print feel** to create epic, desolate atmospheres, heavily influenced by Moebius and modern wasteland sci-fi illustrations.
**[Core Style Logic]**
- **Visual Genre & Brushwork Texture**: Adopts a **Hard-edged Line Art** style. The image emphasizes bold black outlines with a strong comic illustration feel. The texture presents a **grainy, flat-print quality**, similar to old newspapers or retro posters, rejecting smooth gradients in favor of hatching or stippling for shadows.
- **Color Aesthetic Logic**: Employs a **"Limited Palette."** The frame is typically dominated by an oppressive, unified tone (e.g., dusty earth, rust orange, desert yellow). The core visual impact comes from a **single strong contrast point** (such as a massive red setting sun), a "single-point highlight" logic that instantly grabs attention against the gloomy background.
- **Lighting Technique**: Uses **"High-contrast Side Lighting."** Simulates the low-angle light of dusk or dawn, producing extremely long shadows. The lighting logic is highly simplified with sharp, distinct terminators, creating a dry, scorching, and silent dramatic tension.`,
"nostalgia": `**[Expert Role]**
You are a visual artist specializing in the **"Nostalgic Cel-shading"** style, expert at simulating the texture of 1980s-90s hand-drawn animation. You use color and noise to create a gentle, emotional, and slightly melancholic urban atmosphere.
**[Core Style Logic]**
- **Visual Genre & Frame Texture**: Adopts the classic **90s Retro Anime Style**. The image features obvious **film grain** and slight **chromatic aberration**, simulating the playback quality of old TVs or VHS tapes. The texture emphasizes "imperfect delicacy"—lines are soft rather than sharp like modern vectors, giving a sense of handcrafted warmth.
- **Color Aesthetic Logic**: Uses a **"Muted Pastel Palette."** The frame is dominated by a soft, dreamlike twilight, usually featuring lavender, lotus pink, or grayish-blue. The core logic is the **"Weakened Black Point"**: there are no pure blacks; all dark colors lean toward purple or blue. This tone instantly outlines a lonely but cozy "urban dusk" feel.
- **Lighting Technique**: Emphasizes **"Diffuse Point Lights."** Light is not a hard projection but a bleeding glow. For example, streetlights, car headlights, or the moon have a soft, hazy halo (Glow effect). Surfaces often have a slight post-rain reflection or dampness, increasing the layers and dreaminess of the light.`,
"pixel": `**[Expert Role]**
You are a senior **Pixel Art Consultant (8-bit/16-bit)**, skilled at using restricted resolutions and palettes to build highly immersive virtual worlds, simulating the aesthetics of early video games like *Stardew Valley* or classic RPGs.
**[Core Style Logic]**
- **Visual Genre & Frame Texture**: Adopts a pure **Pixel Art** style. The image consists of clearly visible squares (pixels), emphasizing **"Aliased lines."** It completely discards smooth gradients and blurring, pursuing a digital, grid-based blocky beauty.
- **Color Aesthetic Logic**: Uses a **"Limited Color Palette."** Color choices are extremely streamlined, avoiding natural transitions in favor of large color block overlays. The core logic is **"Dithering logic"**: alternating pixel patterns of different colors to simulate shading. Tones are usually medium saturation, presenting a crisp, bright video game feel.
- **Lighting Technique**: Emphasizes **"Flat Shading."** Lighting does not use feathering or soft light; instead, it uses a layer of darker pixels from the same color family to represent shadows. Light sources are constant without complex reflections, and even the sun or light sources are treated as regular pixel circles.`,
"voxel": `**[Expert Role]**
You are a top-tier **3D Voxel Artist**, skilled at using uniform cube units to build whimsical, modular, and highly ordered miniature worlds. Your style combines the purity of **Low-poly** geometry with modern real-time lighting rendering.
**[Core Style Logic]**
- **Visual Genre & Texture**: Adopts a **3D Voxel Style**. The image is composed of countless proportional cubes (voxels) stacked together, presenting a strong modular feel. The texture features obvious **"blocky lines"** and flat color surfaces; this simplified geometric language creates a unique digital aesthetic.
- **Color Aesthetic Logic**: Uses **"Natural Saturation & Gradient Lighting."** Colors are divided into large blocks based on environmental attributes (green for grass, brown for soil), but the key lies in **"Color Jitter"**: subtle shade variations between blocks in the same area to simulate the randomness of real environments. Tones are bright, fresh, and full of vitality.
- **Lighting Technique**: Emphasizes **"Global Illumination Rendering."** This is the key to elevating voxel art: while objects are blocky, the lighting must be **cinematic and realistic**. Light has warm volumetric qualities (e.g., God rays), shadows are soft with Ambient Occlusion (AO) effects, and voxel edges are highlighted, making the scene look like an exquisite real-life miniature model.`,
"urban": `**[Expert Role]**
You are a leading **Webtoon Artist**, specializing in modern urban character illustrations. Your visual style emphasizes **sharp outlines**, **slick fashion logic**, and a **cool-toned urban atmosphere**, aiming to create a "high-cold, sophisticated, industrial-chic" visual impact.
**[Core Style Logic]**
- **Visual Genre & Frame Texture**: Adopts the **Modern Webtoon Art Style**. The image features extremely clean **crisp line art** (vector-like) without any redundant strokes. The texture presents a smooth digital skin quality, emphasizing color cleanliness and avoiding complex brushwork layering.
- **Color Aesthetic Logic**: Uses **"Muted Urban Tones."** The palette is dominated by neutral colors like black, white, gray, and deep blue. The core logic is **"High-contrast Neon Accents"**: while the overall environment is cool and low-saturation, highlights from **neon glows** or electronic screens (pink, blue, purple) create a sense of late-night urban detachment.
- **Lighting Technique**: Emphasizes **"Hard Cel-shading."** Shadow edges are extremely crisp with no gradients. The logic mimics **"Environmental Rim Lighting"**: light usually comes from side neon signs, leaving a narrow bright edge (Rim lighting) on one side of the character, enhancing their silhouette and 3D feel.`,
"guoman3d": `**[Expert Role]**
You are a top-tier **Next-gen Lead Technical Artist**, skilled in using Unreal Engine 5 (UE5) to create high-precision 3D Xianxia (Immortal Hero) characters. Your style is known for high-fidelity **Physically Based Rendering (PBR)**, complex clothing layers, and global illumination with an Eastern aesthetic.
**[Core Style Logic]**
- **Visual Genre & Frame Texture**: Adopts a **High-fidelity 3D Rendering style**. The image has a strong **next-gen game aesthetic**, emphasizing Subsurface Scattering (SSS) for skin and realistic fabric textures (smoothness of silk, wear on leather, brushed metal). The overall look is a delicate digital sculpture with sharp edges and rich details.
- **Color Aesthetic Logic**: Uses a **"Sophisticated Neutral Palette."** Unlike high-saturation anime styles, this logic leans toward low-saturation, high-brightness colors (off-white, stone green, gray-brown), accented with small areas of dark red or gold for a premium feel. Lighting colors typically mimic **natural morning or evening sunlight**, giving an air of tranquility, solemnity, and grand Eastern charm.
- **Lighting Technique**: Emphasizes **"Cinematic Lighting."** Light directions are clear (usually bright side-backlighting), creating a faint golden **Rim Light** that perfectly separates the subject from the background. Ambient Occlusion (AO) is used to increase detail depth, making every fold in the clothing visible and creating immersive dramatic tension.`,
"chibi3d": `**[Expert Role]**
You are a top-tier **3D Toy Designer and Rendering Artist**, specializing in high-precision digital figurines. Your visual style combines **Chibi proportions** with **Ultra-realistic PBR rendering**, aiming to create a sophisticated, cute, and tactile "Art Toy" visual effect.
**[Core Style Logic]**
- **Visual Genre & Frame Texture**: Adopts a **3D Blind Box / Toy Art Style**. The image features strong **plastic and resin textures**; surfaces are rounded and smooth with subtle beveled edges. The subject uses **Chibi proportions** (large head, small body) to enhance appeal.
- **Color Aesthetic Logic**: Uses a **"Muted Vibrant Palette."** Colors are vivid but not piercing. Color distribution follows a "primary-secondary" principle, using large areas of natural base colors (forest green, earth brown) to set off the bright colors of the character's outfit.
- **Lighting Technique**: Light sources are typically soft and even: **Top/Key Light**: Evenly illuminates the subject's front, highlighting facial features and clothing details. **Ambient Occlusion (AO)**: Produces delicate shadows in crevices and contact points, enhancing the object's sense of weight and realism.`,
},
}
lang := "zh"
if p.IsEnglish() {
lang = "en"
}
if prompts, ok := stylePrompts[lang]; ok {
if prompt, exists := prompts[style]; exists {
return prompt
}
}
return ""
}
// GetVideoConstraintPrompt 获取视频生成的约束提示词
// referenceMode: "single" (单图), "first_last" (首尾帧), "multiple" (多图), "action_sequence" (动作序列)
func (p *PromptI18n) GetVideoConstraintPrompt(referenceMode string) string {
// 动作序列图(九宫格)的约束提示词
actionSequencePrompts := map[string]string{
"zh": `### 角色定义
你是一个极高精度的视频生成专家,擅长将九宫格(3x3)序列图转化为具有电影质感的连贯视频。你的核心任务是解析图像中的时空逻辑,并严格遵守首尾帧约束。
### 核心执行逻辑
1. **首尾帧锚定:** 必须提取九宫格的第一格(左上角)作为视频的起始帧(Frame 0),提取第九格(右下角)作为视频的结束帧(Final Frame)。
2. **序列插值(Interpolation):** 九宫格的第 2 至 第 8 格定义了动作的关键路径。你需分析这些关键帧之间的逻辑位移、光影变化和物体形变。
3. **一致性维护:** 确保角色特征(面部、服装)、场景细节、艺术风格在全视频中保持 100% 的时空稳定性。
4. **动态补充:** 在九宫格定义的关键动作之间,自动补全流畅的过渡帧,确保视频动作频率自然(建议 24fps 或 30fps)。
### 结构化约束指令
* **输入解析:** 识别用户提供的场景描述词(Prompt)与九宫格参考图。
* **动作矢量化:** 计算物体从 Grid 1 到 Grid 9 的运动矢量。如果九宫格展示的是缩放或平移,请在视频中还原精准的运镜。
* **严禁幻觉:** 禁止引入九宫格和提示词中未提及的新元素或背景切换。`,
"en": `### Role Definition
You are an ultra-high-precision video generation expert, specializing in transforming 9-grid (3x3) sequential images into coherent videos with cinematic quality. Your core task is to parse the spatiotemporal logic within the images and strictly adhere to first-and-last frame constraints.
### Core Execution Logic
1. **First-Last Frame Anchoring:** You must extract Grid 1 (top-left corner) as the video's starting frame (Frame 0) and Grid 9 (bottom-right corner) as the ending frame (Final Frame).
2. **Sequence Interpolation:** Grids 2 through 8 define the key action path. You need to analyze the logical displacement, lighting changes, and object deformations between these keyframes.
3. **Consistency Maintenance:** Ensure that character features (face, clothing), scene details, and artistic style maintain 100% spatiotemporal stability throughout the entire video.
4. **Dynamic Supplementation:** Automatically fill in smooth transition frames between the keyframes defined by the 9-grid, ensuring natural video motion frequency (recommended 24fps or 30fps).
### Structured Constraint Instructions
* **Input Parsing:** Identify the scene description (Prompt) and 9-grid reference images provided by the user.
* **Motion Vectorization:** Calculate the motion vectors of objects from Grid 1 to Grid 9. If the 9-grid shows scaling or panning, restore precise camera movements in the video.
* **Hallucination Prohibition:** Do not introduce new elements or background switches not mentioned in the 9-grid and prompt.`,
}
// 通用约束提示词(单图、首尾帧、多图)
generalPrompts := map[string]string{
"zh": `### 角色定义
你是一个顶级的视频动态分析师与合成专家。你能够仅凭一张静态图或一组起始/结束帧,精准识别画面中的物理属性、光影流向及潜在的运动趋势,生成符合物理定律的高质量视频。
### 核心执行逻辑
1. **模式识别:**
* **单图模式(Single Image):** 将输入图视为 Frame 0。分析画面中的"张力点"(如倾斜的身体、流动的液体、眼神的方向),并向该方向延续动作。
* **双图模式(First & Last Frames):** 严格锚定第一张图为起始,第二张图为终点。通过**语义插值算法**,计算两图之间所有元素的位移轨迹。
2. **物理一致性(Physics Preservation):**
* **质量守恒:** 确保物体在运动过程中体积、密度和材质质感不发生突变。
* **运动惯性:** 遵循经典力学,起步平稳,加速自然,停止时不应有生硬的切断感。
3. **环境外推:** 自动补充主画面之外的背景延伸,确保运镜(Pan/Tilt/Zoom)时不会出现画面空洞或黑边。`,
"en": `### Role Definition
You are a top-tier video dynamics analyst and synthesis expert. You can accurately identify physical properties, light flow, and potential motion trends in a static image or a set of start/end frames, generating high-quality videos that comply with physical laws.
### Core Execution Logic
1. **Mode Recognition:**
* **Single Image Mode:** Treat the input image as Frame 0. Analyze "tension points" in the frame (such as tilted bodies, flowing liquids, eye direction) and extend the action in that direction.
* **First & Last Frames Mode:** Strictly anchor the first image as the start and the second image as the endpoint. Use **semantic interpolation algorithms** to calculate the displacement trajectories of all elements between the two images.
2. **Physics Preservation:**
* **Mass Conservation:** Ensure that objects do not undergo sudden changes in volume, density, or material texture during motion.
* **Motion Inertia:** Follow classical mechanics with smooth starts, natural acceleration, and no abrupt stops.
3. **Environment Extrapolation:** Automatically supplement background extensions beyond the main frame to ensure no voids or black edges appear during camera movements (Pan/Tilt/Zoom).`,
}
lang := "zh"
if p.IsEnglish() {
lang = "en"
}
// 如果是动作序列模式,返回九宫格约束提示词
if referenceMode == "action_sequence" {
if prompt, ok := actionSequencePrompts[lang]; ok {
return prompt
}
}
// 其他模式返回通用约束提示词
if prompt, ok := generalPrompts[lang]; ok {
return prompt
}
return ""
}
================================================
FILE: application/services/prop_service.go
================================================
package services
import (
"fmt"
"time"
// Added missing import
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/ai"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/utils"
"gorm.io/gorm"
)
type PropService struct {
db *gorm.DB
aiService *AIService
taskService *TaskService
imageGenerationService *ImageGenerationService
log *logger.Logger
config *config.Config
promptI18n *PromptI18n
}
func NewPropService(db *gorm.DB, aiService *AIService, taskService *TaskService, imageGenerationService *ImageGenerationService, log *logger.Logger, cfg *config.Config) *PropService {
return &PropService{
db: db,
aiService: aiService,
taskService: taskService,
imageGenerationService: imageGenerationService,
log: log,
config: cfg,
promptI18n: NewPromptI18n(cfg),
}
}
// ListProps 获取剧本的道具列表
func (s *PropService) ListProps(dramaID uint) ([]models.Prop, error) {
var props []models.Prop
if err := s.db.Where("drama_id = ?", dramaID).Find(&props).Error; err != nil {
return nil, err
}
return props, nil
}
// CreateProp 创建道具
func (s *PropService) CreateProp(prop *models.Prop) error {
return s.db.Create(prop).Error
}
// UpdateProp 更新道具
func (s *PropService) UpdateProp(id uint, updates map[string]interface{}) error {
return s.db.Model(&models.Prop{}).Where("id = ?", id).Updates(updates).Error
}
// DeleteProp 删除道具
func (s *PropService) DeleteProp(id uint) error {
return s.db.Delete(&models.Prop{}, id).Error
}
// ExtractPropsFromScript 从剧本提取道具(异步)
func (s *PropService) ExtractPropsFromScript(episodeID uint) (string, error) {
var episode models.Episode
if err := s.db.First(&episode, episodeID).Error; err != nil {
return "", fmt.Errorf("episode not found: %w", err)
}
task, err := s.taskService.CreateTask("prop_extraction", fmt.Sprintf("%d", episodeID))
if err != nil {
return "", err
}
go s.processPropExtraction(task.ID, episode)
return task.ID, nil
}
func (s *PropService) processPropExtraction(taskID string, episode models.Episode) {
s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在分析剧本...")
script := ""
if episode.ScriptContent != nil {
script = *episode.ScriptContent
}
// 获取 drama 的 style 信息
var drama models.Drama
if err := s.db.First(&drama, episode.DramaID).Error; err != nil {
s.log.Warnw("Failed to load drama", "error", err, "drama_id", episode.DramaID)
}
promptTemplate := s.promptI18n.GetPropExtractionPrompt(drama.Style)
prompt := fmt.Sprintf(promptTemplate, script)
response, err := s.aiService.GenerateText(prompt, "", ai.WithMaxTokens(2000))
if err != nil {
s.taskService.UpdateTaskError(taskID, err)
return
}
var extractedProps []struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
ImagePrompt string `json:"image_prompt"`
}
if err := utils.SafeParseAIJSON(response, &extractedProps); err != nil {
s.taskService.UpdateTaskError(taskID, fmt.Errorf("解析AI结果失败: %w", err))
return
}
s.taskService.UpdateTaskStatus(taskID, "processing", 50, "正在保存道具...")
var createdProps []models.Prop
for _, p := range extractedProps {
prop := models.Prop{
DramaID: episode.DramaID,
Name: p.Name,
Type: &p.Type,
Description: &p.Description,
Prompt: &p.ImagePrompt,
}
// 检查是否已存在同名道具(避免重复)
var count int64
s.db.Model(&models.Prop{}).Where("drama_id = ? AND name = ?", episode.DramaID, p.Name).Count(&count)
if count == 0 {
if err := s.db.Create(&prop).Error; err == nil {
createdProps = append(createdProps, prop)
}
}
}
s.taskService.UpdateTaskResult(taskID, createdProps)
}
// GeneratePropImage 生成道具图片
// 这里可以复用 ImageGenerationService,或者直接调用 AI Service
// 简单起见,这里直接调用 ImageGenerationService 如果可以,或者 AI Service.
// 为了保持架构一致性,应该创建一个 ImageGeneration 记录,然后复用现有的图片生成流程?
// 但为了简单快速实现,这里先写一个专用的方法,或者更好的方式是:
// 创建一个 ImageGeneration 记录,类型设为 "prop",然后复用 ImageGenerationService 的逻辑。
// 但 ImageGenerationService 目前绑定了 Storyboard/Scene ID 等。
// 所以这里实现一个简化的直接生成逻辑,或者扩展 ImageGenerationService。
// 鉴于时间,我实现一个简化的直接生成并保存图片的方法。
func (s *PropService) GeneratePropImage(propID uint) (string, error) {
// 1. 获取道具信息
var prop models.Prop
if err := s.db.First(&prop, propID).Error; err != nil {
return "", err
}
if prop.Prompt == nil || *prop.Prompt == "" {
return "", fmt.Errorf("道具没有图片提示词")
}
// 2. 创建任务
task, err := s.taskService.CreateTask("prop_image_generation", fmt.Sprintf("%d", propID))
if err != nil {
return "", err
}
go s.processPropImageGeneration(task.ID, prop)
return task.ID, nil
}
func (s *PropService) processPropImageGeneration(taskID string, prop models.Prop) {
s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在生成图片...")
// 准备生成参数
imageStyle := "Modern Japanese anime style"
imageSize := "1024x1024"
// 创建生成请求
req := &GenerateImageRequest{
DramaID: fmt.Sprintf("%d", prop.DramaID),
PropID: &prop.ID,
ImageType: string(models.ImageTypeProp),
Prompt: *prop.Prompt,
Size: imageSize,
Style: &imageStyle,
Provider: s.config.AI.DefaultImageProvider, // 使用默认配置
}
// 调用 ImageGenerationService
imageGen, err := s.imageGenerationService.GenerateImage(req)
if err != nil {
s.taskService.UpdateTaskError(taskID, err)
return
}
// 轮询 ImageGeneration 状态直到完成
maxAttempts := 60
pollInterval := 2 * time.Second
for i := 0; i < maxAttempts; i++ {
time.Sleep(pollInterval)
// 重新加载 imageGen
var currentImageGen models.ImageGeneration
if err := s.db.First(¤tImageGen, imageGen.ID).Error; err != nil {
s.log.Errorw("Failed to poll image generation", "error", err, "id", imageGen.ID)
continue
}
if currentImageGen.Status == models.ImageStatusCompleted {
if currentImageGen.ImageURL != nil {
// 任务成功
// ImageGenerationService 已经更新了 Prop.ImageURL,这里只需要更新 TaskService
s.taskService.UpdateTaskResult(taskID, map[string]string{"image_url": *currentImageGen.ImageURL})
return
}
} else if currentImageGen.Status == models.ImageStatusFailed {
errMsg := "图片生成失败"
if currentImageGen.ErrorMsg != nil {
errMsg = *currentImageGen.ErrorMsg
}
s.taskService.UpdateTaskError(taskID, fmt.Errorf(errMsg))
return
}
// 更新进度(可选)
s.taskService.UpdateTaskStatus(taskID, "processing", 10+i, "正在生成图片...")
}
s.taskService.UpdateTaskError(taskID, fmt.Errorf("生成超时"))
}
// AssociatePropsWithStoryboard 关联道具到分镜
func (s *PropService) AssociatePropsWithStoryboard(storyboardID uint, propIDs []uint) error {
var storyboard models.Storyboard
if err := s.db.First(&storyboard, storyboardID).Error; err != nil {
return err
}
var props []models.Prop
if len(propIDs) > 0 {
if err := s.db.Where("id IN ?", propIDs).Find(&props).Error; err != nil {
return err
}
}
return s.db.Model(&storyboard).Association("Props").Replace(props)
}
================================================
FILE: application/services/resource_transfer_service.go
================================================
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相关功能已移除
// 如需资源转存功能,请使用本地存储
================================================
FILE: application/services/script_generation_service.go
================================================
package services
import (
"fmt"
"strconv"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/ai"
"github.com/drama-generator/backend/pkg/config"
"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
config *config.Config
promptI18n *PromptI18n
taskService *TaskService
}
func NewScriptGenerationService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationService {
return &ScriptGenerationService{
db: db,
aiService: NewAIService(db, log),
log: log,
config: cfg,
promptI18n: NewPromptI18n(cfg),
taskService: NewTaskService(db, log),
}
}
type GenerateCharactersRequest struct {
DramaID string `json:"drama_id" binding:"required"`
EpisodeID uint `json:"episode_id"`
Outline string `json:"outline"`
Count int `json:"count"`
Temperature float64 `json:"temperature"`
Model string `json:"model"` // 指定使用的文本模型
}
func (s *ScriptGenerationService) GenerateCharacters(req *GenerateCharactersRequest) (string, error) {
var drama models.Drama
if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil {
return "", fmt.Errorf("drama not found")
}
// 创建任务
task, err := s.taskService.CreateTask("character_generation", req.DramaID)
if err != nil {
s.log.Errorw("Failed to create character generation task", "error", err)
return "", fmt.Errorf("创建任务失败: %w", err)
}
// 异步处理角色生成
go s.processCharacterGeneration(task.ID, req)
s.log.Infow("Character generation task created", "task_id", task.ID, "drama_id", req.DramaID)
return task.ID, nil
}
// processCharacterGeneration 异步处理角色生成
func (s *ScriptGenerationService) processCharacterGeneration(taskID string, req *GenerateCharactersRequest) {
// 更新任务状态为处理中
s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在生成角色...")
count := req.Count
if count == 0 {
count = 5
}
// 获取 drama 的 style 信息
var drama models.Drama
if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil {
s.log.Errorw("Drama not found during character generation", "error", err, "drama_id", req.DramaID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "剧本信息不存在")
return
}
systemPrompt := s.promptI18n.GetCharacterExtractionPrompt(drama.Style)
outlineText := req.Outline
if outlineText == "" {
outlineText = s.promptI18n.FormatUserPrompt("drama_info_template", drama.Title, drama.Description, drama.Genre)
}
userPrompt := s.promptI18n.FormatUserPrompt("character_request", outlineText, count)
temperature := req.Temperature
if temperature == 0 {
temperature = 0.7
}
// 如果指定了模型,使用指定的模型;否则使用默认配置
var text string
var err error
if req.Model != "" {
s.log.Infow("Using specified model for character generation", "model", req.Model, "task_id", taskID)
client, getErr := s.aiService.GetAIClientForModel("text", req.Model)
if getErr != nil {
s.log.Warnw("Failed to get client for specified model, using default", "model", req.Model, "error", getErr, "task_id", taskID)
text, err = s.aiService.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature))
} else {
text, err = client.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature))
}
} else {
text, err = s.aiService.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature))
}
if err != nil {
s.log.Errorw("Failed to generate characters", "error", err, "task_id", taskID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "AI生成失败: "+err.Error())
return
}
s.log.Infow("AI response received for character generation", "length", len(text), "preview", text[:minInt(200, len(text))], "task_id", taskID)
// AI直接返回数组格式
var result []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"`
}
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))], "task_id", taskID)
s.taskService.UpdateTaskStatus(taskID, "failed", 0, "解析AI返回结果失败")
return
}
var characters []models.Character
for _, char := range result {
// 检查角色是否已存在
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, "task_id", taskID)
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, "task_id", taskID)
continue
}
characters = append(characters, character)
}
// 如果提供了 EpisodeID,建立 episode_characters 关联关系
if req.EpisodeID > 0 {
var episode models.Episode
if err := s.db.First(&episode, req.EpisodeID).Error; err == nil {
// 使用 GORM 的 Association 建立多对多关联
if err := s.db.Model(&episode).Association("Characters").Append(characters); err != nil {
s.log.Errorw("Failed to associate characters with episode", "error", err, "episode_id", req.EpisodeID, "task_id", taskID)
} else {
s.log.Infow("Characters associated with episode", "episode_id", req.EpisodeID, "character_count", len(characters), "task_id", taskID)
}
} else {
s.log.Errorw("Episode not found for association", "episode_id", req.EpisodeID, "error", err, "task_id", taskID)
}
}
// 更新任务状态为完成
resultData := map[string]interface{}{
"characters": characters,
"count": len(characters),
}
s.taskService.UpdateTaskResult(taskID, resultData)
s.log.Infow("Character generation completed", "task_id", taskID, "drama_id", req.DramaID, "character_count", len(characters))
}
// GenerateScenesForEpisode 已废弃,使用 StoryboardService.GenerateStoryboard 替代
// ParseScript 已废弃,使用 GenerateCharacters 替代
// minInt 返回两个整数中较小的一个
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
================================================
FILE: application/services/storyboard_composition_service.go
================================================
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"`
LocalPath *string `json:"local_path,omitempty"`
}
type SceneBackgroundInfo struct {
ID uint `json:"id"`
Location string `json:"location"`
Time string `json:"time"`
ImageURL *string `json:"image_url,omitempty"`
LocalPath *string `json:"local_path,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"`
ShotType *string `json:"shot_type"`
Angle *string `json:"angle"`
Movement *string `json:"movement"`
Location *string `json:"location"`
Time *string `json:"time"`
Duration int `json:"duration"`
Dialogue *string `json:"dialogue"`
Action *string `json:"action"`
Result *string `json:"result"`
Atmosphere *string `json:"atmosphere"`
BgmPrompt *string `json:"bgm_prompt,omitempty"`
SoundEffect *string `json:"sound_effect,omitempty"`
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,
ShotType: storyboard.ShotType,
Angle: storyboard.Angle,
Movement: storyboard.Movement,
Location: storyboard.Location,
Time: storyboard.Time,
Duration: storyboard.Duration,
Action: storyboard.Action,
Dialogue: storyboard.Dialogue,
Result: storyboard.Result,
Atmosphere: storyboard.Atmosphere,
BgmPrompt: storyboard.BgmPrompt,
SoundEffect: storyboard.SoundEffect,
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,
LocalPath: char.LocalPath,
}
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,
LocalPath: scene.LocalPath,
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"`
ImageURL *string `json:"image_url"`
LocalPath *string `json:"local_path"`
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.ImageURL != nil {
updates["image_url"] = req.ImageURL
}
if req.LocalPath != nil {
updates["local_path"] = req.LocalPath
}
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")
}
type UpdateScenePromptRequest struct {
Prompt string `json:"prompt"`
}
func (s *StoryboardCompositionService) UpdateScenePrompt(sceneID string, req *UpdateScenePromptRequest) error {
var scene models.Scene
if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("scene not found")
}
return fmt.Errorf("failed to find scene: %w", err)
}
scene.Prompt = req.Prompt
if err := s.db.Save(&scene).Error; err != nil {
return fmt.Errorf("failed to update scene prompt: %w", err)
}
s.log.Infow("Scene prompt updated", "scene_id", sceneID, "prompt", req.Prompt)
return nil
}
type UpdateSceneInfoRequest struct {
Location *string `json:"location"`
Time *string `json:"time"`
Prompt *string `json:"prompt"`
Description *string `json:"description"`
ImageURL *string `json:"image_url"`
LocalPath *string `json:"local_path"`
}
func (s *StoryboardCompositionService) UpdateSceneInfo(sceneID string, req *UpdateSceneInfoRequest) error {
var scene models.Scene
if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("scene not found")
}
return fmt.Errorf("failed to find scene: %w", err)
}
updates := make(map[string]interface{})
if req.Location != nil {
updates["location"] = *req.Location
}
if req.Time != nil {
updates["time"] = *req.Time
}
if req.Prompt != nil {
updates["prompt"] = *req.Prompt
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.ImageURL != nil {
updates["image_url"] = *req.ImageURL
}
if req.LocalPath != nil {
updates["local_path"] = *req.LocalPath
}
if len(updates) > 0 {
if err := s.db.Model(&scene).Updates(updates).Error; err != nil {
return fmt.Errorf("failed to update scene: %w", err)
}
}
s.log.Infow("Scene info updated", "scene_id", sceneID, "updates", updates)
return nil
}
func (s *StoryboardCompositionService) DeleteScene(sceneID string) error {
var scene models.Scene
if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("scene not found")
}
return fmt.Errorf("failed to find scene: %w", err)
}
// 删除场景
if err := s.db.Delete(&scene).Error; err != nil {
return fmt.Errorf("failed to delete scene: %w", err)
}
s.log.Infow("Scene deleted successfully", "scene_id", sceneID)
return nil
}
func getStringValue(s *string) string {
if s != nil {
return *s
}
return ""
}
type CreateSceneRequest struct {
DramaID uint `json:"drama_id"`
EpisodeID *uint `json:"episode_id"` // 添加章节ID字段
Location string `json:"location"`
Time string `json:"time"`
Prompt string `json:"prompt"`
ImageURL string `json:"image_url"`
LocalPath string `json:"local_path"`
Description string `json:"description"`
}
func (s *StoryboardCompositionService) CreateScene(req *CreateSceneRequest) (*models.Scene, error) {
scene := &models.Scene{
DramaID: req.DramaID,
EpisodeID: req.EpisodeID, // 设置章节ID
Location: req.Location,
Time: req.Time,
Prompt: req.Prompt,
Status: "draft",
}
if req.ImageURL != "" {
scene.ImageURL = &req.ImageURL
scene.Status = "completed"
}
if req.LocalPath != "" {
scene.LocalPath = &req.LocalPath
}
if err := s.db.Create(scene).Error; err != nil {
return nil, fmt.Errorf("failed to create scene: %w", err)
}
s.log.Infow("Scene created successfully", "scene_id", scene.ID, "drama_id", scene.DramaID, "episode_id", req.EpisodeID)
return scene, nil
}
================================================
FILE: application/services/storyboard_service.go
================================================
package services
import (
"strconv"
"fmt"
"strings"
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/ai"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type StoryboardService struct {
db *gorm.DB
aiService *AIService
taskService *TaskService
log *logger.Logger
config *config.Config
promptI18n *PromptI18n
}
func NewStoryboardService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *StoryboardService {
return &StoryboardService{
db: db,
aiService: NewAIService(db, log),
taskService: NewTaskService(db, log),
log: log,
config: cfg,
promptI18n: NewPromptI18n(cfg),
}
}
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, model string) (string, 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 "", 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 "", fmt.Errorf("剧本内容为空,请先生成剧集内容")
}
// 获取该剧本的所有角色
var characters []models.Character
if err := s.db.Where("drama_id = ?", episode.DramaID).Order("name ASC").Find(&characters).Error; err != nil {
return "", 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, ", "))
}
// 使用国际化提示词
systemPrompt := s.promptI18n.GetStoryboardSystemPrompt()
scriptLabel := s.promptI18n.FormatUserPrompt("script_content_label")
taskLabel := s.promptI18n.FormatUserPrompt("task_label")
taskInstruction := s.promptI18n.FormatUserPrompt("task_instruction")
charListLabel := s.promptI18n.FormatUserPrompt("character_list_label")
charConstraint := s.promptI18n.FormatUserPrompt("character_constraint")
sceneListLabel := s.promptI18n.FormatUserPrompt("scene_list_label")
sceneConstraint := s.promptI18n.FormatUserPrompt("scene_constraint")
prompt := fmt.Sprintf(`%s
%s
%s
%s%s
%s
%s
%s
%s
%s
%s
【剧本原文】
%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提供足够的画面构建信息
- 避免抽象词汇,使用具象的视觉化描述`, systemPrompt, scriptLabel, scriptContent, taskLabel, taskInstruction, charListLabel, characterList, charConstraint, sceneListLabel, sceneList, sceneConstraint)
// 创建异步任务
task, err := s.taskService.CreateTask("storyboard_generation", episodeID)
if err != nil {
s.log.Errorw("Failed to create task", "error", err)
return "", fmt.Errorf("创建任务失败: %w", err)
}
s.log.Infow("Generating storyboard asynchronously",
"task_id", task.ID,
"episode_id", episodeID,
"drama_id", episode.DramaID,
"script_length", len(scriptContent),
"character_count", len(characters),
"characters", characterList,
"scene_count", len(scenes),
"scenes", sceneList)
// 启动后台goroutine处理AI调用和后续逻辑
go s.processStoryboardGeneration(task.ID, episodeID, model, prompt)
// 立即返回任务ID
return task.ID, nil
}
// processStoryboardGeneration 后台处理故事板生成
func (s *StoryboardService) processStoryboardGeneration(taskID, episodeID, model, prompt string) {
// 更新任务状态为处理中
if err := s.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始生成分镜头..."); err != nil {
s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID)
return
}
s.log.Infow("Processing storyboard generation", "task_id", taskID, "episode_id", episodeID)
// 调用AI服务生成(如果指定了模型则使用指定的模型)
// 设置较大的max_tokens以确保完整返回所有分镜的JSON
var text string
var err error
if model != "" {
s.log.Infow("Using specified model for storyboard generation", "model", model, "task_id", taskID)
client, getErr := s.aiService.GetAIClientForModel("text", model)
if getErr != nil {
s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr, "task_id", taskID)
text, err = s.aiService.GenerateText(prompt, "", ai.WithMaxTokens(16000))
} else {
text, err = client.GenerateText(prompt, "", ai.WithMaxTokens(16000))
}
} else {
text, err = s.aiService.GenerateText(prompt, "", ai.WithMaxTokens(16000))
}
if err != nil {
s.log.Errorw("Failed to generate storyboard", "error", err, "task_id", taskID)
if updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf("生成分镜头失败: %w", err)); updateErr != nil {
s.log.Errorw("Failed to update task error", "error", updateErr, "task_id", taskID)
}
return
}
// 更新任务进度
if err := s.taskService.UpdateTaskStatus(taskID, "processing", 50, "分镜头生成完成,正在解析结果..."); err != nil {
s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID)
return
}
// 解析JSON结果
// AI可能返回两种格式:
// 1. 数组格式: [{...}, {...}]
// 2. 对象格式: {"storyboards": [{...}, {...}]}
var result GenerateStoryboardResult
// 先尝试解析为数组格式
var storyboards []Storyboard
if err := utils.SafeParseAIJSON(text, &storyboards); err == nil {
// 成功解析为数组,包装为对象
result.Storyboards = storyboards
result.Total = len(storyboards)
s.log.Infow("Parsed storyboard as array format", "count", len(storyboards), "task_id", taskID)
} else {
// 尝试解析为对象格式
if err := utils.SafeParseAIJSON(text, &result); err != nil {
s.log.Errorw("Failed to parse storyboard JSON in both formats", "error", err, "response", text[:min(500, len(text))], "task_id", taskID)
if updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf("解析分镜头结果失败: %w", err)); updateErr != nil {
s.log.Errorw("Failed to update task error", "error", updateErr, "task_id", taskID)
}
return
}
result.Total = len(result.Storyboards)
s.log.Infow("Parsed storyboard as object format", "count", len(result.Storyboards), "task_id", taskID)
}
// 计算总时长(所有分镜时长之和)
totalDuration := 0
for _, sb := range result.Storyboards {
totalDuration += sb.Duration
}
s.log.Infow("Storyboard generated",
"task_id", taskID,
"episode_id", episodeID,
"count", result.Total,
"total_duration_seconds", totalDuration)
// 更新任务进度
if err := s.taskService.UpdateTaskStatus(taskID, "processing", 70, "正在保存分镜头..."); err != nil {
s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID)
return
}
// 保存分镜头到数据库
if err := s.saveStoryboards(episodeID, result.Storyboards); err != nil {
s.log.Errorw("Failed to save storyboards", "error", err, "task_id", taskID)
if updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf("保存分镜头失败: %w", err)); updateErr != nil {
s.log.Errorw("Failed to update task error", "error", updateErr, "task_id", taskID)
}
return
}
// 更新任务进度
if err := s.taskService.UpdateTaskStatus(taskID, "processing", 90, "正在更新剧集时长..."); err != nil {
s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID)
return
}
// 更新剧集时长(秒转分钟,向上取整)
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, "task_id", taskID)
// 不中断流程,只记录错误
} else {
s.log.Infow("Episode duration updated",
"task_id", taskID,
"episode_id", episodeID,
"duration_seconds", totalDuration,
"duration_minutes", durationMinutes)
}
// 更新任务结果
resultData := gin.H{
"storyboards": result.Storyboards,
"total": result.Total,
"total_duration": totalDuration,
"duration_minutes": durationMinutes,
}
if err := s.taskService.UpdateTaskResult(taskID, resultData); err != nil {
s.log.Errorw("Failed to update task result", "error", err, "task_id", taskID)
return
}
s.log.Infow("Storyboard generation completed", "task_id", taskID, "episode_id", episodeID)
}
// 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
videoRatio := "16:9"
// 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, fmt.Sprintf("=VideoRatio: %s", videoRatio))
if len(parts) > 0 {
return strings.Join(parts, ". ")
}
return "Anime style video scene"
}
func (s *StoryboardService) saveStoryboards(episodeID string, storyboards []Storyboard) error {
// 验证 episodeID
epID, err := strconv.ParseUint(episodeID, 10, 32)
if err != nil {
s.log.Errorw("Invalid episode ID", "episode_id", episodeID, "error", err)
return fmt.Errorf("无效的章节ID: %s", episodeID)
}
// 防御性检查:如果AI返回的分镜数量为0,不应该删除旧分镜
if len(storyboards) == 0 {
s.log.Errorw("AI返回的分镜数量为0,拒绝保存以避免删除现有分镜", "episode_id", episodeID)
return fmt.Errorf("AI生成分镜失败:返回的分镜数量为0")
}
s.log.Infow("开始保存分镜头",
"episode_id", episodeID,
"episode_id_uint", uint(epID),
"storyboard_count", len(storyboards))
// 开启事务
return s.db.Transaction(func(tx *gorm.DB) error {
// 验证该章节是否存在
var episode models.Episode
if err := tx.First(&episode, epID).Error; err != nil {
s.log.Errorw("Episode not found", "episode_id", episodeID, "error", err)
return fmt.Errorf("章节不存在: %s", episodeID)
}
s.log.Infow("找到章节信息",
"episode_id", episode.ID,
"episode_number", episode.EpisodeNum,
"drama_id", episode.DramaID,
"title", episode.Title)
// 获取该剧集所有的分镜ID(使用 uint 类型)
var storyboardIDs []uint
if err := tx.Model(&models.Storyboard{}).
Where("episode_id = ?", uint(epID)).
Pluck("id", &storyboardIDs).Error; err != nil {
return err
}
s.log.Infow("查询到现有分镜",
"episode_id_string", episodeID,
"episode_id_uint", uint(epID),
"existing_storyboard_count", len(storyboardIDs),
"storyboard_ids", storyboardIDs)
// 如果有分镜,先清理关联的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
}
s.log.Infow("已清理关联的图片生成记录", "count", len(storyboardIDs))
}
// 删除该剧集已有的分镜头(使用 uint 类型确保类型匹配)
s.log.Warnw("准备删除分镜数据",
"episode_id_string", episodeID,
"episode_id_uint", uint(epID),
"episode_id_from_db", episode.ID,
"will_delete_count", len(storyboardIDs))
result := tx.Where("episode_id = ?", uint(epID)).Delete(&models.Storyboard{})
if result.Error != nil {
s.log.Errorw("删除旧分镜失败", "episode_id", uint(epID), "error", result.Error)
return result.Error
}
s.log.Infow("已删除旧分镜头",
"episode_id", uint(epID),
"deleted_count", result.RowsAffected)
// 注意:不删除背景,因为背景是在分镜拆解前就提取好的
// 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)
}
// 处理 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
})
}
// CreateStoryboardRequest 创建分镜请求
type CreateStoryboardRequest struct {
EpisodeID uint `json:"episode_id"`
SceneID *uint `json:"scene_id"`
StoryboardNumber int `json:"storyboard_number"`
Title *string `json:"title"`
Location *string `json:"location"`
Time *string `json:"time"`
ShotType *string `json:"shot_type"`
Angle *string `json:"angle"`
Movement *string `json:"movement"`
Description *string `json:"description"`
Action *string `json:"action"`
Result *string `json:"result"`
Atmosphere *string `json:"atmosphere"`
Dialogue *string `json:"dialogue"`
BgmPrompt *string `json:"bgm_prompt"`
SoundEffect *string `json:"sound_effect"`
Duration int `json:"duration"`
Characters []uint `json:"characters"`
}
// CreateStoryboard 创建单个分镜
func (s *StoryboardService) CreateStoryboard(req *CreateStoryboardRequest) (*models.Storyboard, error) {
// 构建Storyboard对象
sb := Storyboard{
ShotNumber: req.StoryboardNumber,
ShotType: getString(req.ShotType),
Angle: getString(req.Angle),
Time: getString(req.Time),
Location: getString(req.Location),
SceneID: req.SceneID,
Movement: getString(req.Movement),
Action: getString(req.Action),
Dialogue: getString(req.Dialogue),
Result: getString(req.Result),
Atmosphere: getString(req.Atmosphere),
Emotion: "", // 可以后续添加
Duration: req.Duration,
BgmPrompt: getString(req.BgmPrompt),
SoundEffect: getString(req.SoundEffect),
Characters: req.Characters,
}
if req.Title != nil {
sb.Title = *req.Title
}
// 生成提示词
imagePrompt := s.generateImagePrompt(sb)
videoPrompt := s.generateVideoPrompt(sb)
// 构建 description
desc := ""
if req.Description != nil {
desc = *req.Description
}
modelSB := &models.Storyboard{
EpisodeID: req.EpisodeID,
SceneID: req.SceneID,
StoryboardNumber: req.StoryboardNumber,
Title: req.Title,
Location: req.Location,
Time: req.Time,
ShotType: req.ShotType,
Angle: req.Angle,
Movement: req.Movement,
Description: &desc,
Action: req.Action,
Result: req.Result,
Atmosphere: req.Atmosphere,
Dialogue: req.Dialogue,
ImagePrompt: &imagePrompt,
VideoPrompt: &videoPrompt,
BgmPrompt: req.BgmPrompt,
SoundEffect: req.SoundEffect,
Duration: req.Duration,
}
if err := s.db.Create(modelSB).Error; err != nil {
return nil, fmt.Errorf("failed to create storyboard: %w", err)
}
// 关联角色
if len(req.Characters) > 0 {
var characters []models.Character
if err := s.db.Where("id IN ?", req.Characters).Find(&characters).Error; err != nil {
s.log.Warnw("Failed to find characters for new storyboard", "error", err)
} else if len(characters) > 0 {
s.db.Model(modelSB).Association("Characters").Append(characters)
}
}
s.log.Infow("Storyboard created", "id", modelSB.ID, "episode_id", req.EpisodeID)
return modelSB, nil
}
// DeleteStoryboard 删除分镜
func (s *StoryboardService) DeleteStoryboard(storyboardID uint) error {
result := s.db.Where("id = ? ", storyboardID).Delete(&models.Storyboard{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("storyboard not found")
}
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func getString(s *string) string {
if s == nil {
return ""
}
return *s
}
================================================
FILE: application/services/storyboard_update_full.go
================================================
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 val, ok := updates["scene_id"].(float64); ok {
sceneID := uint(val)
updateData["scene_id"] = sceneID
}
// 使用当前数据库值填充缺失字段(用于生成提示词)
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
}
================================================
FILE: application/services/task_service.go
================================================
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
}
================================================
FILE: application/services/upload_service.go
================================================
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
}
// UploadResult 上传结果
type UploadResult struct {
URL string // 完整访问URL
LocalPath string // 相对路径(相对于 storage 根目录)
}
// UploadFile 上传文件到本地存储
func (s *UploadService) UploadFile(file io.Reader, fileName, contentType string, category string) (*UploadResult, error) {
// 创建分类目录
categoryPath := filepath.Join(s.storagePath, category)
if err := os.MkdirAll(categoryPath, 0755); err != nil {
return nil, 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 nil, 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 nil, fmt.Errorf("写入文件失败: %w", err)
}
// 构建访问URL和相对路径
fileURL := fmt.Sprintf("%s/%s/%s", s.baseURL, category, newFileName)
localPath := fmt.Sprintf("%s/%s", category, newFileName)
s.log.Infow("File uploaded successfully", "path", filePath, "url", fileURL, "local_path", localPath)
return &UploadResult{
URL: fileURL,
LocalPath: localPath,
}, nil
}
// UploadCharacterImage 上传角色图片
func (s *UploadService) UploadCharacterImage(file io.Reader, fileName, contentType string) (*UploadResult, 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
}
================================================
FILE: application/services/video_generation_service.go
================================================
package services
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
models "github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/infrastructure/external/ffmpeg"
"github.com/drama-generator/backend/infrastructure/storage"
"github.com/drama-generator/backend/pkg/logger"
"github.com/drama-generator/backend/pkg/utils"
"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
ffmpeg *ffmpeg.FFmpeg
promptI18n *PromptI18n
}
func NewVideoGenerationService(db *gorm.DB, transferService *ResourceTransferService, localStorage *storage.LocalStorage, aiService *AIService, log *logger.Logger, promptI18n *PromptI18n) *VideoGenerationService {
service := &VideoGenerationService{
db: db,
localStorage: localStorage,
transferService: transferService,
aiService: aiService,
log: log,
ffmpeg: ffmpeg.NewFFmpeg(log),
promptI18n: promptI18n,
}
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"`
ImageLocalPath *string `json:"image_local_path"` // 单图模式的本地路径
// 首尾帧模式
FirstFrameURL *string `json:"first_frame_url"`
FirstFrameLocalPath *string `json:"first_frame_local_path"` // 首帧本地路径
LastFrameURL *string `json:"last_frame_url"`
LastFrameLocalPath *string `json:"last_frame_local_path"` // 尾帧本地路径
// 多图模式
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":
// 单图模式 - 优先使用 local_path
if request.ImageLocalPath != nil && *request.ImageLocalPath != "" {
videoGen.ImageURL = request.ImageLocalPath
} else if request.ImageURL != "" {
videoGen.ImageURL = &request.ImageURL
}
case "first_last":
// 首尾帧模式 - 优先使用 local_path
if request.FirstFrameLocalPath != nil && *request.FirstFrameLocalPath != "" {
videoGen.FirstFrameURL = request.FirstFrameLocalPath
} else if request.FirstFrameURL != nil {
videoGen.FirstFrameURL = request.FirstFrameURL
}
if request.LastFrameLocalPath != nil && *request.LastFrameLocalPath != "" {
videoGen.LastFrameURL = request.LastFrameLocalPath
} else 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)
}
// Start background goroutine to process video generation asynchronously
// This allows the API to return immediately while video generation happens in background
// CRITICAL: The goroutine will handle all video generation logic including API calls and polling
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
}
// 获取drama的style信息
var drama models.Drama
if err := s.db.First(&drama, videoGen.DramaID).Error; err != nil {
s.log.Warnw("Failed to load drama for style", "error", err, "drama_id", videoGen.DramaID)
}
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))
}
// 根据参考图模式添加相应的选项,并将本地图片转换为base64
if videoGen.ReferenceMode != nil {
switch *videoGen.ReferenceMode {
case "first_last":
// 首尾帧模式 - 转换本地图片为base64
if videoGen.FirstFrameURL != nil {
firstFrameBase64, err := s.convertImageToBase64(*videoGen.FirstFrameURL)
if err != nil {
s.log.Warnw("Failed to convert first frame to base64, using original URL", "error", err)
opts = append(opts, video.WithFirstFrame(*videoGen.FirstFrameURL))
} else {
opts = append(opts, video.WithFirstFrame(firstFrameBase64))
}
}
if videoGen.LastFrameURL != nil {
lastFrameBase64, err := s.convertImageToBase64(*videoGen.LastFrameURL)
if err != nil {
s.log.Warnw("Failed to convert last frame to base64, using original URL", "error", err)
opts = append(opts, video.WithLastFrame(*videoGen.LastFrameURL))
} else {
opts = append(opts, video.WithLastFrame(lastFrameBase64))
}
}
case "multiple":
// 多图模式 - 转换本地图片为base64
if videoGen.ReferenceImageURLs != nil {
var imageURLs []string
if err := json.Unmarshal([]byte(*videoGen.ReferenceImageURLs), &imageURLs); err == nil {
var base64Images []string
for _, imgURL := range imageURLs {
base64Img, err := s.convertImageToBase64(imgURL)
if err != nil {
s.log.Warnw("Failed to convert reference image to base64, using original URL", "error", err, "url", imgURL)
base64Images = append(base64Images, imgURL)
} else {
base64Images = append(base64Images, base64Img)
}
}
opts = append(opts, video.WithReferenceImages(base64Images))
}
}
}
}
// 构造imageURL参数(单图模式使用,其他模式传空字符串)
// 如果是本地图片,转换为base64
imageURL := ""
if videoGen.ImageURL != nil {
base64Image, err := s.convertImageToBase64(*videoGen.ImageURL)
if err != nil {
s.log.Warnw("Failed to convert image to base64, using original URL", "error", err)
imageURL = *videoGen.ImageURL
} else {
imageURL = base64Image
}
}
// 构建完整的提示词:风格提示词 + 约束提示词 + 用户提示词
prompt := videoGen.Prompt
// 2. 添加视频约束提示词
// 根据参考图模式选择对应的约束提示词
referenceMode := "single" // 默认单图模式
if videoGen.ReferenceMode != nil {
referenceMode = *videoGen.ReferenceMode
}
// 如果是单图模式,需要检查图片是否为动作序列图
if referenceMode == "single" && videoGen.ImageGenID != nil {
var imageGen models.ImageGeneration
if err := s.db.First(&imageGen, *videoGen.ImageGenID).Error; err == nil {
// 如果图片的frame_type是action,使用动作序列约束提示词
if imageGen.FrameType != nil && *imageGen.FrameType == "action" {
referenceMode = "action_sequence"
s.log.Infow("Detected action sequence image in single mode",
"id", videoGenID,
"image_gen_id", *videoGen.ImageGenID,
"frame_type", *imageGen.FrameType)
}
}
}
constraintPrompt := s.promptI18n.GetVideoConstraintPrompt(referenceMode)
if constraintPrompt != "" {
prompt = constraintPrompt + "\n\n" + prompt
s.log.Infow("Added constraint prompt to video generation",
"id", videoGenID,
"reference_mode", referenceMode,
"constraint_prompt_length", len(constraintPrompt))
}
// 打印完整的提示词信息
s.log.Infow("Video generation prompts",
"id", videoGenID,
"user_prompt", videoGen.Prompt,
"constraint_prompt", constraintPrompt,
"final_prompt", prompt)
result, err := client.GenerateVideo(imageURL, prompt, opts...)
if err != nil {
s.log.Errorw("Video generation API call failed", "error", err, "id", videoGenID)
s.updateVideoGenError(videoGenID, err.Error())
return
}
// CRITICAL FIX: Validate TaskID before starting polling goroutine
// Empty TaskID would cause polling to fail silently or cause issues
if result.TaskID != "" {
s.db.Model(&videoGen).Updates(map[string]interface{}{
"task_id": result.TaskID,
"status": models.VideoStatusProcessing,
})
// Start background goroutine to poll task status
// This allows the API to return immediately while video generation continues asynchronously
// The goroutine will poll until completion, failure, or timeout (max 300 attempts * 10s = 50 minutes)
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) {
// CRITICAL FIX: Validate taskID parameter to prevent invalid API calls
// Empty taskID would cause unnecessary API calls and potential errors
if taskID == "" {
s.log.Errorw("Invalid empty taskID for polling", "video_gen_id", videoGenID)
s.updateVideoGenError(videoGenID, "invalid task ID for polling")
return
}
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
}
// Polling configuration: max 300 attempts with 10 second intervals
// Total maximum polling time: 300 * 10s = 50 minutes
// This prevents infinite polling if the task never completes
maxAttempts := 300
interval := 10 * time.Second
for attempt := 0; attempt < maxAttempts; attempt++ {
// Sleep before each poll attempt to avoid overwhelming the API
// First iteration sleeps before the first check (after 0 attempts)
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
}
// CRITICAL FIX: Check if status was manually changed (e.g., cancelled by user)
// If status is no longer "processing", stop polling to avoid unnecessary API calls
// This prevents polling when the task has been cancelled or failed externally
if videoGen.Status != models.VideoStatusProcessing {
s.log.Infow("Video generation status changed, stopping poll", "id", videoGenID, "status", videoGen.Status)
return
}
// Poll the video generation API for task status
// Continue polling on transient errors (network issues, temporary API failures)
// Only stop on permanent errors or task completion
result, err := client.GetTaskStatus(taskID)
if err != nil {
s.log.Errorw("Failed to get task status", "error", err, "task_id", taskID, "attempt", attempt+1)
// Continue polling on error - might be transient network issue
// Will eventually timeout after maxAttempts if error persists
continue
}
// Check if task completed successfully
// CRITICAL FIX: Validate that video URL exists when task is marked as completed
// Some APIs may mark task as completed but fail to provide the video URL
if result.Completed {
if result.VideoURL != "" {
// Successfully completed with video URL - download and update database
s.completeVideoGeneration(videoGenID, result.VideoURL, &result.Duration, &result.Width, &result.Height, nil)
return
}
// Task marked as completed but no video URL - this is an error condition
s.updateVideoGenError(videoGenID, "task completed but no video URL")
return
}
// Check if task failed with an error message
if result.Error != "" {
s.updateVideoGenError(videoGenID, result.Error)
return
}
// Task still in progress - log and continue polling
s.log.Infow("Video generation in progress", "id", videoGenID, "attempt", attempt+1, "max_attempts", maxAttempts)
}
// CRITICAL FIX: Handle polling timeout gracefully
// After maxAttempts (50 minutes), mark task as failed if still not completed
// This prevents indefinite polling and resource waste
s.updateVideoGenError(videoGenID, fmt.Sprintf("polling timeout after %d attempts (%.1f minutes)", maxAttempts, float64(maxAttempts*int(interval))/60.0))
}
func (s *VideoGenerationService) completeVideoGeneration(videoGenID uint, videoURL string, duration *int, width *int, height *int, firstFrameURL *string) {
var localVideoPath *string
// 下载视频到本地存储并保存相对路径到数据库
if s.localStorage != nil && videoURL != "" {
downloadResult, err := s.localStorage.DownloadFromURLWithPath(videoURL, "videos")
if err != nil {
s.log.Warnw("Failed to download video to local storage",
"error", err,
"id", videoGenID,
"original_url", videoURL)
} else {
localVideoPath = &downloadResult.RelativePath
s.log.Infow("Video downloaded to local storage",
"id", videoGenID,
"original_url", videoURL,
"local_path", downloadResult.RelativePath)
}
}
// 如果视频已下载到本地,探测真实时长
// 特别是当 AI 服务返回的 duration 为 0 或 nil 时,必须探测
shouldProbe := localVideoPath != nil && s.ffmpeg != nil && (duration == nil || *duration == 0)
if shouldProbe {
absPath := s.localStorage.GetAbsolutePath(*localVideoPath)
if probedDuration, err := s.ffmpeg.GetVideoDuration(absPath); err == nil {
// 转换为整数秒(向上取整)
durationInt := int(probedDuration + 0.5)
duration = &durationInt
s.log.Infow("Probed video duration (was 0 or nil)",
"id", videoGenID,
"duration_seconds", durationInt,
"duration_float", probedDuration)
} else {
s.log.Errorw("Failed to probe video duration, duration will be 0",
"error", err,
"id", videoGenID,
"local_path", *localVideoPath)
}
} else if localVideoPath != nil && s.ffmpeg != nil && duration != nil && *duration > 0 {
// 即使有 duration,也验证一下(可选)
absPath := s.localStorage.GetAbsolutePath(*localVideoPath)
if probedDuration, err := s.ffmpeg.GetVideoDuration(absPath); err == nil {
durationInt := int(probedDuration + 0.5)
if durationInt != *duration {
s.log.Warnw("Probed duration differs from provided duration",
"id", videoGenID,
"provided", *duration,
"probed", durationInt)
// 使用探测到的时长(更准确)
duration = &durationInt
}
}
}
// 下载首帧图片到本地存储(仅用于缓存,不更新数据库)
if firstFrameURL != nil && *firstFrameURL != "" && s.localStorage != nil {
_, err := s.localStorage.DownloadFromURL(*firstFrameURL, "video_frames")
if err != nil {
s.log.Warnw("Failed to download first frame to local storage",
"error", err,
"id", videoGenID,
"original_url", *firstFrameURL)
} else {
s.log.Infow("First frame downloaded to local storage for caching",
"id", videoGenID,
"original_url", *firstFrameURL)
}
}
// 数据库中保存原始URL和本地路径
updates := map[string]interface{}{
"status": models.VideoStatusCompleted,
"video_url": videoURL,
"local_path": localVideoPath,
}
// 只有当 duration 大于 0 时才保存,避免保存无效的 0 值
if duration != nil && *duration > 0 {
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 {
// 更新 Storyboard 的 video_url 和 duration
storyboardUpdates := map[string]interface{}{
"video_url": videoURL,
}
// 只有当 duration 大于 0 时才更新,避免用无效的 0 值覆盖
if duration != nil && *duration > 0 {
storyboardUpdates["duration"] = *duration
}
if err := s.db.Model(&models.Storyboard{}).Where("id = ?", *videoGen.StoryboardID).Updates(storyboardUpdates).Error; err != nil {
s.log.Warnw("Failed to update storyboard", "storyboard_id", *videoGen.StoryboardID, "error", err)
} else {
s.log.Infow("Updated storyboard with video info", "storyboard_id", *videoGen.StoryboardID, "duration", duration)
}
}
}
s.log.Infow("Video generation completed", "id", videoGenID, "url", videoURL, "duration", duration)
}
func (s *VideoGenerationService) updateVideoGenError(videoGenID uint, errorMsg string) {
if err := s.db.Model(&models.VideoGeneration{}).Where("id = ?", videoGenID).Updates(map[string]interface{}{
"status": models.VideoStatusFailed,
"error_msg": errorMsg,
}).Error; err != nil {
s.log.Errorw("Failed to update video generation error", "error", err, "id", videoGenID)
}
}
func (s *VideoGenerationService) getVideoClient(provider string, modelName string) (video.VideoClient, error) {
// 根据模型名称获取AI配置
var config *models.AIServiceConfig
var err error
if modelName != "" {
config, err = s.aiService.GetConfigForModel("video", modelName)
if err != nil {
s.log.Warnw("Failed to get config for model, using default", "model", modelName, "error", err)
config, err = s.aiService.GetDefaultConfig("video")
if err != nil {
return nil, fmt.Errorf("no video AI config found: %w", err)
}
}
} else {
config, err = s.aiService.GetDefaultConfig("video")
if err != nil {
return nil, fmt.Errorf("no video AI config found: %w", err)
}
}
// 使用配置中的信息创建客户端
baseURL := config.BaseURL
apiKey := config.APIKey
model := modelName
if model == "" && len(config.Model) > 0 {
model = config.Model[0]
}
// 根据配置中的 provider 创建对应的客户端
var endpoint string
var queryEndpoint string
switch config.Provider {
case "chatfire":
endpoint = "/video/generations"
queryEndpoint = "/video/task/{taskId}"
return video.NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil
case "doubao", "volcengine", "volces":
endpoint = "/contents/generations/tasks"
queryEndpoint = "/contents/generations/tasks/{taskId}"
return video.NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil
case "openai":
// OpenAI Sora 使用 /v1/videos 端点
return video.NewOpenAISoraClient(baseURL, apiKey, model), nil
case "runway":
return video.NewRunwayClient(baseURL, apiKey, model), nil
case "pika":
return video.NewPikaClient(baseURL, apiKey, model), nil
case "minimax":
return video.NewMinimaxClient(baseURL, apiKey, model), nil
default:
return nil, fmt.Errorf("unsupported video provider: %s", provider)
}
}
func (s *VideoGenerationService) RecoverPendingTasks() {
var pendingVideos []models.VideoGeneration
// Query for pending tasks with non-empty task_id
// Note: Using IS NOT NULL and != '' to ensure we only get valid task IDs
if err := s.db.Where("status = ? AND task_id IS NOT NULL 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 {
// CRITICAL FIX: Check for nil TaskID before dereferencing to prevent panic
// Even though we filter for non-empty task_id, GORM might still return nil pointers
// This nil check prevents a potential runtime panic
if videoGen.TaskID == nil || *videoGen.TaskID == "" {
s.log.Warnw("Skipping video generation with nil or empty TaskID", "id", videoGen.ID)
continue
}
// Start goroutine to poll task status for each pending video
// Each goroutine will poll independently until completion or timeout
go s.pollTaskStatus(videoGen.ID, *videoGen.TaskID, videoGen.Provider, videoGen.Model)
}
}
func (s *VideoGenerationService) GetVideoGeneration(id uint) (*models.VideoGeneration, error) {
var videoGen models.VideoGeneration
if err := s.db.First(&videoGen, id).Error; err != nil {
return nil, err
}
return &videoGen, nil
}
func (s *VideoGenerationService) ListVideoGenerations(dramaID *uint, storyboardID *uint, status string, limit int, offset int) ([]*models.VideoGeneration, int64, error) {
var videos []*models.VideoGeneration
var total int64
query := s.db.Model(&models.VideoGeneration{})
if dramaID != nil {
query = query.Where("drama_id = ?", *dramaID)
}
if storyboardID != nil {
query = query.Where("storyboard_id = ?", *storyboardID)
}
if status != "" {
query = query.Where("status = ?", status)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&videos).Error; err != nil {
return nil, 0, err
}
return videos, total, nil
}
func (s *VideoGenerationService) GenerateVideoFromImage(imageGenID uint) (*models.VideoGeneration, error) {
var imageGen models.ImageGeneration
if err := s.db.First(&imageGen, imageGenID).Error; err != nil {
return nil, fmt.Errorf("image generation not found")
}
if imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil {
return nil, fmt.Errorf("image is not ready")
}
// 获取关联的Storyboard以获取时长
var duration *int
if imageGen.StoryboardID != nil {
var storyboard models.Storyboard
if err := s.db.Where("id = ?", *imageGen.StoryboardID).First(&storyboard).Error; err == nil {
duration = &storyboard.Duration
s.log.Infow("Using storyboard duration for video generation",
"storyboard_id", *imageGen.StoryboardID,
"duration", storyboard.Duration)
}
}
req := &GenerateVideoRequest{
DramaID: fmt.Sprintf("%d", imageGen.DramaID),
StoryboardID: imageGen.StoryboardID,
ImageGenID: &imageGenID,
ImageURL: *imageGen.ImageURL,
Prompt: imageGen.Prompt,
Provider: "doubao",
Duration: duration,
}
return s.GenerateVideo(req)
}
func (s *VideoGenerationService) BatchGenerateVideosForEpisode(episodeID string) ([]*models.VideoGeneration, error) {
var episode models.Episode
if err := s.db.Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil {
return nil, fmt.Errorf("episode not found")
}
var results []*models.VideoGeneration
for _, storyboard := range episode.Storyboards {
if storyboard.ImagePrompt == nil {
continue
}
var imageGen models.ImageGeneration
if err := s.db.Where("storyboard_id = ? AND status = ?", storyboard.ID, models.ImageStatusCompleted).
Order("created_at DESC").First(&imageGen).Error; err != nil {
s.log.Warnw("No completed image for storyboard", "storyboard_id", storyboard.ID)
continue
}
videoGen, err := s.GenerateVideoFromImage(imageGen.ID)
if err != nil {
s.log.Errorw("Failed to generate video", "storyboard_id", storyboard.ID, "error", err)
continue
}
results = append(results, videoGen)
}
return results, nil
}
func (s *VideoGenerationService) DeleteVideoGeneration(id uint) error {
return s.db.Delete(&models.VideoGeneration{}, id).Error
}
// convertImageToBase64 将图片转换为base64格式
// 优先使用本地存储的图片,如果没有则使用URL
func (s *VideoGenerationService) convertImageToBase64(imageURL string) (string, error) {
// 如果已经是base64格式,直接返回
if strings.HasPrefix(imageURL, "data:") {
return imageURL, nil
}
// 尝试从本地存储读取
if s.localStorage != nil {
var relativePath string
// 1. 检查是否是本地URL(包含 /static/)
if strings.Contains(imageURL, "/static/") {
// 提取相对路径,例如从 "http://localhost:5678/static/images/xxx.jpg" 提取 "images/xxx.jpg"
parts := strings.Split(imageURL, "/static/")
if len(parts) == 2 {
relativePath = parts[1]
}
} else if !strings.HasPrefix(imageURL, "http://") && !strings.HasPrefix(imageURL, "https://") {
// 2. 如果不是 HTTP/HTTPS URL,视为相对路径(如 "images/xxx.jpg")
relativePath = imageURL
}
// 如果识别出相对路径,尝试读取本地文件
if relativePath != "" {
absPath := s.localStorage.GetAbsolutePath(relativePath)
// 使用工具函数转换为base64
base64Str, err := utils.ImageToBase64(absPath)
if err == nil {
s.log.Infow("Converted local image to base64", "path", relativePath)
return base64Str, nil
}
s.log.Warnw("Failed to convert local image to base64, will try URL", "error", err, "path", absPath)
}
}
// 如果本地读取失败或不是本地路径,尝试从URL下载并转换
base64Str, err := utils.ImageToBase64(imageURL)
if err != nil {
return "", fmt.Errorf("failed to convert image to base64: %w", err)
}
urlLen := len(imageURL)
if urlLen > 50 {
urlLen = 50
}
s.log.Infow("Converted remote image to base64", "url", imageURL[:urlLen])
return base64Str, nil
}
================================================
FILE: application/services/video_merge_service.go
================================================
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 {
// 使用 scene.VideoURL,它已经在前面的代码中被正确处理
// 如果是本地文件,已经包含了完整路径(storagePath + LocalPath)
// 如果是 HTTP URL,则直接使用
videoPath := scene.VideoURL
clips[i] = ffmpeg.VideoClip{
URL: videoPath,
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,
"video_path", videoPath,
"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)
// 生成相对路径(不包含协议、IP、端口)
relPath := filepath.Join("videos", "merged", fileName)
result := &video.VideoResult{
VideoURL: relPath, // 只保存相对路径
Duration: int(totalDuration),
Completed: true,
Status: "completed",
}
return result, nil
}
func (s *VideoMergeService) pollMergeStatus(mergeID uint, client video.VideoClient, taskID string) {
maxAttempts := 240
pollInterval := 5 * time.Second
for i := 0; i < maxAttempts; i++ {
time.Sleep(pollInterval)
result, err := client.GetTaskStatus(taskID)
if err != nil {
s.log.Errorw("Failed to get merge task status", "error", err, "task_id", taskID)
continue
}
if result.Completed {
s.completeMerge(mergeID, result)
return
}
if result.Error != "" {
s.updateMergeError(mergeID, result.Error)
return
}
}
s.updateMergeError(mergeID, "timeout: video merge took too long")
}
func (s *VideoMergeService) completeMerge(mergeID uint, result *video.VideoResult) {
now := time.Now()
// 获取merge记录
var videoMerge models.VideoMerge
if err := s.db.First(&videoMerge, mergeID).Error; err != nil {
s.log.Errorw("Failed to load video merge for completion", "error", err, "id", mergeID)
return
}
finalVideoURL := result.VideoURL
// 使用本地存储,不再使用MinIO
s.log.Infow("Video merge completed, using local storage", "merge_id", mergeID, "local_path", result.VideoURL)
updates := map[string]interface{}{
"status": models.VideoMergeStatusCompleted,
"merged_url": finalVideoURL,
"completed_at": now,
}
if result.Duration > 0 {
updates["duration"] = result.Duration
}
s.db.Model(&models.VideoMerge{}).Where("id = ?", mergeID).Updates(updates)
// 更新episode的状态和最终视频URL
if videoMerge.EpisodeID != 0 {
s.db.Model(&models.Episode{}).Where("id = ?", videoMerge.EpisodeID).Updates(map[string]interface{}{
"status": "completed",
"video_url": finalVideoURL,
})
s.log.Infow("Episode finalized", "episode_id", videoMerge.EpisodeID, "video_url", finalVideoURL)
}
s.log.Infow("Video merge completed", "id", mergeID, "url", finalVideoURL)
}
func (s *VideoMergeService) updateMergeError(mergeID uint, errorMsg string) {
s.db.Model(&models.VideoMerge{}).Where("id = ?", mergeID).Updates(map[string]interface{}{
"status": models.VideoMergeStatusFailed,
"error_msg": errorMsg,
})
s.log.Errorw("Video merge failed", "id", mergeID, "error", errorMsg)
}
func (s *VideoMergeService) getVideoClient(provider string) (video.VideoClient, error) {
config, err := s.aiService.GetDefaultConfig("video")
if err != nil {
return nil, fmt.Errorf("failed to get video config: %w", err)
}
// 使用第一个模型
model := ""
if len(config.Model) > 0 {
model = config.Model[0]
}
// 根据配置中的 provider 创建对应的客户端
var endpoint string
var queryEndpoint string
switch config.Provider {
case "runway":
return video.NewRunwayClient(config.BaseURL, config.APIKey, model), nil
case "pika":
return video.NewPikaClient(config.BaseURL, config.APIKey, model), nil
case "openai", "sora":
return video.NewOpenAISoraClient(config.BaseURL, config.APIKey, model), nil
case "minimax":
return video.NewMinimaxClient(config.BaseURL, config.APIKey, model), nil
case "chatfire":
endpoint = "/video/generations"
queryEndpoint = "/video/task/{taskId}"
return video.NewChatfireClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
case "doubao", "volces", "ark":
endpoint = "/contents/generations/tasks"
queryEndpoint = "/generations/tasks/{taskId}"
return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
default:
endpoint = "/contents/generations/tasks"
queryEndpoint = "/generations/tasks/{taskId}"
return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil
}
}
func (s *VideoMergeService) GetMerge(mergeID uint) (*models.VideoMerge, error) {
var merge models.VideoMerge
if err := s.db.Where("id = ? ", mergeID).First(&merge).Error; err != nil {
return nil, err
}
return &merge, nil
}
func (s *VideoMergeService) ListMerges(episodeID *string, status string, page, pageSize int) ([]models.VideoMerge, int64, error) {
query := s.db.Model(&models.VideoMerge{})
if episodeID != nil && *episodeID != "" {
query = query.Where("episode_id = ?", *episodeID)
}
if status != "" {
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var merges []models.VideoMerge
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&merges).Error; err != nil {
return nil, 0, err
}
return merges, total, nil
}
func (s *VideoMergeService) DeleteMerge(mergeID uint) error {
result := s.db.Where("id = ? ", mergeID).Delete(&models.VideoMerge{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("merge not found")
}
return nil
}
// TimelineClip 时间线片段数据
type TimelineClip struct {
AssetID interface{} `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"`
}
// getAssetIDString 将 AssetID 转换为字符串
func getAssetIDString(assetID interface{}) string {
if assetID == nil {
return ""
}
switch v := assetID.(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprintf("%v", v)
}
}
// 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 {
s.log.Infow("Processing timeline data", "clips_count", len(timelineData.Clips))
// 使用前端提供的时间线数据
for i, clip := range timelineData.Clips {
assetIDStr := getAssetIDString(clip.AssetID)
s.log.Infow("Processing clip", "index", i, "storyboard_id", clip.StoryboardID, "asset_id", assetIDStr, "order", clip.Order)
// 优先使用素材库中的视频(通过AssetID)
var videoURL string
var sceneID uint
if assetIDStr != "" {
// 从素材库获取视频,优先使用 local_path
var asset models.Asset
if err := s.db.Where("id = ? AND type = ?", assetIDStr, models.AssetTypeVideo).First(&asset).Error; err == nil {
// 优先使用 local_path
if asset.LocalPath != nil && *asset.LocalPath != "" {
// 检查是否已经是完整路径
if filepath.IsAbs(*asset.LocalPath) || filepath.HasPrefix(*asset.LocalPath, s.storagePath) {
videoURL = *asset.LocalPath
} else {
videoURL = filepath.Join(s.storagePath, *asset.LocalPath)
}
s.log.Infow("Using local video from asset library", "asset_id", assetIDStr, "local_path", videoURL)
} else {
// 回退到远程 URL
videoURL = asset.URL
s.log.Infow("Using remote video from asset library", "asset_id", assetIDStr, "video_url", videoURL)
}
// 如果asset关联了storyboard,使用关联的storyboard_id
if asset.StoryboardID != nil {
sceneID = *asset.StoryboardID
}
} else {
s.log.Warnw("Asset not found, will try storyboard video", "asset_id", assetIDStr, "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
}
// 查找关联的 video_generation 记录以获取 local_path
var videoGen models.VideoGeneration
if err := s.db.Where("storyboard_id = ? AND status = ?", scene.ID, "completed").Order("created_at DESC").First(&videoGen).Error; err == nil {
if videoGen.LocalPath != nil && *videoGen.LocalPath != "" {
// 检查是否已经是完整路径
if filepath.IsAbs(*videoGen.LocalPath) || filepath.HasPrefix(*videoGen.LocalPath, s.storagePath) {
videoURL = *videoGen.LocalPath
} else {
videoURL = filepath.Join(s.storagePath, *videoGen.LocalPath)
}
sceneID = scene.ID
s.log.Infow("Using local video from video_generation", "storyboard_id", clip.StoryboardID, "local_path", videoURL)
} else if scene.VideoURL != nil && *scene.VideoURL != "" {
// 回退到远程 URL
videoURL = *scene.VideoURL
sceneID = scene.ID
s.log.Infow("Using remote video from storyboard", "storyboard_id", clip.StoryboardID, "video_url", videoURL)
}
} else if scene.VideoURL != nil && *scene.VideoURL != "" {
// 如果没有找到 video_generation,直接使用 storyboard 的 video_url
videoURL = *scene.VideoURL
sceneID = scene.ID
s.log.Infow("Using video from storyboard (no video_generation found)", "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,
"video_url", videoURL,
"transition", clip.Transition)
sceneClips = append(sceneClips, sceneClip)
s.log.Infow("Scene clip added", "total_clips", len(sceneClips))
}
} 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 {
// 优先使用 local_path
if asset.LocalPath != nil && *asset.LocalPath != "" {
// 检查是否已经是完整路径
if filepath.IsAbs(*asset.LocalPath) || filepath.HasPrefix(*asset.LocalPath, s.storagePath) {
videoURL = *asset.LocalPath
} else {
videoURL = filepath.Join(s.storagePath, *asset.LocalPath)
}
s.log.Infow("Using local video from asset library for storyboard",
"storyboard_id", scene.ID,
"asset_id", asset.ID,
"local_path", videoURL)
} else {
videoURL = asset.URL
s.log.Infow("Using remote video from asset library for storyboard",
"storyboard_id", scene.ID,
"asset_id", asset.ID,
"video_url", videoURL)
}
} else {
// 如果素材库没有,查找 video_generation 记录
var videoGen models.VideoGeneration
if err := s.db.Where("storyboard_id = ? AND status = ?", scene.ID, "completed").Order("created_at DESC").First(&videoGen).Error; err == nil {
if videoGen.LocalPath != nil && *videoGen.LocalPath != "" {
// 检查是否已经是完整路径
if filepath.IsAbs(*videoGen.LocalPath) || filepath.HasPrefix(*videoGen.LocalPath, s.storagePath) {
videoURL = *videoGen.LocalPath
} else {
videoURL = filepath.Join(s.storagePath, *videoGen.LocalPath)
}
s.log.Infow("Using local video from video_generation for storyboard",
"storyboard_id", scene.ID,
"local_path", videoURL)
} else if scene.VideoURL != nil && *scene.VideoURL != "" {
videoURL = *scene.VideoURL
s.log.Infow("Using remote video from storyboard",
"storyboard_id", scene.ID,
"video_url", videoURL)
}
} else if scene.VideoURL != nil && *scene.VideoURL != "" {
// 最后回退到 storyboard 的 video_url
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
}
================================================
FILE: cmd/migrate/main.go
================================================
package main
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/infrastructure/database"
"github.com/drama-generator/backend/pkg/config"
"github.com/drama-generator/backend/pkg/logger"
"gorm.io/gorm"
)
// DataMigrationService 数据迁移服务
type DataMigrationService struct {
db *gorm.DB
log *logger.Logger
storageRoot string
urlMapping map[string]string // 原始URL -> 本地路径的映射
}
// MigrationStats 迁移统计信息
type MigrationStats struct {
AssetsSuccess int
AssetsFailed int
CharacterLibrariesSuccess int
CharacterLibrariesFailed int
CharactersSuccess int
CharactersFailed int
ImageGenerationsSuccess int
ImageGenerationsFailed int
ScenesSuccess int
ScenesFailed int
VideosSuccess int
VideosFailed int
}
func main() {
fmt.Println("=== 数据清洗工具:迁移 local_path ===")
fmt.Println("开始时间:", time.Now().Format("2006-01-02 15:04:05"))
fmt.Println()
// 1. 初始化日志
logr := logger.NewLogger(false)
logr.Info("初始化日志系统...")
// 2. 加载配置
cfg, err := config.LoadConfig()
if err != nil {
logr.Fatalw("加载配置失败", "error", err)
}
logr.Info("配置加载成功")
// 3. 连接数据库
db, err := database.NewDatabase(cfg.Database)
if err != nil {
logr.Fatalw("数据库连接失败", "error", err)
}
logr.Info("数据库连接成功")
// 4. 创建迁移服务
service := &DataMigrationService{
db: db,
log: logr,
storageRoot: "data/storage",
urlMapping: make(map[string]string),
}
// 5. 执行迁移
if err := service.MigrateLocalPaths(); err != nil {
logr.Fatalw("数据清洗失败", "error", err)
}
fmt.Println()
fmt.Println("=== 数据清洗完成 ===")
fmt.Println("结束时间:", time.Now().Format("2006-01-02 15:04:05"))
}
// MigrateLocalPaths 迁移所有表中 local_path 为空的数据
func (s *DataMigrationService) MigrateLocalPaths() error {
s.log.Info("开始数据清洗:迁移 local_path 为空的数据")
startTime := time.Now()
// 确保存储目录存在
if err := s.ensureStorageDirectories(); err != nil {
return fmt.Errorf("创建存储目录失败: %w", err)
}
// 迁移各个表的数据(按指定顺序)
stats := &MigrationStats{}
// 1. 迁移 assets 表
if err := s.migrateAssets(stats); err != nil {
s.log.Errorw("迁移 assets 数据失败", "error", err)
}
// 2. 迁移 character_libraries 表
if err := s.migrateCharacterLibraries(stats); err != nil {
s.log.Errorw("迁移 character_libraries 数据失败", "error", err)
}
// 3. 迁移 characters 表
if err := s.migrateCharacters(stats); err != nil {
s.log.Errorw("迁移 characters 数据失败", "error", err)
}
// 4. 迁移 image_generations 表
if err := s.migrateImageGenerations(stats); err != nil {
s.log.Errorw("迁移 image_generations 数据失败", "error", err)
}
// 5. 迁移 scenes 表
if err := s.migrateScenes(stats); err != nil {
s.log.Errorw("迁移 scenes 数据失败", "error", err)
}
// 6. 迁移 video_generations 表
if err := s.migrateVideoGenerations(stats); err != nil {
s.log.Errorw("迁移 video_generations 数据失败", "error", err)
}
duration := time.Since(startTime)
s.log.Infow("数据清洗完成",
"总耗时", duration.String(),
"URL映射缓存数", len(s.urlMapping),
"Assets成功", stats.AssetsSuccess,
"Assets失败", stats.AssetsFailed,
"角色库成功", stats.CharacterLibrariesSuccess,
"角色库失败", stats.CharacterLibrariesFailed,
"角色成功", stats.CharactersSuccess,
"角色失败", stats.CharactersFailed,
"图片生成成功", stats.ImageGenerationsSuccess,
"图片生成失败", stats.ImageGenerationsFailed,
"场景成功", stats.ScenesSuccess,
"场景失败", stats.ScenesFailed,
"视频成功", stats.VideosSuccess,
"视频失败", stats.VideosFailed,
)
return nil
}
// ensureStorageDirectories 确保存储目录存在
func (s *DataMigrationService) ensureStorageDirectories() error {
dirs := []string{
filepath.Join(s.storageRoot, "images"),
filepath.Join(s.storageRoot, "characters"),
filepath.Join(s.storageRoot, "videos"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", dir, err)
}
}
s.log.Infow("存储目录创建成功", "root", s.storageRoot)
return nil
}
// migrateAssets 迁移 assets 表数据
func (s *DataMigrationService) migrateAssets(stats *MigrationStats) error {
s.log.Info("开始迁移 assets 数据...")
var assets []models.Asset
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND url IS NOT NULL AND url != ''").Find(&assets).Error; err != nil {
return fmt.Errorf("查询 assets 数据失败: %w", err)
}
s.log.Infow("找到需要迁移的 assets", "数量", len(assets))
for _, asset := range assets {
s.log.Infow("处理 asset", "id", asset.ID, "name", asset.Name, "type", asset.Type, "url", asset.URL)
subDir := "images"
if asset.Type == models.AssetTypeVideo {
subDir = "videos"
}
localPath, err := s.downloadOrGetCached(asset.URL, subDir, fmt.Sprintf("asset_%d", asset.ID))
if err != nil {
s.log.Errorw("下载 asset 失败", "asset_id", asset.ID, "error", err)
stats.AssetsFailed++
continue
}
if err := s.db.Model(&asset).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新 asset local_path 失败", "asset_id", asset.ID, "error", err)
stats.AssetsFailed++
continue
}
s.log.Infow("asset 迁移成功", "asset_id", asset.ID, "local_path", localPath)
stats.AssetsSuccess++
}
return nil
}
// migrateCharacterLibraries 迁移 character_libraries 表数据
func (s *DataMigrationService) migrateCharacterLibraries(stats *MigrationStats) error {
s.log.Info("开始迁移 character_libraries 数据...")
var charLibs []models.CharacterLibrary
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&charLibs).Error; err != nil {
return fmt.Errorf("查询 character_libraries 数据失败: %w", err)
}
s.log.Infow("找到需要迁移的 character_libraries", "数量", len(charLibs))
for _, charLib := range charLibs {
s.log.Infow("处理 character_library", "id", charLib.ID, "name", charLib.Name, "image_url", charLib.ImageURL)
localPath, err := s.downloadOrGetCached(charLib.ImageURL, "characters", fmt.Sprintf("charlib_%d", charLib.ID))
if err != nil {
s.log.Errorw("下载 character_library 图片失败", "charlib_id", charLib.ID, "error", err)
stats.CharacterLibrariesFailed++
continue
}
if err := s.db.Model(&charLib).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新 character_library local_path 失败", "charlib_id", charLib.ID, "error", err)
stats.CharacterLibrariesFailed++
continue
}
s.log.Infow("character_library 迁移成功", "charlib_id", charLib.ID, "local_path", localPath)
stats.CharacterLibrariesSuccess++
}
return nil
}
// migrateCharacters 迁移角色数据
func (s *DataMigrationService) migrateCharacters(stats *MigrationStats) error {
s.log.Info("开始迁移角色数据...")
var characters []models.Character
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&characters).Error; err != nil {
return fmt.Errorf("查询角色数据失败: %w", err)
}
s.log.Infow("找到需要迁移的角色", "数量", len(characters))
for _, character := range characters {
if character.ImageURL == nil {
continue
}
s.log.Infow("处理角色", "id", character.ID, "name", character.Name, "image_url", *character.ImageURL)
localPath, err := s.downloadOrGetCached(*character.ImageURL, "characters", fmt.Sprintf("character_%d", character.ID))
if err != nil {
s.log.Errorw("下载角色图片失败", "character_id", character.ID, "error", err)
stats.CharactersFailed++
continue
}
if err := s.db.Model(&character).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新角色 local_path 失败", "character_id", character.ID, "error", err)
stats.CharactersFailed++
continue
}
s.log.Infow("角色迁移成功", "character_id", character.ID, "local_path", localPath)
stats.CharactersSuccess++
}
return nil
}
// migrateImageGenerations 迁移 image_generations 表数据
func (s *DataMigrationService) migrateImageGenerations(stats *MigrationStats) error {
s.log.Info("开始迁移 image_generations 数据...")
var imageGens []models.ImageGeneration
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&imageGens).Error; err != nil {
return fmt.Errorf("查询 image_generations 数据失败: %w", err)
}
s.log.Infow("找到需要迁移的 image_generations", "数量", len(imageGens))
for _, imageGen := range imageGens {
if imageGen.ImageURL == nil {
continue
}
imageTypeStr := string(imageGen.ImageType)
s.log.Infow("处理 image_generation", "id", imageGen.ID, "image_type", imageTypeStr, "image_url", *imageGen.ImageURL)
subDir := "images"
if imageGen.ImageType == "character" {
subDir = "characters"
}
localPath, err := s.downloadOrGetCached(*imageGen.ImageURL, subDir, fmt.Sprintf("imggen_%d", imageGen.ID))
if err != nil {
s.log.Errorw("下载 image_generation 图片失败", "imggen_id", imageGen.ID, "error", err)
stats.ImageGenerationsFailed++
continue
}
if err := s.db.Model(&imageGen).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新 image_generation local_path 失败", "imggen_id", imageGen.ID, "error", err)
stats.ImageGenerationsFailed++
continue
}
s.log.Infow("image_generation 迁移成功", "imggen_id", imageGen.ID, "local_path", localPath)
stats.ImageGenerationsSuccess++
}
return nil
}
// migrateScenes 迁移场景数据
func (s *DataMigrationService) migrateScenes(stats *MigrationStats) error {
s.log.Info("开始迁移场景数据...")
var scenes []models.Scene
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&scenes).Error; err != nil {
return fmt.Errorf("查询场景数据失败: %w", err)
}
s.log.Infow("找到需要迁移的场景", "数量", len(scenes))
for _, scene := range scenes {
if scene.ImageURL == nil {
continue
}
s.log.Infow("处理场景", "id", scene.ID, "location", scene.Location, "image_url", *scene.ImageURL)
localPath, err := s.downloadOrGetCached(*scene.ImageURL, "images", fmt.Sprintf("scene_%d", scene.ID))
if err != nil {
s.log.Errorw("下载场景图片失败", "scene_id", scene.ID, "error", err)
stats.ScenesFailed++
continue
}
if err := s.db.Model(&scene).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新场景 local_path 失败", "scene_id", scene.ID, "error", err)
stats.ScenesFailed++
continue
}
s.log.Infow("场景迁移成功", "scene_id", scene.ID, "local_path", localPath)
stats.ScenesSuccess++
}
return nil
}
// migrateVideoGenerations 迁移视频生成数据
func (s *DataMigrationService) migrateVideoGenerations(stats *MigrationStats) error {
s.log.Info("开始迁移视频生成数据...")
var videoGens []models.VideoGeneration
if err := s.db.Where("(local_path IS NULL OR local_path = '') AND video_url IS NOT NULL AND video_url != ''").Find(&videoGens).Error; err != nil {
return fmt.Errorf("查询视频生成数据失败: %w", err)
}
s.log.Infow("找到需要迁移的视频", "数量", len(videoGens))
for _, videoGen := range videoGens {
if videoGen.VideoURL == nil {
continue
}
s.log.Infow("处理视频", "id", videoGen.ID, "video_url", *videoGen.VideoURL)
localPath, err := s.downloadOrGetCached(*videoGen.VideoURL, "videos", fmt.Sprintf("video_%d", videoGen.ID))
if err != nil {
s.log.Errorw("下载视频失败", "video_gen_id", videoGen.ID, "error", err)
stats.VideosFailed++
continue
}
if err := s.db.Model(&videoGen).Update("local_path", localPath).Error; err != nil {
s.log.Errorw("更新视频 local_path 失败", "video_gen_id", videoGen.ID, "error", err)
stats.VideosFailed++
continue
}
s.log.Infow("视频迁移成功", "video_gen_id", videoGen.ID, "local_path", localPath)
stats.VideosSuccess++
}
return nil
}
// downloadOrGetCached 下载文件或从缓存获取本地路径
func (s *DataMigrationService) downloadOrGetCached(url, subDir, prefix string) (string, error) {
// 1. 检查 URL 映射缓存
if localPath, exists := s.urlMapping[url]; exists {
s.log.Infow("使用缓存的本地路径", "url", url, "local_path", localPath)
return localPath, nil
}
// 2. 如果缓存中没有,则下载文件
var localPath string
var err error
// 根据子目录判断是图片还是视频
if subDir == "videos" {
localPath, err = s.downloadAndSaveVideo(url, subDir, prefix)
} else {
localPath, err = s.downloadAndSaveImage(url, subDir, prefix)
}
if err != nil {
return "", err
}
// 3. 将 URL 和本地路径的映射关系存入缓存
s.urlMapping[url] = localPath
s.log.Infow("已缓存 URL 映射", "url", url, "local_path", localPath)
return localPath, nil
}
// downloadAndSaveImage 下载并保存图片
func (s *DataMigrationService) downloadAndSaveImage(imageURL, subDir, prefix string) (string, error) {
if imageURL == "" {
return "", fmt.Errorf("图片 URL 为空")
}
// 如果已经是本地路径,直接返回
if strings.HasPrefix(imageURL, "/static/") || strings.HasPrefix(imageURL, "data/") {
return imageURL, nil
}
// 从 URL 中提取文件扩展名(去掉查询参数)
ext := s.extractFileExtension(imageURL)
// 生成文件名
timestamp := time.Now().Unix()
filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext)
relativePath := filepath.Join(subDir, filename)
fullPath := filepath.Join(s.storageRoot, relativePath)
// 下载文件
if err := s.downloadFile(imageURL, fullPath); err != nil {
return "", fmt.Errorf("下载文件失败: %w", err)
}
// 返回相对路径(用于存储到数据库)
return relativePath, nil
}
// downloadAndSaveVideo 下载并保存视频
func (s *DataMigrationService) downloadAndSaveVideo(videoURL, subDir, prefix string) (string, error) {
if videoURL == "" {
return "", fmt.Errorf("视频 URL 为空")
}
// 如果已经是本地路径,直接返回
if strings.HasPrefix(videoURL, "/static/") || strings.HasPrefix(videoURL, "data/") {
return videoURL, nil
}
// 从 URL 中提取文件扩展名(去掉查询参数)
ext := s.extractFileExtension(videoURL)
if ext == "" || ext == ".jpeg" || ext == ".jpg" || ext == ".png" {
ext = ".mp4" // 视频默认扩展名
}
// 生成文件名
timestamp := time.Now().Unix()
filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext)
relativePath := filepath.Join(subDir, filename)
fullPath := filepath.Join(s.storageRoot, relativePath)
// 下载文件
if err := s.downloadFile(videoURL, fullPath); err != nil {
return "", fmt.Errorf("下载文件失败: %w", err)
}
// 返回相对路径(用于存储到数据库)
return relativePath, nil
}
// extractFileExtension 从 URL 中提取文件扩展名(去掉查询参数)
func (s *DataMigrationService) extractFileExtension(url string) string {
// 去掉查询参数
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}
// 去掉 fragment
if idx := strings.Index(url, "#"); idx != -1 {
url = url[:idx]
}
// 获取文件扩展名
ext := filepath.Ext(url)
if ext == "" {
// 如果没有扩展名,默认返回 .jpg
return ".jpg"
}
// 转换为小写
ext = strings.ToLower(ext)
// 验证扩展名是否合理(限制长度)
if len(ext) > 10 {
return ".jpg"
}
return ext
}
// downloadFile 下载文件到指定路径
func (s *DataMigrationService) downloadFile(url, filepath string) error {
s.log.Infow("开始下载文件", "url", url, "filepath", filepath)
// 创建 HTTP 请求
client := &http.Client{
Timeout: 60 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP 状态码错误: %d", resp.StatusCode)
}
// 创建文件
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
// 写入文件
size, err := io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
s.log.Infow("文件下载成功", "filepath", filepath, "size", size)
return nil
}
================================================
FILE: configs/config.example.yaml
================================================
app:
name: "Huobao Drama API"
version: "1.0.0"
debug: true
language: "zh" # 系统语言:zh(中文) 或 en(英文)
server:
port: 5678
host: "0.0.0.0"
cors_origins:
- "http://localhost:3012"
read_timeout: 600
write_timeout: 600
database:
type: "sqlite"
path: "./data/drama_generator.db"
max_idle: 10
max_open: 100
storage:
type: "local"
local_path: "./data/storage"
base_url: "http://localhost:5678/static"
ai:
default_text_provider: "openai"
default_image_provider: "openai"
default_video_provider: "doubao"
================================================
FILE: docker-compose.yml
================================================
services:
huobao-drama:
# image: huobao-drama:latest
container_name: huobao-drama
build:
context: .
dockerfile: Dockerfile
args:
# Docker Hub 镜像源(注意末尾斜杠)
DOCKER_REGISTRY: ${DOCKER_REGISTRY:-}
# npm 镜像源
NPM_REGISTRY: ${NPM_REGISTRY:-}
# Go 代理
GO_PROXY: ${GO_PROXY:-}
# Alpine apk 镜像源
ALPINE_MIRROR: ${ALPINE_MIRROR:-}
ports:
- "5678:5678"
volumes:
# 持久化数据目录(使用命名卷,容器内以 root 运行)
- huobao-data:/app/data
# 挂载配置文件(可选,如需自定义配置请取消注释)
# - ./configs/config.yaml:/app/configs/config.yaml:ro
# 注意:如果使用本地目录挂载,需要确保目录权限正确
# 例如:- ./data:/app/data (需要 chmod 777 ./data)
environment:
- TZ=Asia/Shanghai
# 访问宿主机服务说明:
# 使用 host.docker.internal 代替 127.0.0.1
# 例如:http://host.docker.internal:11434 (Ollama)
extra_hosts:
- "host.docker.internal:host-gateway" # 统一支持所有平台
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5678/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
networks:
- huobao-network
volumes:
huobao-data:
driver: local
networks:
huobao-network:
driver: bridge
================================================
FILE: docs/DATA_MIGRATION.md
================================================
# 数据清洗服务文档
## 概述
数据清洗服务(Data Migration Service)用于自动下载并迁移数据库中 `local_path` 字段为空的数据。该服务会在应用启动时自动执行,将远程 URL 的文件下载到本地存储,并更新数据库中的 `local_path` 字段。
## 功能特性
- ✅ **自动执行**:服务启动时自动运行,无需手动干预
- ✅ **异步处理**:后台异步执行,不阻塞服务启动
- ✅ **多表支持**:支持场景、角色、视频、分镜等多个表
- ✅ **智能分类**:根据数据类型自动分类存储到不同目录
- ✅ **错误容忍**:单个文件下载失败不影响其他文件的处理
- ✅ **详细日志**:提供完整的执行日志和统计信息
## 处理的数据表
### 1. 场景表(scenes)
- **字段**:`image_url` → `local_path`
- **存储目录**:`data/storage/images/`
- **文件命名**:`scene_{id}_{timestamp}.{ext}`
### 2. 角色表(characters)
- **字段**:`image_url` → `local_path`
- **存储目录**:`data/storage/characters/`
- **文件命名**:`character_{id}_{timestamp}.{ext}`
### 3. 视频生成表(video_generations)
- **字段**:`video_url` → `local_path`
- **存储目录**:`data/storage/videos/`
- **文件命名**:`video_{id}_{timestamp}.{ext}`
### 4. 分镜表(storyboards)
- **字段**:`image_url` → `local_path`
- **存储目录**:`data/storage/images/`
- **文件命名**:`storyboard_{id}_{timestamp}.{ext}`
## 执行流程
```
1. 服务启动
↓
2. 数据库连接和迁移
↓
3. 启动数据清洗任务(异步)
↓
4. 创建存储目录
↓
5. 查询各表中 local_path 为空的数据
↓
6. 遍历每条记录
├─ 下载文件到本地
├─ 更新 local_path 字段
└─ 记录成功/失败统计
↓
7. 输出执行统计
```
## 日志示例
### 启动日志
```
INFO 启动数据清洗任务...
INFO 开始数据清洗:迁移 local_path 为空的数据
INFO 存储目录创建成功 root=data/storage
```
### 处理日志
```
INFO 开始迁移场景数据...
INFO 找到需要迁移的场景 数量=5
INFO 处理场景 id=1 location=大型超市 image_url=https://...
INFO 开始下载文件 url=https://... filepath=data/storage/images/scene_1_1706345678.jpg
INFO 文件下载成功 filepath=data/storage/images/scene_1_1706345678.jpg size=245678
INFO 场景迁移成功 scene_id=1 local_path=images/scene_1_1706345678.jpg
```
### 完成日志
```
INFO 数据清洗完成
总耗时=15.234s
场景成功=5 场景失败=0
角色成功=3 角色失败=1
视频成功=2 视频失败=0
分镜成功=4 分镜失败=0
```
### 错误日志
```
ERROR 下载场景图片失败 scene_id=10 error=HTTP 状态码错误: 404
ERROR 更新角色 local_path 失败 character_id=5 error=database connection lost
```
## 配置说明
### 存储根目录
默认存储根目录为 `data/storage`,可在代码中修改:
```go
storageRoot: "data/storage" // 可自定义路径
```
### 下载超时设置
默认 HTTP 请求超时为 60 秒:
```go
client := &http.Client{
Timeout: 60 * time.Second, // 可根据需要调整
}
```
## 错误处理
### 跳过的情况
- URL 为空
- URL 已经是本地路径(以 `/static/` 或 `data/` 开头)
- HTTP 请求失败(404、超时等)
- 文件写入失败
- 数据库更新失败
### 错误不会导致
- ❌ 服务启动失败
- ❌ 其他数据处理中断
- ❌ 数据库回滚
## 手动触发
如果需要手动触发数据清洗(例如在运行时),可以通过以下方式:
```go
// 创建服务实例
migrationService := services.NewDataMigrationService(db, logger)
// 执行迁移
if err := migrationService.MigrateLocalPaths(); err != nil {
log.Printf("数据清洗失败: %v", err)
}
```
## 性能考虑
### 异步执行
数据清洗任务在后台异步执行,不会阻塞服务启动。服务可以立即开始处理用户请求。
### 网络带宽
- 大量文件下载可能占用网络带宽
- 建议在低峰期执行或限制并发下载数
### 存储空间
- 确保服务器有足够的磁盘空间
- 定期清理不再使用的文件
## 监控建议
### 关键指标
- 成功迁移数量
- 失败迁移数量
- 总执行时间
- 磁盘使用率
### 告警条件
- 失败率 > 10%
- 执行时间 > 5 分钟
- 磁盘使用率 > 90%
## 故障排查
### 问题:所有下载都失败
**可能原因**:
- 网络连接问题
- 防火墙阻止外部请求
- 源服务器不可用
**解决方案**:
- 检查网络连接
- 检查防火墙配置
- 验证源 URL 是否可访问
### 问题:部分下载失败
**可能原因**:
- 特定 URL 无效或过期
- 文件格式不支持
- 临时网络波动
**解决方案**:
- 查看错误日志定位具体 URL
- 手动验证 URL 有效性
- 重启服务重试
### 问题:数据库更新失败
**可能原因**:
- 数据库连接断开
- 权限不足
- 字段约束冲突
**解决方案**:
- 检查数据库连接
- 验证数据库用户权限
- 检查表结构和约束
## 代码位置
- **服务实现**:`application/services/data_migration_service.go`
- **集成代码**:`main.go`(第 45-55 行)
- **文档**:`docs/DATA_MIGRATION.md`
## 版本历史
- **v1.0.0** (2026-01-27)
- 初始版本
- 支持场景、角色、视频、分镜数据迁移
- 异步执行,详细日志
================================================
FILE: domain/models/ai_config.go
================================================
package models
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
)
type AIServiceConfig struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServiceType string `gorm:"type:varchar(50);not null" json:"service_type"` // text, image, video
Provider string `gorm:"type:varchar(50)" json:"provider"` // openai, gemini, volcengine, etc.
Name string `gorm:"type:varchar(100);not null" json:"name"`
BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"`
APIKey string `gorm:"type:varchar(255);not null" json:"api_key"`
Model ModelField `gorm:"type:text" json:"model"`
Endpoint string `gorm:"type:varchar(255)" json:"endpoint"`
QueryEndpoint string `gorm:"type:varchar(255)" json:"query_endpoint"`
Priority int `gorm:"default:0" json:"priority"` // 优先级,数值越大优先级越高
IsDefault bool `gorm:"default:false" json:"is_default"`
IsActive bool `gorm:"default:true" json:"is_active"`
Settings string `gorm:"type:text" json:"settings"`
CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
}
func (c *AIServiceConfig) TableName() string {
return "ai_service_configs"
}
type AIServiceProvider struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(100);not null;uniqueIndex" json:"name"`
DisplayName string `gorm:"type:varchar(100);not null" json:"display_name"`
ServiceType string `gorm:"type:varchar(50);not null" json:"service_type"`
DefaultURL string `gorm:"type:varchar(255)" json:"default_url"`
Description string `gorm:"type:text" json:"description"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"`
}
func (p *AIServiceProvider) TableName() string {
return "ai_service_providers"
}
// ModelField 自定义类型,支持字符串或字符串数组
type ModelField []string
// Value 实现 driver.Valuer 接口,用于存储到数据库
func (m ModelField) Value() (driver.Value, error) {
if len(m) == 0 {
return nil, nil
}
data, err := json.Marshal(m)
if err != nil {
return nil, err
}
return string(data), nil
}
// Scan 实现 sql.Scanner 接口,用于从数据库读取
func (m *ModelField) Scan(value interface{}) error {
if value == nil {
*m = []string{}
return nil
}
var data []byte
switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.New("unsupported type for ModelField")
}
// 尝试解析为数组
var arr []string
if err := json.Unmarshal(data, &arr); err == nil {
*m = arr
return nil
}
// 如果解析失败,尝试作为单个字符串处理
var str string
if err := json.Unmarshal(data, &str); err == nil {
*m = []string{str}
return nil
}
// 兼容旧数据:直接作为字符串
*m = []string{string(data)}
return nil
}
// MarshalJSON 实现 json.Marshaler 接口
func (m ModelField) MarshalJSON() ([]byte, error) {
if len(m) == 0 {
return json.Marshal([]string{})
}
return json.Marshal([]string(m))
}
// UnmarshalJSON 实现 json.Unmarshaler 接口,支持字符串或数组
func (m *ModelField) UnmarshalJSON(data []byte) error {
// 尝试解析为数组
var arr []string
if err := json.Unmarshal(data, &arr); err == nil {
*m = arr
return nil
}
// 尝试解析为单个字符串
var str string
if err := json.Unmarshal(data, &str); err == nil {
*m = []string{str}
return nil
}
return errors.New("model field must be string or array of strings")
}
================================================
FILE: domain/models/asset.go
================================================
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"`
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"
}
================================================
FILE: domain/models/character_library.go
================================================
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"`
LocalPath *string `gorm:"type:varchar(500)" json:"local_path,omitempty"`
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"
}
================================================
FILE: domain/models/drama.go
================================================
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"`
Props []Prop `gorm:"foreignKey:DramaID" json:"props,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"`
LocalPath *string `gorm:"type:text" json:"local_path,omitempty"`
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"`
Props []Prop `gorm:"many2many:storyboard_props;" json:"props,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"`
LocalPath *string `gorm:"type:text" json:"local_path"`
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"
}
type Prop 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"`
Type *string `gorm:"type:varchar(50)" json:"type"` // e.g., "weapon", "daily", "vehicle"
Description *string `gorm:"type:text" json:"description"`
Prompt *string `gorm:"type:text" json:"prompt"` // AI Image prompt
ImageURL *string `gorm:"type:varchar(500)" json:"image_url"`
LocalPath *string `gorm:"type:text" json:"local_path,omitempty"`
ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images"`
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:"-"`
// Relationships
Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"`
Storyboards []Storyboard `gorm:"many2many:storyboard_props;" json:"storyboards,omitempty"`
}
func (p *Prop) TableName() string {
return "props"
}
================================================
FILE: domain/models/frame_prompt.go
================================================
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"
)
================================================
FILE: domain/models/image_generation.go
================================================
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"`
PropID *uint `gorm:"index" json:"prop_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"`
Prop *Prop `gorm:"foreignKey:PropID" json:"prop,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" // 场景图片
ImageTypeProp ImageType = "prop" // 道具图片
ImageTypeStoryboard ImageType = "storyboard" // 分镜图片
)
================================================
FILE: domain/models/task.go
================================================
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:"-"`
}
================================================
FILE: domain/models/timeline.go
================================================
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"
}
================================================
FILE: domain/models/video_generation.go
================================================
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"
}
================================================
FILE: domain/models/video_merge.go
================================================
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"
}
================================================
FILE: go.mod
================================================
module github.com/drama-generator/backend
go 1.23.0
replace github.com/drama-generator/backend => ./
require (
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.17.0
go.uber.org/zap v1.26.0
gorm.io/datatypes v1.2.0
gorm.io/driver/mysql v1.5.2
gorm.io/driver/postgres v1.5.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0
modernc.org/sqlite v1.34.4
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/goleak v1.2.1 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
================================================
FILE: go.sum
================================================
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
================================================
FILE: infrastructure/database/custom_logger.go
================================================
package database
import (
"context"
"strings"
"time"
"gorm.io/gorm/logger"
)
// CustomLogger 自定义 GORM logger,截断过长的 SQL 参数(如 base64 数据)
type CustomLogger struct {
logger.Interface
}
// NewCustomLogger 创建自定义 logger
func NewCustomLogger() logger.Interface {
return &CustomLogger{
Interface: logger.Default.LogMode(logger.Silent),
}
}
// Trace 重写 Trace 方法,禁用 SQL 日志输出
func (l *CustomLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
// 不输出任何 SQL 日志
// 如果需要调试,可以临时取消注释下面的代码
/*
sql, rows := fc()
sql = truncateLongValues(sql)
elapsed := time.Since(begin)
if err != nil {
l.Interface.Error(ctx, "SQL error: %v [%v] %s", err, elapsed, sql)
} else {
l.Interface.Info(ctx, "[%.3fms] [rows:%d] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql)
}
*/
}
// truncateLongValues 截断 SQL 中的长字符串值
func truncateLongValues(sql string) string {
// 查找 base64 格式的数据 (data:image/...;base64,...)
if strings.Contains(sql, "data:image/") && strings.Contains(sql, ";base64,") {
parts := strings.Split(sql, "\"")
for i, part := range parts {
if strings.HasPrefix(part, "data:image/") && strings.Contains(part, ";base64,") {
if len(part) > 100 {
// 保留前50字符,添加截断标记
parts[i] = part[:50] + "...[base64 data truncated]"
}
}
}
sql = strings.Join(parts, "\"")
}
// 截断其他过长的值
if len(sql) > 5000 {
// 查找 VALUES 或 SET 后的内容
if idx := strings.Index(sql, " VALUES "); idx > 0 && len(sql) > idx+5000 {
sql = sql[:idx+5000] + "...[truncated]"
} else if idx := strings.Index(sql, " SET "); idx > 0 && len(sql) > idx+3000 {
sql = sql[:idx+3000] + "...[truncated]"
} else if len(sql) > 5000 {
sql = sql[:5000] + "...[truncated]"
}
}
return sql
}
// Info 实现 Info 方法
func (l *CustomLogger) Info(ctx context.Context, msg string, data ...interface{}) {
l.Interface.Info(ctx, msg, data...)
}
// Warn 实现 Warn 方法
func (l *CustomLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
l.Interface.Warn(ctx, msg, data...)
}
// Error 实现 Error 方法
func (l *CustomLogger) Error(ctx context.Context, msg string, data ...interface{}) {
// 检查并截断 data 中的长字符串
truncatedData := make([]interface{}, len(data))
for i, d := range data {
if str, ok := d.(string); ok && len(str) > 200 {
if strings.HasPrefix(str, "data:image/") {
truncatedData[i] = str[:50] + "...[base64 data]"
} else {
truncatedData[i] = str[:200] + "..."
}
} else {
truncatedData[i] = d
}
}
l.Interface.Error(ctx, msg, truncatedData...)
}
// LogMode 实现 LogMode 方法
func (l *CustomLogger) LogMode(level logger.LogLevel) logger.Interface {
newLogger := *l
newLogger.Interface = l.Interface.LogMode(level)
return &newLogger
}
================================================
FILE: infrastructure/database/database.go
================================================
package database
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/drama-generator/backend/domain/models"
"github.com/drama-generator/backend/pkg/config"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
)
func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
dsn := cfg.DSN()
if cfg.Type == "sqlite" {
dbDir := filepath.Dir(dsn)
if err := os.MkdirAll(dbDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
}
gormConfig := &gorm.Config{
Logger: NewCustomLogger(),
}
var db *gorm.DB
var err error
if cfg.Type == "sqlite" {
// 使用 modernc.org/sqlite 纯 Go 驱动(无需 CGO)
// 添加并发优化参数:WAL 模式、busy_timeout、cache
dsnWithParams := dsn + "?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&cache=shared"
db, err = gorm.Open(sqlite.Dialector{
DriverName: "sqlite",
DSN: dsnWithParams,
}, gormConfig)
} else {
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
}
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get database instance: %w", err)
}
// SQLite 连接池配置(限制并发连接数)
if cfg.Type == "sqlite" {
sqlDB.SetMaxIdleConns(1)
sqlDB.SetMaxOpenConns(1) // SQLite 单写入,限制为 1
} else {
sqlDB.SetMaxIdleConns(cfg.MaxIdle)
sqlDB.SetMaxOpenConns(cfg.MaxOpen)
}
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return db, nil
}
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
// 核心模型
&models.Drama{},
&models.Episode{},
&models.Character{},
&models.Scene{},
&models.Storyboard{},
&models.FramePrompt{},
&models.Prop{},
// 生成相关
&models.ImageGeneration{},
&models.VideoGeneration{},
&models.VideoMerge{},
// AI配置
&models.AIServiceConfig{},
&models.AIServiceProvider{},
// 资源管理
&models.Asset{},
&models.CharacterLibrary{},
// 任务管理
&models.AsyncTask{},
)
}
================================================
FILE: infrastructure/external/ffmpeg/ffmpeg.go
================================================
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) {
// 检查是否是本地文件路径
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
// 这是本地文件路径,检查文件是否存在
if _, err := os.Stat(url); err == nil {
f.log.Infow("Copying local video file to temp", "source", url, "dest", destPath)
// 复制本地文件到临时目录,避免删除原始文件
sourceFile, err := os.Open(url)
if err != nil {
return "", fmt.Errorf("failed to open source file: %w", err)
}
defer sourceFile.Close()
destFile, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("failed to create dest file: %w", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return "", fmt.Errorf("failed to copy file: %w", err)
}
return destPath, nil
} else {
return "", fmt.Errorf("local file not found: %s", url)
}
}
// 远程 URL,需要下载
f.log.Infow("Downloading video", "url", url, "dest", destPath)
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return "", fmt.Errorf("failed to save file: %w", err)
}
return destPath, nil
}
func (f *FFmpeg) trimVideo(inputPath, outputPath string, startTime, endTime float64) error {
f.log.Infow("Trimming video",
"input", inputPath,
"output", outputPath,
"start", startTime,
"end", endTime)
// 如果startTime和endTime都为0,或者endTime <= startTime,复制整个视频
// 使用重新编码而非-c copy以确保输出文件完整性
if (startTime == 0 && endTime == 0) || endTime <= startTime {
f.log.Infow("No valid trim range, re-encoding entire video")
cmd := exec.Command("ffmpeg",
"-i", inputPath,
"-c:v", "libx264",
"-preset", "fast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"-y",
outputPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Errorw("FFmpeg re-encode failed", "error", err, "output", string(output))
return fmt.Errorf("ffmpeg re-encode failed: %w, output: %s", err, string(output))
}
f.log.Infow("Video re-encoded successfully", "output", outputPath)
return nil
}
// 使用FFmpeg裁剪视频
// -ss: 开始时间(秒)
// -to/-t: 结束时间或持续时间
// 使用重新编码而非-c copy以确保输出文件完整性,避免Windows环境下流信息丢失
var cmd *exec.Cmd
if endTime > 0 {
// 有明确的结束时间
cmd = exec.Command("ffmpeg",
"-i", inputPath,
"-ss", fmt.Sprintf("%.2f", startTime),
"-to", fmt.Sprintf("%.2f", endTime),
"-c:v", "libx264",
"-preset", "fast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"-y",
outputPath,
)
} else {
// 只有开始时间,裁剪到视频末尾
cmd = exec.Command("ffmpeg",
"-i", inputPath,
"-ss", fmt.Sprintf("%.2f", startTime),
"-c:v", "libx264",
"-preset", "fast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"-y",
outputPath,
)
}
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Errorw("FFmpeg trim failed", "error", err, "output", string(output))
return fmt.Errorf("ffmpeg trim failed: %w, output: %s", err, string(output))
}
f.log.Infow("Video trimmed successfully", "output", outputPath)
return nil
}
func (f *FFmpeg) concatenateVideosWithTransitions(inputPaths []string, clips []VideoClip, outputPath string) error {
if len(inputPaths) == 0 {
return fmt.Errorf("no input paths")
}
// 如果只有一个视频,直接复制
if len(inputPaths) == 1 {
f.log.Infow("Only one clip, copying directly")
return f.copyFile(inputPaths[0], outputPath)
}
// 检查是否有转场效果
hasTransitions := false
for _, clip := range clips {
if clip.Transition != nil && len(clip.Transition) > 0 {
hasTransitions = true
break
}
}
// 如果没有转场效果,使用简单拼接
if !hasTransitions {
f.log.Infow("No transitions, using simple concatenation")
return f.concatenateVideos(inputPaths, outputPath)
}
// 使用xfade滤镜添加转场效果
f.log.Infow("Merging with transitions", "clips_count", len(inputPaths))
return f.mergeWithXfade(inputPaths, clips, outputPath)
}
func (f *FFmpeg) concatenateVideos(inputPaths []string, outputPath string) error {
// 创建文件列表
listFile := filepath.Join(f.tempDir, fmt.Sprintf("filelist_%d.txt", time.Now().Unix()))
defer os.Remove(listFile)
var content strings.Builder
for _, path := range inputPaths {
content.WriteString(fmt.Sprintf("file '%s'\n", path))
}
if err := os.WriteFile(listFile, []byte(content.String()), 0644); err != nil {
return fmt.Errorf("failed to create file list: %w", err)
}
// 使用FFmpeg合并视频
// -f concat: 使用concat demuxer
// -safe 0: 允许不安全的文件路径
// -i: 输入文件列表
// -c copy: 直接复制流,不重新编码(速度快)
cmd := exec.Command("ffmpeg",
"-f", "concat",
"-safe", "0",
"-i", listFile,
"-c", "copy",
"-y", // 覆盖输出文件
outputPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Errorw("FFmpeg failed", "error", err, "output", string(output))
return fmt.Errorf("ffmpeg execution failed: %w, output: %s", err, string(output))
}
f.log.Infow("FFmpeg concatenation completed", "output", outputPath)
return nil
}
func (f *FFmpeg) mergeWithXfade(inputPaths []string, clips []VideoClip, outputPath string) error {
// 使用xfade滤镜进行转场
// 构建输入参数
args := []string{}
for _, path := range inputPaths {
args = append(args, "-i", path)
}
// 检测每个视频是否有音频流
audioStreams := make([]bool, len(inputPaths))
hasAnyAudio := false
for i, path := range inputPaths {
audioStreams[i] = f.hasAudioStream(path)
if audioStreams[i] {
hasAnyAudio = true
}
f.log.Infow("Audio stream detection", "index", i, "path", path, "has_audio", audioStreams[i])
}
f.log.Infow("Overall audio detection", "has_any_audio", hasAnyAudio, "audio_streams", audioStreams)
// 检测视频分辨率,找到最大分辨率作为目标分辨率
maxWidth := 0
maxHeight := 0
for i, path := range inputPaths {
width, height := f.getVideoResolution(path)
if width > maxWidth {
maxWidth = width
}
if height > maxHeight {
maxHeight = height
}
f.log.Infow("Video resolution detection", "index", i, "width", width, "height", height)
}
f.log.Infow("Target resolution", "width", maxWidth, "height", maxHeight)
// 为每个视频流添加缩放滤镜,统一分辨率
// 同时为有转场的视频添加 tpad 延长(freeze 最后一帧)
var scaleFilters []string
for i := 0; i < len(inputPaths); i++ {
// 检查当前视频是否需要转场到下一个视频
var tpadDuration float64 = 0
if i < len(clips)-1 && clips[i].Transition != nil {
// 检查转场类型
if tType, ok := clips[i].Transition["type"].(string); ok {
// none 转场不需要 tpad
if strings.ToLower(tType) != "none" && tType != "" {
if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 {
tpadDuration = tDuration
} else {
tpadDuration = 1.0 // 默认1秒
}
}
} else {
// 没有指定类型,默认需要转场
if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 {
tpadDuration = tDuration
} else {
tpadDuration = 1.0
}
}
}
// 使用scale滤镜缩放到目标分辨率,pad添加黑边保持长宽比
// 如果需要转场,使用 tpad 延长视频(freeze最后一帧)
if tpadDuration > 0 {
scaleFilters = append(scaleFilters,
fmt.Sprintf("[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,tpad=stop_mode=clone:stop_duration=%.2f[v%d]",
i, maxWidth, maxHeight, maxWidth, maxHeight, tpadDuration, i))
f.log.Infow("Adding tpad to video", "index", i, "duration", tpadDuration)
} else {
scaleFilters = append(scaleFilters,
fmt.Sprintf("[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2[v%d]",
i, maxWidth, maxHeight, maxWidth, maxHeight, i))
}
}
// 构建filter_complex
// 检查是否有任何转场效果
hasAnyTransition := false
for i := 0; i < len(inputPaths)-1; i++ {
if clips[i].Transition != nil {
if tType, ok := clips[i].Transition["type"].(string); ok {
if strings.ToLower(tType) != "none" && tType != "" {
hasAnyTransition = true
break
}
}
}
}
// 如果没有任何转场,使用简单拼接
if !hasAnyTransition {
f.log.Infow("No transitions detected, using simple concatenation")
return f.concatenateVideos(inputPaths, outputPath)
}
// 构建转场滤镜,使用缩放后的视频流
// 对所有相邻视频都应用 xfade,type=none 时使用 0 秒时长实现无缝拼接
var transitionFilters []string
var offset float64 = 0
for i := 0; i < len(inputPaths)-1; i++ {
// 获取当前片段的时长
clipDuration := clips[i].Duration
if clips[i].EndTime > 0 && clips[i].StartTime >= 0 {
clipDuration = clips[i].EndTime - clips[i].StartTime
}
// 默认转场参数
transitionType := "fade"
transitionDuration := 1.0
if clips[i].Transition != nil {
if tType, ok := clips[i].Transition["type"].(string); ok {
if strings.ToLower(tType) == "none" || tType == "" {
// none 转场使用 0 秒时长,实现无缝拼接
transitionDuration = 0.0
f.log.Infow("Using no transition (0s xfade)", "clip_index", i)
} else {
transitionType = f.mapTransitionType(tType)
f.log.Infow("Using transition type", "type", tType, "mapped", transitionType)
}
}
// 只有非 none 转场才读取时长
if transitionDuration > 0 {
if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 {
transitionDuration = tDuration
}
}
}
// 计算转场开始的时间点
offset += clipDuration
if offset < 0 {
offset = 0
}
f.log.Infow("Transition settings",
"clip_index", i,
"type", transitionType,
"duration", transitionDuration,
"offset", offset,
"clip_duration", clipDuration)
var inputLabel, outputLabel string
if i == 0 {
inputLabel = fmt.Sprintf("[v0][v1]")
} else {
inputLabel = fmt.Sprintf("[vx%02d][v%d]", i-1, i+1)
}
if i == len(inputPaths)-2 {
outputLabel = "[outv]"
} else {
outputLabel = fmt.Sprintf("[vx%02d]", i)
}
filterPart := fmt.Sprintf("%sxfade=transition=%s:duration=%.1f:offset=%.1f%s",
inputLabel, transitionType, transitionDuration, offset, outputLabel)
transitionFilters = append(transitionFilters, filterPart)
}
// 合并缩放和转场滤镜
var videoFilters []string
videoFilters = append(videoFilters, scaleFilters...)
videoFilters = append(videoFilters, transitionFilters...)
filterComplex := strings.Join(videoFilters, ";")
// 音频处理:如果有任何视频包含音频流,则处理音频
var fullFilter string
if hasAnyAudio {
// 为音频流添加处理:生成静音流或延长音频
var audioFilters []string
for i := 0; i < len(inputPaths); i++ {
// 计算该视频的时长
clipDuration := clips[i].Duration
if clips[i].EndTime > 0 && clips[i].StartTime >= 0 {
clipDuration = clips[i].EndTime - clips[i].StartTime
}
// 检查是否需要为转场延长音频
var padDuration float64 = 0
if i < len(clips)-1 && clips[i].Transition != nil {
// 检查转场类型
needTransition := true
if tType, ok := clips[i].Transition["type"].(string); ok {
if strings.ToLower(tType) == "none" || tType == "" {
needTransition = false
}
}
// 只有需要转场时才延长音频
if needTransition {
if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 {
padDuration = tDuration
} else {
padDuration = 1.0
}
}
}
if !audioStreams[i] {
// 没有音频的视频:生成静音轨道(包括转场延长)
totalDuration := clipDuration + padDuration
audioFilters = append(audioFilters,
fmt.Sprintf("anullsrc=channel_layout=stereo:sample_rate=44100:duration=%.2f[a%d]", totalDuration, i))
f.log.Infow("Generated silence for audio", "index", i, "duration", totalDuration)
} else if padDuration > 0 {
// 有音频且需要延长:使用apad添加静音延长(稍后会用acrossfade处理)
audioFilters = append(audioFilters,
fmt.Sprintf("[%d:a]apad=pad_dur=%.2f[a%d]", i, padDuration, i))
f.log.Infow("Padding audio with silence", "index", i, "pad_duration", padDuration)
} else {
// 有音频但不需要延长:直接标记
audioFilters = append(audioFilters,
fmt.Sprintf("[%d:a]acopy[a%d]", i, i))
}
}
// 音频交叉淡入淡出(避免转场时静音)
// 对所有相邻音频都应用 acrossfade,type=none 时使用 0 秒时长
var audioCrossfades []string
for i := 0; i < len(inputPaths)-1; i++ {
// 默认转场时长
transitionDuration := 1.0
if clips[i].Transition != nil {
if tType, ok := clips[i].Transition["type"].(string); ok {
if strings.ToLower(tType) == "none" || tType == "" {
// none 转场使用 0 秒
transitionDuration = 0.0
}
}
// 只有非 none 转场才读取自定义时长
if transitionDuration > 0 {
if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 {
transitionDuration = tDuration
}
}
}
var inputLabel, outputLabel string
if i == 0 {
inputLabel = "[a0][a1]"
} else {
inputLabel = fmt.Sprintf("[ax%02d][a%d]", i-1, i+1)
}
if i == len(inputPaths)-2 {
outputLabel = "[outa]"
} else {
outputLabel = fmt.Sprintf("[ax%02d]", i)
}
// acrossfade: d=转场时长,c1=第一个音频淡出曲线,c2=第二个音频淡入曲线
// 0 秒时长实现无缝音频拼接
audioCrossfades = append(audioCrossfades,
fmt.Sprintf("%sacrossfade=d=%.2f:c1=tri:c2=tri%s", inputLabel, transitionDuration, outputLabel))
f.log.Infow("Audio crossfade",
"clip_index", i,
"duration", transitionDuration)
}
// 构建完整滤镜:音频处理 + 音频交叉淡入淡出
var allAudioFilters []string
allAudioFilters = append(allAudioFilters, audioFilters...)
allAudioFilters = append(allAudioFilters, audioCrossfades...)
fullFilter = filterComplex + ";" + strings.Join(allAudioFilters, ";")
} else {
// 所有视频都无音频流,只处理视频
fullFilter = filterComplex
}
// 构建完整命令
args = append(args,
"-filter_complex", fullFilter,
"-map", "[outv]",
)
// 仅在有任何音频时映射音频输出
if hasAnyAudio {
args = append(args, "-map", "[outa]")
}
args = append(args,
"-c:v", "libx264",
"-preset", "medium",
"-crf", "23",
)
// 仅在有任何音频时设置音频编码参数
if hasAnyAudio {
args = append(args,
"-c:a", "aac",
"-b:a", "128k",
)
}
args = append(args,
"-y",
outputPath,
)
f.log.Infow("Running FFmpeg with transitions", "filter", fullFilter, "has_any_audio", hasAnyAudio)
cmd := exec.Command("ffmpeg", args...)
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Errorw("FFmpeg xfade failed", "error", err, "output", string(output))
return fmt.Errorf("ffmpeg xfade failed: %w, output: %s", err, string(output))
}
f.log.Infow("Video merged with transitions successfully")
return nil
}
func (f *FFmpeg) mapTransitionType(transType string) string {
// 将前端传入的转场类型映射为FFmpeg xfade支持的类型
// FFmpeg xfade支持的完整转场列表: https://ffmpeg.org/ffmpeg-filters.html#xfade
switch strings.ToLower(transType) {
// 淡入淡出类
case "fade", "fadein", "fadeout":
return "fade"
case "fadeblack":
return "fadeblack"
case "fadewhite":
return "fadewhite"
case "fadegrays":
return "fadegrays"
// 滑动类
case "slideleft":
return "slideleft"
case "slideright":
return "slideright"
case "slideup":
return "slideup"
case "slidedown":
return "slidedown"
// 擦除类
case "wipeleft":
return "wipeleft"
case "wiperight":
return "wiperight"
case "wipeup":
return "wipeup"
case "wipedown":
return "wipedown"
// 圆形类
case "circleopen":
return "circleopen"
case "circleclose":
return "circleclose"
// 矩形打开/关闭类
case "horzopen":
return "horzopen"
case "horzclose":
return "horzclose"
case "vertopen":
return "vertopen"
case "vertclose":
return "vertclose"
// 其他特效
case "dissolve":
return "dissolve"
case "distance":
return "distance"
case "pixelize":
return "pixelize"
default:
return "fade" // 默认淡入淡出
}
}
func (f *FFmpeg) hasAudioStream(videoPath string) bool {
cmd := exec.Command("ffprobe",
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=codec_type",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
return false
}
result := strings.TrimSpace(string(output))
return result == "audio"
}
func (f *FFmpeg) getVideoResolution(videoPath string) (int, int) {
cmd := exec.Command("ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0",
videoPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Warnw("Failed to get video resolution", "path", videoPath, "error", err)
return 1920, 1080 // 默认分辨率
}
result := strings.TrimSpace(string(output))
parts := strings.Split(result, ",")
if len(parts) != 2 {
f.log.Warnw("Invalid resolution format", "output", result)
return 1920, 1080
}
var width, height int
fmt.Sscanf(parts[0], "%d", &width)
fmt.Sscanf(parts[1], "%d", &height)
if width <= 0 || height <= 0 {
return 1920, 1080
}
return width, height
}
// GetVideoDuration 获取视频时长(秒)
func (f *FFmpeg) GetVideoDuration(videoPath string) (float64, error) {
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Errorw("Failed to get video duration", "path", videoPath, "error", err)
return 0, fmt.Errorf("ffprobe failed: %w", err)
}
result := strings.TrimSpace(string(output))
var duration float64
_, err = fmt.Sscanf(result, "%f", &duration)
if err != nil {
f.log.Errorw("Failed to parse duration", "output", result, "error", err)
return 0, fmt.Errorf("parse duration failed: %w", err)
}
if duration <= 0 {
return 0, fmt.Errorf("invalid duration: %f", duration)
}
return duration, nil
}
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)
}
// ExtractAudio 从视频文件中提取音频轨道
// 返回提取的音频文件路径
func (f *FFmpeg) ExtractAudio(videoURL, outputPath string) (string, error) {
f.log.Infow("Extracting audio from video", "url", videoURL, "output", outputPath)
// 下载视频文件
downloadPath := filepath.Join(f.tempDir, fmt.Sprintf("video_%d.mp4", time.Now().Unix()))
localVideoPath, err := f.downloadVideo(videoURL, downloadPath)
if err != nil {
return "", fmt.Errorf("failed to download video: %w", err)
}
defer os.Remove(localVideoPath)
// 检查视频是否有音频流
if !f.hasAudioStream(localVideoPath) {
f.log.Warnw("Video has no audio stream, generating silence", "video", videoURL)
// 获取视频时长
duration, err := f.GetVideoDuration(localVideoPath)
if err != nil {
return "", fmt.Errorf("failed to get video duration: %w", err)
}
// 生成静音音频文件
return f.generateSilence(outputPath, duration)
}
// 确保输出目录存在
outputDir := filepath.Dir(outputPath)
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
// 使用FFmpeg提取音频
// -vn: 禁用视频
// -acodec: 音频编码器
// -ar: 音频采样率
// -ac: 音频声道数
// -ab: 音频比特率
cmd := exec.Command("ffmpeg",
"-i", localVideoPath,
"-vn",
"-acodec", "aac",
"-ar", "44100",
"-ac", "2",
"-ab", "128k",
"-y",
outputPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Errorw("FFmpeg audio extraction failed", "error", err, "output", string(output))
return "", fmt.Errorf("ffmpeg audio extraction failed: %w, output: %s", err, string(output))
}
f.log.Infow("Audio extracted successfully", "output", outputPath)
return outputPath, nil
}
// generateSilence 生成指定时长的静音音频文件
func (f *FFmpeg) generateSilence(outputPath string, duration float64) (string, error) {
f.log.Infow("Generating silence audio", "duration", duration, "output", outputPath)
// 确保输出目录存在
outputDir := filepath.Dir(outputPath)
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
// 使用FFmpeg生成静音
// -f lavfi: 使用lavfi(libavfilter)输入
// -i anullsrc: 生成静音音频源
cmd := exec.Command("ffmpeg",
"-f", "lavfi",
"-i", fmt.Sprintf("anullsrc=channel_layout=stereo:sample_rate=44100"),
"-t", fmt.Sprintf("%.2f", duration),
"-acodec", "aac",
"-ab", "128k",
"-y",
outputPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
f.log.Errorw("FFmpeg silence generation failed", "error", err, "output", string(output))
return "", fmt.Errorf("ffmpeg silence generation failed: %w, output: %s", err, string(output))
}
f.log.Infow("Silence audio generated successfully", "output", outputPath)
return outputPath, nil
}
================================================
FILE: infrastructure/scheduler/resource_transfer_scheduler.go
================================================
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()
}
================================================
FILE: infrastructure/storage/local_storage.go
================================================
package storage
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
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)
}
// DownloadResult 下载结果,包含URL和相对路径
type DownloadResult struct {
URL string // 完整的访问URL
RelativePath string // 相对于basePath的路径,用于保存到数据库
AbsolutePath string // 绝对文件路径
}
// DownloadFromURL 从远程URL下载文件到本地存储
func (s *LocalStorage) DownloadFromURL(url, category string) (string, error) {
result, err := s.DownloadFromURLWithPath(url, category)
if err != nil {
return "", err
}
return result.URL, nil
}
// DownloadFromURLWithPath 从远程URL下载文件到本地存储,返回详细信息
func (s *LocalStorage) DownloadFromURLWithPath(url, category string) (*DownloadResult, error) {
// CRITICAL FIX: Add HTTP client with timeout to prevent hanging indefinitely
// Without timeout, the download can hang forever if the remote server is unresponsive
// 5 minute timeout is reasonable for large video/image files
client := &http.Client{
Timeout: 5 * time.Minute,
}
// 发送HTTP请求下载文件
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download file: HTTP %d", resp.StatusCode)
}
// 从URL或Content-Type推断文件扩展名
ext := getFileExtension(url, resp.Header.Get("Content-Type"))
// 创建目录
dir := filepath.Join(s.basePath, category)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create category directory: %w", err)
}
// 生成唯一文件名(时间戳 + UUID 前8位)
timestamp := time.Now().Format("20060102_150405")
uniqueID := uuid.New().String()[:8]
filename := fmt.Sprintf("%s_%s%s", timestamp, uniqueID, ext)
filePath := filepath.Join(dir, filename)
// 保存文件
dst, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, resp.Body); err != nil {
return nil, fmt.Errorf("failed to save file: %w", err)
}
// 返回详细信息
relativePath := filepath.Join(category, filename)
localURL := fmt.Sprintf("%s/%s/%s", s.baseURL, category, filename)
return &DownloadResult{
URL: localURL,
RelativePath: relativePath,
AbsolutePath: filePath,
}, nil
}
// GetAbsolutePath 根据相对路径获取绝对路径
func (s *LocalStorage) GetAbsolutePath(relativePath string) string {
return filepath.Join(s.basePath, relativePath)
}
// getFileExtension 从URL或Content-Type推断文件扩展名
func getFileExtension(url, contentType string) string {
// 首先尝试从URL获取扩展名
if idx := strings.LastIndex(url, "."); idx != -1 {
ext := url[idx:]
// 只取扩展名部分,忽略查询参数
if qIdx := strings.Index(ext, "?"); qIdx != -1 {
ext = ext[:qIdx]
}
if len(ext) <= 5 { // 合理的扩展名长度
return ext
}
}
// 根据Content-Type推断扩展名
switch {
case strings.Contains(contentType, "image/jpeg"):
return ".jpg"
case strings.Contains(contentType, "image/png"):
return ".png"
case strings.Contains(contentType, "image/gif"):
return ".gif"
case strings.Contains(contentType, "image/webp"):
return ".webp"
case strings.Contains(contentType, "video/mp4"):
return ".mp4"
case strings.Contains(contentType, "video/webm"):
return ".webm"
case strings.Contains(contentType, "video/quicktime"):
return ".mov"
default:
return ".bin"
}
}
================================================
FILE: main.go
================================================
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...")
// 清理资源
// CRITICAL FIX: Properly close database connection to prevent resource leaks
// SQLite connections should be closed gracefully to avoid database lock issues
sqlDB, err := db.DB()
if err == nil {
if err := sqlDB.Close(); err != nil {
logr.Warnw("Failed to close database connection", "error", err)
} else {
logr.Info("Database connection closed")
}
}
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")
}
================================================
FILE: migrations/20260126_add_local_path.sql
================================================
-- 添加 local_path 字段到相关表
-- 创建时间: 2026-01-26
-- 说明: 为 characters, scenes, props, character_libraries 表添加 local_path 字段以支持本地存储路径
-- 为 characters 表添加 local_path 字段
ALTER TABLE characters ADD COLUMN local_path TEXT;
-- 为 scenes 表添加 local_path 字段
ALTER TABLE scenes ADD COLUMN local_path TEXT;
-- 为 props 表添加 local_path 字段
ALTER TABLE props ADD COLUMN local_path TEXT;
-- 为 character_libraries 表添加 local_path 字段
ALTER TABLE character_libraries ADD COLUMN local_path TEXT;
================================================
FILE: migrations/init.sql
================================================
-- 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,
local_path 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,
local_path 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 props (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER NOT NULL,
name TEXT NOT NULL,
type TEXT,
description TEXT,
prompt TEXT,
image_url TEXT,
local_path TEXT,
reference_images 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_props_drama_id ON props(drama_id);
CREATE INDEX IF NOT EXISTS idx_props_deleted_at ON props(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,
local_path TEXT,
description TEXT,
tags TEXT,
source_type TEXT NOT NULL DEFAULT 'generated', -- generated, uploaded
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_character_libraries_category ON character_libraries(category);
CREATE INDEX IF NOT EXISTS idx_character_libraries_deleted_at ON character_libraries(deleted_at);
-- ======================================
-- 4. 时间线相关表
-- ======================================
-- 时间线表
CREATE TABLE IF NOT EXISTS timelines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER NOT NULL,
episode_id INTEGER,
name TEXT NOT NULL,
description TEXT,
duration INTEGER NOT NULL DEFAULT 0, -- 总时长(秒)
fps INTEGER NOT NULL DEFAULT 30,
resolution TEXT,
status TEXT NOT NULL DEFAULT 'draft', -- draft, editing, completed, exporting
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_timelines_drama_id ON timelines(drama_id);
CREATE INDEX IF NOT EXISTS idx_timelines_episode_id ON timelines(episode_id);
CREATE INDEX IF NOT EXISTS idx_timelines_status ON timelines(status);
CREATE INDEX IF NOT EXISTS idx_timelines_deleted_at ON timelines(deleted_at);
-- 时间线轨道表
CREATE TABLE IF NOT EXISTS timeline_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timeline_id INTEGER NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- video, audio, text
track_order INTEGER NOT NULL DEFAULT 0,
is_locked INTEGER NOT NULL DEFAULT 0,
is_muted INTEGER NOT NULL DEFAULT 0,
volume INTEGER DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_timeline_tracks_timeline_id ON timeline_tracks(timeline_id);
CREATE INDEX IF NOT EXISTS idx_timeline_tracks_type ON timeline_tracks(type);
CREATE INDEX IF NOT EXISTS idx_timeline_tracks_deleted_at ON timeline_tracks(deleted_at);
-- 时间线片段表
CREATE TABLE IF NOT EXISTS timeline_clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER NOT NULL,
asset_id INTEGER,
storyboard_id INTEGER, -- 修正:引用storyboards而非scenes
name TEXT,
start_time INTEGER NOT NULL, -- 开始时间(毫秒)
end_time INTEGER NOT NULL, -- 结束时间(毫秒)
duration INTEGER NOT NULL, -- 时长(毫秒)
trim_start INTEGER, -- 裁剪开始(毫秒)
trim_end INTEGER, -- 裁剪结束(毫秒)
speed REAL DEFAULT 1.0,
volume INTEGER,
is_muted INTEGER NOT NULL DEFAULT 0,
fade_in INTEGER, -- 淡入时长(毫秒)
fade_out INTEGER, -- 淡出时长(毫秒)
transition_in_id INTEGER,
transition_out_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_timeline_clips_track_id ON timeline_clips(track_id);
CREATE INDEX IF NOT EXISTS idx_timeline_clips_asset_id ON timeline_clips(asset_id);
CREATE INDEX IF NOT EXISTS idx_timeline_clips_storyboard_id ON timeline_clips(storyboard_id);
CREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_in ON timeline_clips(transition_in_id);
CREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_out ON timeline_clips(transition_out_id);
CREATE INDEX IF NOT EXISTS idx_timeline_clips_deleted_at ON timeline_clips(deleted_at);
-- 片段转场表
CREATE TABLE IF NOT EXISTS clip_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, -- fade, crossfade, slide, wipe, zoom, dissolve
duration INTEGER NOT NULL DEFAULT 500, -- 转场时长(毫秒)
easing TEXT,
config TEXT, -- JSON存储
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_clip_transitions_type ON clip_transitions(type);
CREATE INDEX IF NOT EXISTS idx_clip_transitions_deleted_at ON clip_transitions(deleted_at);
-- 片段效果表
CREATE TABLE IF NOT EXISTS clip_effects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
clip_id INTEGER NOT NULL,
type TEXT NOT NULL, -- filter, color, blur, brightness, contrast, saturation
name TEXT,
is_enabled INTEGER NOT NULL DEFAULT 1,
effect_order INTEGER NOT NULL DEFAULT 0,
config TEXT, -- JSON存储
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_clip_effects_clip_id ON clip_effects(clip_id);
CREATE INDEX IF NOT EXISTS idx_clip_effects_type ON clip_effects(type);
CREATE INDEX IF NOT EXISTS idx_clip_effects_deleted_at ON clip_effects(deleted_at);
-- ======================================
-- 5. 资源管理相关表
-- ======================================
-- 资源表
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL, -- image, video, audio
category TEXT,
url TEXT NOT NULL,
thumbnail_url TEXT,
local_path TEXT,
file_size INTEGER,
mime_type TEXT,
width INTEGER,
height INTEGER,
duration INTEGER, -- 时长(秒)
format TEXT,
image_gen_id INTEGER,
video_gen_id INTEGER,
is_favorite INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_assets_drama_id ON assets(drama_id);
CREATE INDEX IF NOT EXISTS idx_assets_type ON assets(type);
CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);
CREATE INDEX IF NOT EXISTS idx_assets_image_gen_id ON assets(image_gen_id);
CREATE INDEX IF NOT EXISTS idx_assets_video_gen_id ON assets(video_gen_id);
CREATE INDEX IF NOT EXISTS idx_assets_deleted_at ON assets(deleted_at);
-- 资源标签表
CREATE TABLE IF NOT EXISTS asset_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_asset_tags_deleted_at ON asset_tags(deleted_at);
-- 资源集合表
CREATE TABLE IF NOT EXISTS asset_collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drama_id INTEGER,
name TEXT NOT NULL,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_asset_collections_drama_id ON asset_collections(drama_id);
CREATE INDEX IF NOT EXISTS idx_asset_collections_deleted_at ON asset_collections(deleted_at);
-- 资源标签关系表(多对多)
CREATE TABLE IF NOT EXISTS asset_tag_relations (
asset_id INTEGER NOT NULL,
asset_tag_id INTEGER NOT NULL,
PRIMARY KEY (asset_id, asset_tag_id)
);
CREATE INDEX IF NOT EXISTS idx_asset_tag_relations_asset_id ON asset_tag_relations(asset_id);
CREATE INDEX IF NOT EXISTS idx_asset_tag_relations_tag_id ON asset_tag_relations(asset_tag_id);
-- 资源集合关系表(多对多)
CREATE TABLE IF NOT EXISTS asset_collection_relations (
asset_id INTEGER NOT NULL,
asset_collection_id INTEGER NOT NULL,
PRIMARY KEY (asset_id, asset_collection_id)
);
CREATE INDEX IF NOT EXISTS idx_asset_collection_relations_asset_id ON asset_collection_relations(asset_id);
CREATE INDEX IF NOT EXISTS idx_asset_collection_relations_collection_id ON asset_collection_relations(asset_collection_id);
-- ======================================
-- 6. AI服务配置表 (开源版本 - 全局配置)
-- ======================================
-- AI服务配置表 (全局配置,无用户隔离)
CREATE TABLE IF NOT EXISTS ai_service_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_type TEXT NOT NULL, -- text, image, video
provider TEXT, -- openai, gemini, volcengine, etc.
name TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT NOT NULL,
model TEXT,
endpoint TEXT,
query_endpoint TEXT,
priority INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
settings TEXT, -- JSON存储
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_ai_service_configs_service_type ON ai_service_configs(service_type);
CREATE INDEX IF NOT EXISTS idx_ai_service_configs_deleted_at ON ai_service_configs(deleted_at);
-- AI服务提供商表
CREATE TABLE IF NOT EXISTS ai_service_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
service_type TEXT NOT NULL, -- text, image, video
default_url TEXT,
description TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_ai_service_providers_service_type ON ai_service_providers(service_type);
CREATE INDEX IF NOT EXISTS idx_ai_service_providers_deleted_at ON ai_service_providers(deleted_at);
-- ======================================
-- 7. 初始数据
-- ======================================
-- 插入默认AI服务提供商
INSERT OR IGNORE INTO ai_service_providers (name, display_name, service_type, default_url, description) VALUES
('openai', 'OpenAI', 'text', 'https://api.openai.com/v1', 'OpenAI GPT模型'),
('openai-dalle', 'OpenAI DALL-E', 'image', 'https://api.openai.com/v1', 'OpenAI DALL-E图片生成'),
('openai-sora', 'OpenAI Sora', 'video', 'https://api.openai.com/v1', 'OpenAI Sora视频生成'),
('midjourney', 'Midjourney', 'image', '', 'Midjourney图片生成'),
('doubao-image', '豆包(火山引擎)', 'image', 'https://ark.cn-beijing.volces.com', '火山引擎豆包图片生成'),
('gemini-image', 'Google Gemini', 'image', 'https://generativelanguage.googleapis.com', 'Google Gemini原生图片生成(base64)'),
('runway', 'Runway', 'video', '', 'Runway视频生成'),
('pika', 'Pika Labs', 'video', '', 'Pika视频生成'),
('doubao', '豆包(火山引擎)', 'video', 'https://ark.cn-beijing.volces.com', '火山引擎豆包视频生成'),
('minimax', 'MiniMax', 'video', '', 'MiniMax视频生成');
================================================
FILE: pkg/ai/client.go
================================================
package ai
// AIClient 定义文本生成客户端接口
type AIClient interface {
GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error)
GenerateImage(prompt string, size string, n int) ([]string, error)
TestConnection() error
}
================================================
FILE: pkg/ai/gemini_client.go
================================================
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type GeminiClient struct {
BaseURL string
APIKey string
Model string
Endpoint string
HTTPClient *http.Client
}
type GeminiTextRequest struct {
Contents []GeminiContent `json:"contents"`
SystemInstruction *GeminiInstruction `json:"systemInstruction,omitempty"`
}
type GeminiContent struct {
Parts []GeminiPart `json:"parts"`
Role string `json:"role,omitempty"`
}
type GeminiPart struct {
Text string `json:"text"`
}
type GeminiInstruction struct {
Parts []GeminiPart `json:"parts"`
}
type GeminiTextResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
Role string `json:"role"`
} `json:"content"`
FinishReason string `json:"finishReason"`
Index int `json:"index"`
SafetyRatings []struct {
Category string `json:"category"`
Probability string `json:"probability"`
} `json:"safetyRatings"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
}
func NewGeminiClient(baseURL, apiKey, model, endpoint string) *GeminiClient {
if baseURL == "" {
baseURL = "https://generativelanguage.googleapis.com"
}
if endpoint == "" {
endpoint = "/v1beta/models/{model}:generateContent"
}
if model == "" {
model = "gemini-3-pro"
}
return &GeminiClient{
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
Endpoint: endpoint,
HTTPClient: &http.Client{
Timeout: 10 * time.Minute,
},
}
}
func (c *GeminiClient) GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) {
model := c.Model
// 构建请求体
reqBody := GeminiTextRequest{
Contents: []GeminiContent{
{
Parts: []GeminiPart{{Text: prompt}},
Role: "user",
},
},
}
// 使用 systemInstruction 字段处理系统提示
if systemPrompt != "" {
reqBody.SystemInstruction = &GeminiInstruction{
Parts: []GeminiPart{{Text: systemPrompt}},
}
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
fmt.Printf("Gemini: Failed to marshal request: %v\n", err)
return "", fmt.Errorf("marshal request: %w", err)
}
// 替换端点中的 {model} 占位符
endpoint := c.BaseURL + c.Endpoint
endpoint = strings.ReplaceAll(endpoint, "{model}", model)
url := fmt.Sprintf("%s?key=%s", endpoint, c.APIKey)
// 打印请求信息(隐藏 API Key)
safeURL := strings.Replace(url, c.APIKey, "***", 1)
fmt.Printf("Gemini: Sending request to: %s\n", safeURL)
requestPreview := string(jsonData)
if len(jsonData) > 300 {
requestPreview = string(jsonData[:300]) + "..."
}
fmt.Printf("Gemini: Request body: %s\n", requestPreview)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("Gemini: Failed to create request: %v\n", err)
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
fmt.Printf("Gemini: Executing HTTP request...\n")
resp, err := c.HTTPClient.Do(req)
if err != nil {
fmt.Printf("Gemini: HTTP request failed: %v\n", err)
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
fmt.Printf("Gemini: Received response with status: %d\n", resp.StatusCode)
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Gemini: Failed to read response body: %v\n", err)
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
fmt.Printf("Gemini: API error (status %d): %s\n", resp.StatusCode, string(body))
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
// 打印响应体用于调试
bodyPreview := string(body)
if len(body) > 500 {
bodyPreview = string(body[:500]) + "..."
}
fmt.Printf("Gemini: Response body: %s\n", bodyPreview)
var result GeminiTextResponse
if err := json.Unmarshal(body, &result); err != nil {
errorPreview := string(body)
if len(body) > 200 {
errorPreview = string(body[:200])
}
fmt.Printf("Gemini: Failed to parse response: %v\n", err)
return "", fmt.Errorf("parse response: %w, body preview: %s", err, errorPreview)
}
fmt.Printf("Gemini: Successfully parsed response, candidates count: %d\n", len(result.Candidates))
if len(result.Candidates) == 0 {
fmt.Printf("Gemini: No candidates in response\n")
return "", fmt.Errorf("no candidates in response")
}
if len(result.Candidates[0].Content.Parts) == 0 {
fmt.Printf("Gemini: No parts in first candidate\n")
return "", fmt.Errorf("no parts in response")
}
responseText := result.Candidates[0].Content.Parts[0].Text
fmt.Printf("Gemini: Generated text: %s\n", responseText)
return responseText, nil
}
func (c *GeminiClient) GenerateImage(prompt string, size string, n int) ([]string, error) {
return nil, fmt.Errorf("GenerateImage not implemented for Gemini client")
}
func (c *GeminiClient) TestConnection() error {
fmt.Printf("Gemini: TestConnection called with BaseURL=%s, Model=%s, Endpoint=%s\n", c.BaseURL, c.Model, c.Endpoint)
_, err := c.GenerateText("Hello", "")
if err != nil {
fmt.Printf("Gemini: TestConnection failed: %v\n", err)
} else {
fmt.Printf("Gemini: TestConnection succeeded\n")
}
return err
}
================================================
FILE: pkg/ai/openai_client.go
================================================
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"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"`
MaxCompletionTokens *int `json:"max_completion_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 ImageGenerationRequest struct {
Model string `json:"model,omitempty"`
Prompt string `json:"prompt"`
N int `json:"n,omitempty"`
Size string `json:"size,omitempty"`
}
type ImageGenerationResponse struct {
Created int64 `json:"created"`
Data []struct {
URL string `json:"url"`
B64JSON string `json:"b64_json"`
} `json:"data"`
}
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) {
resp, err := c.doChatRequest(req)
if err == nil {
return resp, nil
}
if shouldRetryWithMaxCompletionTokens(err, req) {
tokens := *req.MaxTokens
retryReq := *req
retryReq.MaxTokens = nil
retryReq.MaxCompletionTokens = &tokens
fmt.Printf("OpenAI: retrying with max_completion_tokens=%d\n", tokens)
return c.doChatRequest(&retryReq)
}
return nil, err
}
func (c *OpenAIClient) doChatRequest(req *ChatCompletionRequest) (*ChatCompletionResponse, error) {
jsonData, err := json.Marshal(req)
if err != nil {
fmt.Printf("OpenAI: Failed to marshal request: %v\n", err)
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
url := c.BaseURL + c.Endpoint
// 打印请求信息
fmt.Printf("OpenAI: Sending request to: %s\n", url)
fmt.Printf("OpenAI: BaseURL=%s, Endpoint=%s, Model=%s\n", c.BaseURL, c.Endpoint, c.Model)
requestPreview := string(jsonData)
if len(jsonData) > 300 {
requestPreview = string(jsonData[:300]) + "..."
}
fmt.Printf("OpenAI: Request body: %s\n", requestPreview)
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("OpenAI: Failed to create request: %v\n", err)
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
fmt.Printf("OpenAI: Executing HTTP request...\n")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
fmt.Printf("OpenAI: HTTP request failed: %v\n", err)
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
fmt.Printf("OpenAI: Received response with status: %d\n", resp.StatusCode)
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("OpenAI: Failed to read response body: %v\n", err)
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
fmt.Printf("OpenAI: API error (status %d): %s\n", resp.StatusCode, string(body))
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err != nil {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
return nil, fmt.Errorf("API error: %s", errResp.Error.Message)
}
// 打印响应体用于调试
bodyPreview := string(body)
if len(body) > 500 {
bodyPreview = string(body[:500]) + "..."
}
fmt.Printf("OpenAI: Response body: %s\n", bodyPreview)
var chatResp ChatCompletionResponse
if err := json.Unmarshal(body, &chatResp); err != nil {
errorPreview := string(body)
if len(body) > 200 {
errorPreview = string(body[:200])
}
fmt.Printf("OpenAI: Failed to parse response: %v\n", err)
return nil, fmt.Errorf("failed to unmarshal response: %w, body preview: %s", err, errorPreview)
}
fmt.Printf("OpenAI: Successfully parsed response, choices count: %d\n", len(chatResp.Choices))
if len(chatResp.Choices) == 0 {
fmt.Printf("OpenAI: No choices in response\n")
return nil, fmt.Errorf("no choices in response")
}
// 检查 finish_reason,处理内容过滤的情况
if len(chatResp.Choices) > 0 {
finishReason := chatResp.Choices[0].FinishReason
content := chatResp.Choices[0].Message.Content
usage := chatResp.Usage
fmt.Printf("OpenAI: finish_reason=%s, content_length=%d\n", finishReason, len(content))
if finishReason == "content_filter" {
return nil, fmt.Errorf("AI内容被安全过滤器拦截,可能因为:\n1. 请求内容触发了安全策略\n2. 生成的内容包含敏感信息\n3. 建议:调整输入内容或联系API提供商调整过滤策略")
}
if usage.TotalTokens == 0 && finishReason != "stop" {
return nil, fmt.Errorf("AI返回内容为空 (finish_reason: %s),可能的原因:\n1. 内容被过滤\n2. Token限制\n3. API异常", finishReason)
}
}
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) GenerateImage(prompt string, size string, n int) ([]string, error) {
// 图片生成端点通常是 /v1/images/generations
// 如果 c.Endpoint 是 chat 端点,我们需要将其替换
// 这是一个简单的处理逻辑,实际可能需要更复杂的配置
imageEndpoint := "/v1/images/generations"
// 如果 BaseURL 是类似 api.openai.com,那么直接拼接
url := c.BaseURL + imageEndpoint
reqBody := ImageGenerationRequest{
Prompt: prompt,
N: n,
Size: size,
Model: c.Model, // 如果是DALL-E 3,模型名很重要
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, 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, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
return nil, fmt.Errorf("API error: %s", errResp.Error.Message)
}
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var imgResp ImageGenerationResponse
if err := json.Unmarshal(body, &imgResp); err != nil {
return nil, err
}
var urls []string
for _, data := range imgResp.Data {
if data.URL != "" {
urls = append(urls, data.URL)
} else if data.B64JSON != "" {
// 如果返回的是base64,添加前缀
urls = append(urls, "data:image/png;base64,"+data.B64JSON)
}
}
return urls, nil
}
func (c *OpenAIClient) TestConnection() error {
fmt.Printf("OpenAI: TestConnection called with BaseURL=%s, Endpoint=%s, Model=%s\n", c.BaseURL, c.Endpoint, c.Model)
messages := []ChatMessage{
{
Role: "user",
Content: "Hello",
},
}
_, err := c.ChatCompletion(messages, WithMaxTokens(50))
if err != nil {
fmt.Printf("OpenAI: TestConnection failed: %v\n", err)
} else {
fmt.Printf("OpenAI: TestConnection succeeded\n")
}
return err
}
func shouldRetryWithMaxCompletionTokens(err error, req *ChatCompletionRequest) bool {
if err == nil || req == nil || req.MaxTokens == nil || req.MaxCompletionTokens != nil {
return false
}
msg := err.Error()
if strings.Contains(msg, "Unsupported parameter: 'max_tokens'") {
return true
}
if strings.Contains(msg, "max_tokens is not supported") {
return true
}
if strings.Contains(msg, "max_completion_tokens") {
return true
}
return false
}
================================================
FILE: pkg/config/config.go
================================================
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"`
Language string `mapstructure:"language"` // zh 或 en
}
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,
)
}
================================================
FILE: pkg/image/gemini_image_client.go
================================================
package image
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type GeminiImageClient struct {
BaseURL string
APIKey string
Model string
Endpoint string
HTTPClient *http.Client
}
type GeminiImageRequest struct {
Contents []struct {
Parts []GeminiPart `json:"parts"`
} `json:"contents"`
GenerationConfig struct {
ResponseModalities []string `json:"responseModalities"`
} `json:"generationConfig"`
}
type GeminiPart struct {
Text string `json:"text,omitempty"`
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
}
type GeminiInlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"` // base64 编码的图片数据
}
type GeminiImageResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
InlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
} `json:"inlineData,omitempty"`
Text string `json:"text,omitempty"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
}
// downloadImageToBase64 下载图片 URL 并转换为 base64
func downloadImageToBase64(imageURL string) (string, string, error) {
resp, err := http.Get(imageURL)
if err != nil {
return "", "", fmt.Errorf("download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("download image failed with status: %d", resp.StatusCode)
}
imageData, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read image data: %w", err)
}
// 根据 Content-Type 确定 mimeType
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "image/jpeg"
}
base64Data := base64.StdEncoding.EncodeToString(imageData)
return base64Data, mimeType, nil
}
func NewGeminiImageClient(baseURL, apiKey, model, endpoint string) *GeminiImageClient {
if baseURL == "" {
baseURL = "https://generativelanguage.googleapis.com"
}
if endpoint == "" {
endpoint = "/v1beta/models/{model}:generateContent"
}
if model == "" {
model = "gemini-3-pro-image-preview"
}
return &GeminiImageClient{
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
Endpoint: endpoint,
HTTPClient: &http.Client{
Timeout: 10 * time.Minute,
},
}
}
func (c *GeminiImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
options := &ImageOptions{
Size: "1920x1920",
Quality: "standard",
}
for _, opt := range opts {
opt(options)
}
model := c.Model
if options.Model != "" {
model = options.Model
}
promptText := prompt
if options.NegativePrompt != "" {
promptText += fmt.Sprintf("\n\nNegative prompt: %s", options.NegativePrompt)
}
if options.Size != "" {
promptText += fmt.Sprintf("\n\nImage size: %s", options.Size)
}
// 构建请求的 parts,支持参考图
parts := []GeminiPart{}
// 如果有参考图,先添加参考图
if len(options.ReferenceImages) > 0 {
for _, refImg := range options.ReferenceImages {
var base64Data string
var mimeType string
var err error
// 检查是否是 HTTP/HTTPS URL
if strings.HasPrefix(refImg, "http://") || strings.HasPrefix(refImg, "https://") {
// 下载图片并转换为 base64
base64Data, mimeType, err = downloadImageToBase64(refImg)
if err != nil {
continue
}
} else if strings.HasPrefix(refImg, "data:") {
// 如果是 data URI 格式,需要解析
// 格式: data:image/jpeg;base64,xxxxx
mimeType = "image/jpeg"
parts := []byte(refImg)
for i := 0; i < len(parts); i++ {
if parts[i] == ',' {
base64Data = refImg[i+1:]
// 提取 mime type
if i > 11 {
mimeTypeEnd := i
for j := 5; j < i; j++ {
if parts[j] == ';' {
mimeTypeEnd = j
break
}
}
mimeType = refImg[5:mimeTypeEnd]
}
break
}
}
} else {
// 假设已经是 base64 编码
base64Data = refImg
mimeType = "image/jpeg"
}
if base64Data != "" {
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
}
}
}
// 添加文本提示词
parts = append(parts, GeminiPart{
Text: promptText,
})
reqBody := GeminiImageRequest{
Contents: []struct {
Parts []GeminiPart `json:"parts"`
}{
{
Parts: parts,
},
},
GenerationConfig: struct {
ResponseModalities []string `json:"responseModalities"`
}{
ResponseModalities: []string{"IMAGE"},
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
endpoint := c.BaseURL + c.Endpoint
endpoint = replaceModelPlaceholder(endpoint, model)
url := fmt.Sprintf("%s?key=%s", endpoint, c.APIKey)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
bodyStr := string(body)
if len(bodyStr) > 1000 {
bodyStr = fmt.Sprintf("%s ... %s", bodyStr[:500], bodyStr[len(bodyStr)-500:])
}
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, bodyStr)
}
var result GeminiImageResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
return nil, fmt.Errorf("no image generated in response")
}
base64Data := result.Candidates[0].Content.Parts[0].InlineData.Data
if base64Data == "" {
return nil, fmt.Errorf("no base64 image data in response")
}
dataURI := fmt.Sprintf("data:image/jpeg;base64,%s", base64Data)
return &ImageResult{
Status: "completed",
ImageURL: dataURI,
Completed: true,
Width: 1024,
Height: 1024,
}, nil
}
func (c *GeminiImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {
return nil, fmt.Errorf("not supported for Gemini (synchronous generation)")
}
func replaceModelPlaceholder(endpoint, model string) string {
result := endpoint
if bytes.Contains([]byte(result), []byte("{model}")) {
result = string(bytes.ReplaceAll([]byte(result), []byte("{model}"), []byte(model)))
}
return result
}
================================================
FILE: pkg/image/image_client.go
================================================
package image
type ImageClient interface {
GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error)
GetTaskStatus(taskID string) (*ImageResult, error)
}
type ImageResult struct {
TaskID string
Status string
ImageURL string
Width int
Height int
Error string
Completed bool
}
type ImageOptions struct {
NegativePrompt string
Size string
Quality string
Style string
Steps int
CfgScale float64
Seed int64
Model string
Width int
Height int
ReferenceImages []string // 参考图片URL列表
}
type ImageOption func(*ImageOptions)
func WithNegativePrompt(prompt string) ImageOption {
return func(o *ImageOptions) {
o.NegativePrompt = prompt
}
}
func WithSize(size string) ImageOption {
return func(o *ImageOptions) {
o.Size = size
}
}
func WithQuality(quality string) ImageOption {
return func(o *ImageOptions) {
o.Quality = quality
}
}
func WithStyle(style string) ImageOption {
return func(o *ImageOptions) {
o.Style = style
}
}
func WithSteps(steps int) ImageOption {
return func(o *ImageOptions) {
o.Steps = steps
}
}
func WithCfgScale(scale float64) ImageOption {
return func(o *ImageOptions) {
o.CfgScale = scale
}
}
func WithSeed(seed int64) ImageOption {
return func(o *ImageOptions) {
o.Seed = seed
}
}
func WithModel(model string) ImageOption {
return func(o *ImageOptions) {
o.Model = model
}
}
func WithDimensions(width, height int) ImageOption {
return func(o *ImageOptions) {
o.Width = width
o.Height = height
}
}
func WithReferenceImages(images []string) ImageOption {
return func(o *ImageOptions) {
o.ReferenceImages = images
}
}
================================================
FILE: pkg/image/openai_image_client.go
================================================
package image
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type OpenAIImageClient struct {
BaseURL string
APIKey string
Model string
Endpoint string
HTTPClient *http.Client
}
type DALLERequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Size string `json:"size,omitempty"`
Quality string `json:"quality,omitempty"`
N int `json:"n"`
Image []string `json:"image,omitempty"`
}
type DALLEResponse struct {
Created int64 `json:"created"`
Data []struct {
URL string `json:"url"`
RevisedPrompt string `json:"revised_prompt,omitempty"`
} `json:"data"`
}
func NewOpenAIImageClient(baseURL, apiKey, model, endpoint string) *OpenAIImageClient {
if endpoint == "" {
endpoint = "/v1/images/generations"
}
return &OpenAIImageClient{
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
Endpoint: endpoint,
HTTPClient: &http.Client{
Timeout: 10 * time.Minute,
},
}
}
func (c *OpenAIImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
options := &ImageOptions{
Size: "1920x1920",
Quality: "standard",
}
for _, opt := range opts {
opt(options)
}
model := c.Model
if options.Model != "" {
model = options.Model
}
reqBody := DALLERequest{
Model: model,
Prompt: prompt,
Size: options.Size,
Quality: options.Quality,
N: 1,
Image: options.ReferenceImages,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
url := c.BaseURL + c.Endpoint
fmt.Printf("[OpenAI Image] Request URL: %s\n", url)
fmt.Printf("[OpenAI Image] Request Body: %s\n", string(jsonData))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
fmt.Printf("OpenAI API Response: %s\n", string(body))
var result DALLEResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body))
}
if len(result.Data) == 0 {
return nil, fmt.Errorf("no image generated, response: %s", string(body))
}
return &ImageResult{
Status: "completed",
ImageURL: result.Data[0].URL,
Completed: true,
}, nil
}
func (c *OpenAIImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {
return nil, fmt.Errorf("not supported for OpenAI/DALL-E")
}
================================================
FILE: pkg/image/volcengine_image_client.go
================================================
package image
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type VolcEngineImageClient struct {
BaseURL string
APIKey string
Model string
Endpoint string
QueryEndpoint string
HTTPClient *http.Client
}
type VolcEngineImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Image []string `json:"image,omitempty"`
SequentialImageGeneration string `json:"sequential_image_generation,omitempty"`
Size string `json:"size,omitempty"`
Watermark bool `json:"watermark,omitempty"`
}
type VolcEngineImageResponse struct {
Model string `json:"model"`
Created int64 `json:"created"`
Data []struct {
URL string `json:"url"`
Size string `json:"size"`
} `json:"data"`
Usage struct {
GeneratedImages int `json:"generated_images"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Error interface{} `json:"error,omitempty"`
}
func NewVolcEngineImageClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcEngineImageClient {
if endpoint == "" {
endpoint = "/api/v3/images/generations"
}
if queryEndpoint == "" {
queryEndpoint = endpoint
}
return &VolcEngineImageClient{
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
Endpoint: endpoint,
QueryEndpoint: queryEndpoint,
HTTPClient: &http.Client{
Timeout: 10 * time.Minute,
},
}
}
func (c *VolcEngineImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
options := &ImageOptions{
Size: "1920x1920",
Quality: "standard",
}
for _, opt := range opts {
opt(options)
}
model := c.Model
if options.Model != "" {
model = options.Model
}
promptText := prompt
if options.NegativePrompt != "" {
promptText += fmt.Sprintf(". Negative: %s", options.NegativePrompt)
}
size := options.Size
if size == "" {
if model == "doubao-seedream-4-5-251128" {
size = "2K"
} else {
size = "1K"
}
}
reqBody := VolcEngineImageRequest{
Model: model,
Prompt: promptText,
Image: options.ReferenceImages,
SequentialImageGeneration: "disabled",
Size: size,
Watermark: false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
url := c.BaseURL + c.Endpoint
fmt.Printf("[VolcEngine Image] Request URL: %s\n", url)
fmt.Printf("[VolcEngine Image] Request Body: %s\n", string(jsonData))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
fmt.Printf("VolcEngine Image API Response: %s\n", string(body))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var result VolcEngineImageResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if result.Error != nil {
return nil, fmt.Errorf("volcengine error: %v", result.Error)
}
if len(result.Data) == 0 {
return nil, fmt.Errorf("no image generated")
}
return &ImageResult{
Status: "completed",
ImageURL: result.Data[0].URL,
Completed: true,
}, nil
}
func (c *VolcEngineImageClient) GetTaskStatus(taskID string) (*ImageResult, error) {
return nil, fmt.Errorf("not supported for VolcEngine Seedream (synchronous generation)")
}
================================================
FILE: pkg/logger/logger.go
================================================
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(),
}
}
================================================
FILE: pkg/response/response.go
================================================
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)
}
================================================
FILE: pkg/utils/image_utils.go
================================================
package utils
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"strings"
)
// ImageToBase64 将图片转换为 base64 编码
// 支持本地文件路径和 HTTP/HTTPS URL
func ImageToBase64(imagePath string) (string, error) {
var data []byte
var err error
if strings.HasPrefix(imagePath, "http://") || strings.HasPrefix(imagePath, "https://") {
// 从 URL 下载图片
data, err = downloadImageFromURL(imagePath)
if err != nil {
return "", fmt.Errorf("failed to download image from URL: %w", err)
}
} else {
// 从本地文件读取
data, err = os.ReadFile(imagePath)
if err != nil {
return "", fmt.Errorf("failed to read local image file: %w", err)
}
}
// 转换为 base64
base64Str := base64.StdEncoding.EncodeToString(data)
// 检测 MIME 类型
mimeType := detectImageMimeType(data)
// 返回 data URI 格式
return fmt.Sprintf("data:%s;base64,%s", mimeType, base64Str), nil
}
// downloadImageFromURL 从 URL 下载图片数据
func downloadImageFromURL(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP error: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// detectImageMimeType 检测图片的 MIME 类型
func detectImageMimeType(data []byte) string {
if len(data) < 12 {
return "image/jpeg" // 默认
}
// PNG: 89 50 4E 47
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
// JPEG: FF D8 FF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// GIF: 47 49 46
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {
return "image/gif"
}
// WebP: 52 49 46 46 ... 57 45 42 50
if data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
return "image/webp"
}
return "image/jpeg" // 默认
}
================================================
FILE: pkg/utils/json_parser.go
================================================
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)
// 移除开头的 ```json 或 ```
cleaned = regexp.MustCompile("(?m)^```json\\s*").ReplaceAllString(cleaned, "")
cleaned = regexp.MustCompile("(?m)^```\\s*").ReplaceAllString(cleaned, "")
// 移除结尾的 ```
cleaned = regexp.MustCompile("(?m)```\\s*$").ReplaceAllString(cleaned, "")
cleaned = strings.TrimSpace(cleaned)
// 2. 提取JSON (支持对象 {} 和数组 [])
var jsonMatch string
// 优先尝试提取完整的JSON(对象或数组)
// 先尝试对象格式
if strings.HasPrefix(cleaned, "{") {
jsonRegex := regexp.MustCompile(`(?s)\{.*\}`)
jsonMatch = jsonRegex.FindString(cleaned)
}
// 如果没找到对象,尝试数组格式
if jsonMatch == "" && strings.HasPrefix(cleaned, "[") {
jsonRegex := regexp.MustCompile(`(?s)\[.*\]`)
jsonMatch = jsonRegex.FindString(cleaned)
}
// 如果还是没找到,尝试从中间提取
if jsonMatch == "" {
// 尝试对象
objRegex := regexp.MustCompile(`(?s)\{.*\}`)
jsonMatch = objRegex.FindString(cleaned)
// 如果对象没找到,尝试数组
if jsonMatch == "" {
arrRegex := regexp.MustCompile(`(?s)\[.*\]`)
jsonMatch = arrRegex.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 isTruncated(jsonMatch) {
return fmt.Errorf(
"AI响应可能被截断,导致JSON不完整。\n请尝试:\n1. 增加maxTokens参数\n2. 简化输入内容\n3. 使用更强大的模型\n\n原始错误: %s\n响应长度: %d\n响应末尾: %s",
err.Error(),
len(jsonMatch),
truncateString(jsonMatch[maxInt(0, len(jsonMatch)-200):], 200),
)
}
// 6. 提供详细的错误上下文
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. 处理多余的闭合括号(从末尾移除)
// 这是 AI 生成 JSON 时常见的问题
for closeBrackets > openBrackets && len(trimmed) > 0 {
// 从末尾向前查找多余的 ]
lastIdx := strings.LastIndex(trimmed, "]")
if lastIdx >= 0 {
trimmed = trimmed[:lastIdx] + trimmed[lastIdx+1:]
closeBrackets--
} else {
break
}
}
for closeBraces > openBraces && len(trimmed) > 0 {
// 从末尾向前查找多余的 }
lastIdx := strings.LastIndex(trimmed, "}")
if lastIdx >= 0 {
trimmed = trimmed[:lastIdx] + trimmed[lastIdx+1:]
closeBraces--
} else {
break
}
}
// 重新统计括号(因为可能已修改)
openBraces = strings.Count(trimmed, "{")
closeBraces = strings.Count(trimmed, "}")
openBrackets = strings.Count(trimmed, "[")
closeBrackets = strings.Count(trimmed, "]")
// 5. 补全未闭合的数组
for i := 0; i < openBrackets-closeBrackets; i++ {
trimmed += "]"
}
// 6. 补全未闭合的对象
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)
}
// isTruncated 检测JSON字符串是否可能被截断
func isTruncated(jsonStr string) bool {
trimmed := strings.TrimSpace(jsonStr)
if len(trimmed) == 0 {
return false
}
// 检查是否以不完整的字符串结尾(引号未闭合)
lastChar := trimmed[len(trimmed)-1]
if lastChar != '}' && lastChar != ']' {
return true
}
// 检查括号是否匹配
openBraces := strings.Count(trimmed, "{")
closeBraces := strings.Count(trimmed, "}")
openBrackets := strings.Count(trimmed, "[")
closeBrackets := strings.Count(trimmed, "]")
if openBraces != closeBraces || openBrackets != closeBrackets {
return true
}
// 检查引号是否匹配(简化检查,不考虑转义)
quoteCount := strings.Count(trimmed, `"`)
if quoteCount%2 != 0 {
return true
}
return false
}
// 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
}
================================================
FILE: pkg/utils/json_parser_test.go
================================================
package utils
import (
"encoding/json"
"testing"
)
// TestAttemptJSONRepairExcessBraces tests fixing JSON with excess closing braces
// This is the fix for issue #28: AI sometimes returns JSON with extra closing braces
func TestAttemptJSONRepairExcessBraces(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{
name: "normal JSON",
input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]}`,
wantErr: false,
},
{
name: "extra closing brace - issue #28 case",
input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]}}`,
wantErr: false,
},
{
name: "extra closing bracket",
input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]]}`,
wantErr: false,
},
{
name: "multiple extra closing braces",
input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]}}}`,
wantErr: false,
},
{
name: "missing closing brace",
input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]`,
wantErr: false,
},
{
name: "missing closing bracket",
input: `{"backgrounds": [{"location": "test", "prompt": "hello"}`,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result struct {
Backgrounds []struct {
Location string `json:"location"`
Prompt string `json:"prompt"`
} `json:"backgrounds"`
}
err := SafeParseAIJSON(tt.input, &result)
if (err != nil) != tt.wantErr {
t.Errorf("SafeParseAIJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
// Verify the parsed result
if len(result.Backgrounds) != 1 {
t.Errorf("Expected 1 background, got %d", len(result.Backgrounds))
return
}
if result.Backgrounds[0].Location != "test" {
t.Errorf("Expected location 'test', got '%s'", result.Backgrounds[0].Location)
}
if result.Backgrounds[0].Prompt != "hello" {
t.Errorf("Expected prompt 'hello', got '%s'", result.Backgrounds[0].Prompt)
}
}
})
}
}
// TestAttemptJSONRepairFunction tests the attemptJSONRepair function directly
func TestAttemptJSONRepairFunction(t *testing.T) {
tests := []struct {
name string
input string
valid bool
}{
{
name: "fix extra closing brace",
input: `{"key": "value"}}`,
valid: true,
},
{
name: "fix extra closing bracket",
input: `["item1", "item2"]]`,
valid: true,
},
{
name: "fix missing closing brace",
input: `{"key": "value"`,
valid: true,
},
{
name: "fix missing closing bracket",
input: `["item1", "item2"`,
valid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repaired := attemptJSONRepair(tt.input)
var js json.RawMessage
err := json.Unmarshal([]byte(repaired), &js)
if tt.valid && err != nil {
t.Errorf("attemptJSONRepair() failed to produce valid JSON: %v\nInput: %s\nOutput: %s", err, tt.input, repaired)
}
})
}
}
================================================
FILE: pkg/video/chatfire_client.go
================================================
package video
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ChatfireClient Chatfire 视频生成客户端
type ChatfireClient struct {
BaseURL string
APIKey string
Model string
Endpoint string
QueryEndpoint string
HTTPClient *http.Client
}
type ChatfireRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
ImageURL string `json:"image_url,omitempty"`
Duration int `json:"duration,omitempty"`
Size string `json:"size,omitempty"`
}
// ChatfireSoraRequest Sora 模型请求格式
type ChatfireSoraRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Seconds string `json:"seconds,omitempty"`
Size string `json:"size,omitempty"`
InputReference string `json:"input_reference,omitempty"`
}
// ChatfireDoubaoRequest 豆包/火山模型请求格式
type ChatfireDoubaoRequest struct {
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
} `json:"content"`
}
type ChatfireResponse struct {
ID string `json:"id"`
TaskID string `json:"task_id,omitempty"`
Status string `json:"status,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
Data struct {
ID string `json:"id,omitempty"`
Status string `json:"status,omitempty"`
VideoURL string `json:"video_url,omitempty"`
} `json:"data,omitempty"`
}
type ChatfireTaskResponse struct {
ID string `json:"id,omitempty"`
TaskID string `json:"task_id,omitempty"`
Status string `json:"status,omitempty"`
VideoURL string `json:"video_url,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
Data struct {
ID string `json:"id,omitempty"`
Status string `json:"status,omitempty"`
VideoURL string `json:"video_url,omitempty"`
} `json:"data,omitempty"`
Content struct {
VideoURL string `json:"video_url,omitempty"`
} `json:"content,omitempty"`
}
// getErrorMessage 从 error 字段提取错误信息(支持字符串或对象)
func getErrorMessage(errorData json.RawMessage) string {
if len(errorData) == 0 {
return ""
}
// 尝试解析为字符串
var errStr string
if err := json.Unmarshal(errorData, &errStr); err == nil {
return errStr
}
// 尝试解析为对象
var errObj struct {
Message string `json:"message"`
Code string `json:"code"`
}
if err := json.Unmarshal(errorData, &errObj); err == nil {
if errObj.Message != "" {
return errObj.Message
}
}
// 返回原始 JSON 字符串
return string(errorData)
}
func NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *ChatfireClient {
if endpoint == "" {
endpoint = "/video/generations"
}
if queryEndpoint == "" {
queryEndpoint = "/video/task/{taskId}"
}
return &ChatfireClient{
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
Endpoint: endpoint,
QueryEndpoint: queryEndpoint,
HTTPClient: &http.Client{
Timeout: 300 * time.Second,
},
}
}
func (c *ChatfireClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
options := &VideoOptions{
Duration: 5,
AspectRatio: "16:9",
}
for _, opt := range opts {
opt(options)
}
model := c.Model
if options.Model != "" {
model = options.Model
}
// 根据模型名称选择请求格式
var jsonData []byte
var err error
if strings.Contains(model, "doubao") || strings.Contains(model, "seedance") {
// 豆包/火山格式
reqBody := ChatfireDoubaoRequest{
Model: model,
}
// 构建prompt文本(包含duration和ratio参数)
promptText := prompt
if options.AspectRatio != "" {
promptText += fmt.Sprintf(" --ratio %s", options.AspectRatio)
}
if options.Duration > 0 {
promptText += fmt.Sprintf(" --dur %d", options.Duration)
}
// 添加文本内容
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{Type: "text", Text: promptText})
// 处理不同的图片模式
// 1. 组图模式(多个reference_image)
if len(options.ReferenceImageURLs) > 0 {
for _, refURL := range options.ReferenceImageURLs {
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": refURL,
},
Role: "reference_image",
})
}
} else if options.FirstFrameURL != "" && options.LastFrameURL != "" {
// 2. 首尾帧模式
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.FirstFrameURL,
},
Role: "first_frame",
})
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.LastFrameURL,
},
Role: "last_frame",
})
} else if imageURL != "" {
// 3. 单图模式(默认)
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": imageURL,
},
// 单图模式不需要role
})
} else if options.FirstFrameURL != "" {
// 4. 只有首帧
reqBody.Content = append(reqBody.Content, struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.FirstFrameURL,
},
Role: "first_frame",
})
}
jsonData, err = json.Marshal(reqBody)
} else if strings.Contains(model, "sora") {
// Sora 格式
seconds := fmt.Sprintf("%d", options.Duration)
size := options.AspectRatio
if size == "16:9" {
size = "1280x720"
} else if size == "9:16" {
size = "720x1280"
}
reqBody := ChatfireSoraRequest{
Model: model,
Prompt: prompt,
Seconds: seconds,
Size: size,
InputReference: imageURL,
}
jsonData, err = json.Marshal(reqBody)
} else {
// 默认格式
reqBody := ChatfireRequest{
Model: model,
Prompt: prompt,
ImageURL: imageURL,
Duration: options.Duration,
Size: options.AspectRatio,
}
jsonData, err = json.Marshal(reqBody)
}
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
endpoint := c.BaseURL + c.Endpoint
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
// 调试日志:打印响应内容
fmt.Printf("[Chatfire] Response body: %s\n", string(body))
var result ChatfireResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body))
}
// 优先使用 id 字段,其次使用 task_id
taskID := result.ID
if taskID == "" {
taskID = result.TaskID
}
// 如果有 data 嵌套,优先使用 data 中的值
if result.Data.ID != "" {
taskID = result.Data.ID
}
status := result.Status
if status == "" && result.Data.Status != "" {
status = result.Data.Status
}
fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s\n", taskID, status)
if errMsg := getErrorMessage(result.Error); errMsg != "" {
return nil, fmt.Errorf("chatfire error: %s", errMsg)
}
videoResult := &VideoResult{
TaskID: taskID,
Status: status,
Completed: status == "completed" || status == "succeeded",
Duration: options.Duration,
}
return videoResult, nil
}
func (c *ChatfireClient) GetTaskStatus(taskID string) (*VideoResult, error) {
queryPath := c.QueryEndpoint
if strings.Contains(queryPath, "{taskId}") {
queryPath = strings.ReplaceAll(queryPath, "{taskId}", taskID)
} else if strings.Contains(queryPath, "{task_id}") {
queryPath = strings.ReplaceAll(queryPath, "{task_id}", taskID)
} else {
queryPath = queryPath + "/" + taskID
}
endpoint := c.BaseURL + queryPath
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
// 调试日志:打印响应内容
fmt.Printf("[Chatfire] GetTaskStatus Response body: %s\n", string(body))
var result ChatfireTaskResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body))
}
// 优先使用 id 字段,其次使用 task_id
responseTaskID := result.ID
if responseTaskID == "" {
responseTaskID = result.TaskID
}
// 如果有 data 嵌套,优先使用 data 中的值
if result.Data.ID != "" {
responseTaskID = result.Data.ID
}
status := result.Status
if status == "" && result.Data.Status != "" {
status = result.Data.Status
}
// 按优先级获取 video_url:VideoURL -> Data.VideoURL -> Content.VideoURL
videoURL := result.VideoURL
if videoURL == "" && result.Data.VideoURL != "" {
videoURL = result.Data.VideoURL
}
if videoURL == "" && result.Content.VideoURL != "" {
videoURL = result.Content.VideoURL
}
fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s, VideoURL: %s\n", responseTaskID, status, videoURL)
videoResult := &VideoResult{
TaskID: responseTaskID,
Status: status,
Completed: status == "completed" || status == "succeeded",
}
if errMsg := getErrorMessage(result.Error); errMsg != "" {
videoResult.Error = errMsg
}
if videoURL != "" {
videoResult.VideoURL = videoURL
videoResult.Completed = true
}
return videoResult, nil
}
================================================
FILE: pkg/video/minimax_client.go
================================================
package video
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// MiniMax Hailuo 支持的模型
const (
// ModelHailuo23 全新视频生成模型,肢体动作、面部表情、物理表现与指令遵循再度突破
// 支持:文生视频、图生视频
// 时长:768P(6s/10s), 1080P(6s)
ModelHailuo23 = "MiniMax-Hailuo-2.3"
// ModelHailuo23Fast 全新图生视频模型,物理表现与指令遵循具佳,更快更优惠
// 支持:图生视频
// 时长:768P(6s/10s), 1080P(6s)
ModelHailuo23Fast = "MiniMax-Hailuo-2.3-Fast"
// ModelHailuo02 新一代视频生成模型,1080p 原生,SOTA 指令遵循,极致物理表现
// 支持:文生视频、图生视频、首尾帧模式
// 时长:768P(6s/10s), 1080P(6s)
ModelHailuo02 = "MiniMax-Hailuo-02"
)
// MiniMax Hailuo 支持的分辨率
const (
Resolution768P = "768P"
Resolution1080P = "1080P"
)
// MiniMax Hailuo 支持的时长(秒)
const (
Duration6s = 6
Duration10s = 10
)
// 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"`
}
// MinimaxCreateResponse 创建任务的响应
type MinimaxCreateResponse struct {
TaskID string `json:"task_id"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
// MinimaxQueryResponse 查询任务状态的响应
type MinimaxQueryResponse struct {
TaskID string `json:"task_id"`
Status string `json:"status"` // Processing, Success, Failed
FileID string `json:"file_id"`
VideoWidth int `json:"video_width"`
VideoHeight int `json:"video_height"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
// MinimaxFileResponse 获取文件信息的响应
type MinimaxFileResponse struct {
File struct {
FileID interface{} `json:"file_id"` // 可能是 string 或 number
Bytes int `json:"bytes"`
CreatedAt int64 `json:"created_at"`
Filename string `json:"filename"`
Purpose string `json:"purpose"`
DownloadURL string `json:"download_url"`
} `json:"file"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
func NewMinimaxClient(baseURL, apiKey, model string) *MinimaxClient {
return &MinimaxClient{
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
HTTPClient: &http.Client{
Timeout: 300 * time.Second,
},
}
}
// GenerateVideo 生成视频(支持首尾帧和主体参考)
// 步骤1:创建任务,返回 task_id
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
}
// 支持首帧图片
if options.FirstFrameURL != "" {
reqBody.FirstFrameImage = options.FirstFrameURL
} else if imageURL != "" {
reqBody.FirstFrameImage = imageURL
}
// 支持尾帧图片
if options.LastFrameURL != "" {
reqBody.LastFrameImage = options.LastFrameURL
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
// 步骤1:创建任务,POST 请求
// 注意:BaseURL 应该已包含 /v1,例如 https://api.minimaxi.com/v1
endpoint := c.BaseURL + "/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 MinimaxCreateResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if result.BaseResp.StatusCode != 0 {
return nil, fmt.Errorf("minimax error: %s", result.BaseResp.StatusMsg)
}
// 第一步只返回 task_id,状态为 Processing
videoResult := &VideoResult{
TaskID: result.TaskID,
Status: "Processing",
Completed: false,
}
return videoResult, nil
}
// GetTaskStatus 查询任务状态
// 步骤2:查询任务状态,如果成功则进入步骤3获取文件下载地址
func (c *MinimaxClient) GetTaskStatus(taskID string) (*VideoResult, error) {
// 步骤2:查询任务状态
// 注意:BaseURL 应该已包含 /v1
endpoint := fmt.Sprintf("%s/query/video_generation?task_id=%s", c.BaseURL, 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)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var queryResult MinimaxQueryResponse
if err := json.Unmarshal(body, &queryResult); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if queryResult.BaseResp.StatusCode != 0 {
return nil, fmt.Errorf("minimax error: %s", queryResult.BaseResp.StatusMsg)
}
videoResult := &VideoResult{
TaskID: queryResult.TaskID,
Status: queryResult.Status,
Width: queryResult.VideoWidth,
Height: queryResult.VideoHeight,
Completed: false,
}
// 如果状态是 Success 且有 file_id,则获取文件下载地址
if queryResult.Status == "Success" && queryResult.FileID != "" {
downloadURL, err := c.getFileDownloadURL(queryResult.FileID)
if err != nil {
return nil, fmt.Errorf("failed to get download URL: %w", err)
}
videoResult.VideoURL = downloadURL
videoResult.Completed = true
} else if queryResult.Status == "Failed" {
videoResult.Error = "Video generation failed"
videoResult.Completed = true
}
return videoResult, nil
}
// getFileDownloadURL 步骤3:根据 file_id 获取文件下载地址
func (c *MinimaxClient) getFileDownloadURL(fileID string) (string, error) {
// 注意:BaseURL 应该已包含 /v1
endpoint := fmt.Sprintf("%s/files/retrieve?file_id=%s", c.BaseURL, fileID)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var fileResult MinimaxFileResponse
if err := json.Unmarshal(body, &fileResult); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if fileResult.BaseResp.StatusCode != 0 {
return "", fmt.Errorf("minimax error: %s", fileResult.BaseResp.StatusMsg)
}
return fileResult.File.DownloadURL, nil
}
================================================
FILE: pkg/video/openai_sora_client.go
================================================
package video
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto" // Added for explicit MIME header control
"path/filepath"
"strings"
"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)
// Add basic fields
writer.WriteField("model", model)
writer.WriteField("prompt", prompt)
if options.Duration > 0 {
writer.WriteField("seconds", fmt.Sprintf("%d", options.Duration))
}
if options.Resolution != "" {
writer.WriteField("size", options.Resolution)
}
// [PR FIX START]
// The OpenAI Sora API requires 'input_reference' to be a file upload (binary), not a URL string
// set the Content-Type header (e.g., image/png) or the API returns 400
if imageURL != "" {
var imageData []byte
var mimeType string
var filename string = "reference_image.png"
if strings.HasPrefix(imageURL, "data:") {
// Case A: Handle Base64 Data URI (often stored in DB)
parts := strings.Split(imageURL, ",")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid data URI format")
}
// Extract mime type from header (e.g., "data:image/jpeg;base64")
header := parts[0]
if strings.Contains(header, "image/jpeg") || strings.Contains(header, "image/jpg") {
mimeType = "image/jpeg"
filename = "reference.jpg"
} else if strings.Contains(header, "image/png") {
mimeType = "image/png"
filename = "reference.png"
} else if strings.Contains(header, "image/webp") {
mimeType = "image/webp"
filename = "reference.webp"
} else {
mimeType = "image/png" // Default fallback
}
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode base64 image: %w", err)
}
imageData = decoded
} else {
// Case B: Handle Standard HTTP/HTTPS URL
resp, err := http.Get(imageURL)
if err != nil {
return nil, fmt.Errorf("failed to download reference image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download reference image, status: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read downloaded image: %w", err)
}
imageData = data
// Use the Content-Type header from the response
mimeType = resp.Header.Get("Content-Type")
// Fallback/Correction if server sends bad headers
if mimeType == "" || mimeType == "application/octet-stream" {
ext := filepath.Ext(imageURL)
switch strings.ToLower(ext) {
case ".jpg", ".jpeg":
mimeType = "image/jpeg"
case ".png":
mimeType = "image/png"
case ".webp":
mimeType = "image/webp"
default:
mimeType = "image/png"
}
}
// Ensure filename has extension
base := filepath.Base(imageURL)
if base != "" && base != "." {
if idx := strings.Index(base, "?"); idx != -1 {
base = base[:idx]
}
filename = base
}
}
// Create the MIME Header manually to force the Content-Type.
// Standard writer.CreateFormFile does not set Content-Type, causing "unsupported mimetype" errors.
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="input_reference"; filename="%s"`, filename))
h.Set("Content-Type", mimeType)
part, err := writer.CreatePart(h)
if err != nil {
return nil, fmt.Errorf("create part: %w", err)
}
if _, err := part.Write(imageData); err != nil {
return nil, fmt.Errorf("write image data: %w", err)
}
}
// [PR FIX END]
writer.Close()
endpoint := c.BaseURL + "/videos"
req, err := http.NewRequest("POST", endpoint, body)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
}
var result OpenAISoraResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if result.Error.Message != "" {
return nil, fmt.Errorf("openai error: %s", result.Error.Message)
}
videoResult := &VideoResult{
TaskID: result.ID,
Status: result.Status,
Completed: result.Status == "completed",
}
// 优先使用video_url字段,兼容video.url嵌套结构
if result.VideoURL != "" {
videoResult.VideoURL = result.VideoURL
} else if result.Video.URL != "" {
videoResult.VideoURL = result.Video.URL
}
return videoResult, nil
}
func (c *OpenAISoraClient) GetTaskStatus(taskID string) (*VideoResult, error) {
endpoint := c.BaseURL + "/videos/" + taskID
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result OpenAISoraResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
videoResult := &VideoResult{
TaskID: result.ID,
Status: result.Status,
Completed: result.Status == "completed",
}
if result.Error.Message != "" {
videoResult.Error = result.Error.Message
}
// 优先使用video_url字段,兼容video.url嵌套结构
if result.VideoURL != "" {
videoResult.VideoURL = result.VideoURL
} else if result.Video.URL != "" {
videoResult.VideoURL = result.Video.URL
}
return videoResult, nil
}
================================================
FILE: pkg/video/video_client.go
================================================
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
}
================================================
FILE: pkg/video/volces_ark_client.go
================================================
package video
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// VolcesArkClient 火山引擎ARK视频生成客户端
type VolcesArkClient struct {
BaseURL string
APIKey string
Model string
Endpoint string
QueryEndpoint string
HTTPClient *http.Client
}
type VolcesArkContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL map[string]interface{} `json:"image_url,omitempty"`
Role string `json:"role,omitempty"`
}
type VolcesArkRequest struct {
Model string `json:"model"`
Content []VolcesArkContent `json:"content"`
GenerateAudio bool `json:"generate_audio,omitempty"`
}
type VolcesArkResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
Content struct {
VideoURL string `json:"video_url"`
} `json:"content"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
Seed int `json:"seed"`
Resolution string `json:"resolution"`
Ratio string `json:"ratio"`
Duration int `json:"duration"`
FramesPerSecond int `json:"framespersecond"`
ServiceTier string `json:"service_tier"`
ExecutionExpiresAfter int `json:"execution_expires_after"`
GenerateAudio bool `json:"generate_audio"`
Error interface{} `json:"error,omitempty"`
}
func NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcesArkClient {
if endpoint == "" {
endpoint = "/api/v3/contents/generations/tasks"
}
if queryEndpoint == "" {
queryEndpoint = endpoint
}
return &VolcesArkClient{
BaseURL: baseURL,
APIKey: apiKey,
Model: model,
Endpoint: endpoint,
QueryEndpoint: queryEndpoint,
HTTPClient: &http.Client{
Timeout: 300 * time.Second,
},
}
}
// GenerateVideo 生成视频(支持首帧、首尾帧、参考图等多种模式)
func (c *VolcesArkClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) {
options := &VideoOptions{
Duration: 5,
AspectRatio: "adaptive",
}
for _, opt := range opts {
opt(options)
}
model := c.Model
if options.Model != "" {
model = options.Model
}
// 构建prompt文本(包含duration和ratio参数)
promptText := prompt
if options.AspectRatio != "" {
promptText += fmt.Sprintf(" --ratio %s", options.AspectRatio)
}
if options.Duration > 0 {
promptText += fmt.Sprintf(" --dur %d", options.Duration)
}
content := []VolcesArkContent{
{
Type: "text",
Text: promptText,
},
}
// 处理不同的图片模式
// 1. 组图模式(多个reference_image)
if len(options.ReferenceImageURLs) > 0 {
for _, refURL := range options.ReferenceImageURLs {
content = append(content, VolcesArkContent{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": refURL,
},
Role: "reference_image",
})
}
} else if options.FirstFrameURL != "" && options.LastFrameURL != "" {
// 2. 首尾帧模式
content = append(content, VolcesArkContent{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.FirstFrameURL,
},
Role: "first_frame",
})
content = append(content, VolcesArkContent{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.LastFrameURL,
},
Role: "last_frame",
})
} else if imageURL != "" {
// 3. 单图模式(默认)
content = append(content, VolcesArkContent{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": imageURL,
},
// 单图模式不需要role
})
} else if options.FirstFrameURL != "" {
// 4. 只有首帧
content = append(content, VolcesArkContent{
Type: "image_url",
ImageURL: map[string]interface{}{
"url": options.FirstFrameURL,
},
Role: "first_frame",
})
}
// 只有 seedance-1-5-pro 模型支持 generate_audio 参数
generateAudio := false
if strings.Contains(strings.ToLower(model), "seedance-1-5-pro") {
generateAudio = true
}
reqBody := VolcesArkRequest{
Model: model,
Content: content,
GenerateAudio: generateAudio,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
endpoint := c.BaseURL + c.Endpoint
fmt.Printf("[VolcesARK] Generating video - Endpoint: %s, FullURL: %s, Model: %s\n", c.Endpoint, endpoint, model)
fmt.Printf("[VolcesARK] Request body: %s\n", string(jsonData))
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
fmt.Printf("[VolcesARK] Response status: %d, body: %s\n", resp.StatusCode, string(body))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var result VolcesArkResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
fmt.Printf("[VolcesARK] Video generation initiated - TaskID: %s, Status: %s\n", result.ID, result.Status)
if result.Error != nil {
errorMsg := fmt.Sprintf("%v", result.Error)
return nil, fmt.Errorf("volces error: %s", errorMsg)
}
videoResult := &VideoResult{
TaskID: result.ID,
Status: result.Status,
Completed: result.Status == "completed" || result.Status == "succeeded",
Duration: result.Duration,
}
if result.Content.VideoURL != "" {
videoResult.VideoURL = result.Content.VideoURL
videoResult.Completed = true
}
return videoResult, nil
}
func (c *VolcesArkClient) GetTaskStatus(taskID string) (*VideoResult, error) {
// 替换占位符{taskId}、{task_id}或直接拼接
queryPath := c.QueryEndpoint
if strings.Contains(queryPath, "{taskId}") {
queryPath = strings.ReplaceAll(queryPath, "{taskId}", taskID)
} else if strings.Contains(queryPath, "{task_id}") {
queryPath = strings.ReplaceAll(queryPath, "{task_id}", taskID)
} else {
queryPath = queryPath + "/" + taskID
}
endpoint := c.BaseURL + queryPath
fmt.Printf("[VolcesARK] Querying task status - TaskID: %s, QueryEndpoint: %s, FullURL: %s\n", taskID, c.QueryEndpoint, endpoint)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
fmt.Printf("[VolcesARK] Response body: %s\n", string(body))
var result VolcesArkResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
fmt.Printf("[VolcesARK] Parsed result - ID: %s, Status: %s, VideoURL: %s\n", result.ID, result.Status, result.Content.VideoURL)
videoResult := &VideoResult{
TaskID: result.ID,
Status: result.Status,
Completed: result.Status == "completed" || result.Status == "succeeded",
Duration: result.Duration,
}
if result.Error != nil {
videoResult.Error = fmt.Sprintf("%v", result.Error)
}
if result.Content.VideoURL != "" {
videoResult.VideoURL = result.Content.VideoURL
videoResult.Completed = true
}
return videoResult, nil
}
================================================
FILE: web/.gitignore
================================================
# 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
================================================
FILE: web/index.html
================================================
Drama Generator - AI 短剧生成平台
================================================
FILE: web/nginx.conf
================================================
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;
}
================================================
FILE: web/package.json
================================================
{
"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",
"cropperjs": "^2.1.0",
"dayjs": "^1.11.10",
"element-plus": "^2.5.0",
"lodash-es": "^4.17.22",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-i18n": "^9.14.5",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vue/tsconfig": "^0.5.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"sass-embedded": "^1.97.1",
"tailwindcss": "^4.1.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.2.12"
}
}
================================================
FILE: web/public/ffmpeg/ffmpeg-core.js
================================================
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;
================================================
FILE: web/public/ffmpeg/ffmpeg-core.wasm
================================================
[File too large to display: 30.7 MB]
================================================
FILE: web/src/App.vue
================================================
================================================
FILE: web/src/api/ai.ts
================================================
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)
}
}
================================================
FILE: web/src/api/asset.ts
================================================
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}`)
}
}
================================================
FILE: web/src/api/audio.ts
================================================
import axios from 'axios'
const API_BASE_URL = '/api/v1'
export interface ExtractAudioRequest {
video_url: string
}
export interface ExtractAudioResponse {
audio_url: string
duration: number
}
export interface BatchExtractAudioRequest {
video_urls: string[]
}
export interface BatchExtractAudioResponse {
results: ExtractAudioResponse[]
total: number
}
export const audioAPI = {
/**
* 从视频URL提取音频
*/
extractAudio: async (videoUrl: string): Promise => {
const response = await axios.post(
`${API_BASE_URL}/audio/extract`,
{ video_url: videoUrl }
)
return response.data
},
/**
* 批量从视频URL提取音频
*/
batchExtractAudio: async (videoUrls: string[]): Promise => {
const response = await axios.post(
`${API_BASE_URL}/audio/extract/batch`,
{ video_urls: videoUrls }
)
return response.data
}
}
================================================
FILE: web/src/api/character-library.ts
================================================
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
image_url?: string
local_path?: string
}) {
return request.put(`/characters/${characterId}`, data)
},
// 删除角色
deleteCharacter(characterId: number) {
return request.delete(`/characters/${characterId}`)
},
// 从剧本提取角色
extractFromEpisode(episodeId: number) {
return request.post<{ task_id: string; message: string }>(`/episodes/${episodeId}/characters/extract`)
}
}
================================================
FILE: web/src/api/drama.ts
================================================
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
})
},
updateCharacter(id: number, data: any) {
return request.put(`/characters/${id}`, data)
},
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, model?: string) {
return request.post<{ task_id: string; status: string; message: string }>(`/images/episode/${episodeId}/backgrounds/extract`, { model })
},
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;
prompt?: string;
action?: string;
dialogue?: string;
description?: string;
duration?: number;
image_url?: string;
local_path?: string;
}) {
return request.put(`/scenes/${sceneId}`, data)
},
createScene(data: {
drama_id: number;
episode_id?: number;
location: string;
time?: string;
prompt?: string;
description?: string;
image_url?: string;
local_path?: string;
}) {
return request.post('/scenes', data)
},
generateSceneImage(data: { scene_id: number; prompt?: string; model?: string }) {
return request.post<{ image_generation: { id: number } }>('/scenes/generate-image', data)
},
updateScenePrompt(sceneId: string, prompt: string) {
return request.put(`/scenes/${sceneId}/prompt`, { prompt })
},
deleteScene(sceneId: string) {
return request.delete(`/scenes/${sceneId}`)
},
// 完成集数制作(触发视频合成)
finalizeEpisode(episodeId: string, timelineData?: any) {
return request.post(`/episodes/${episodeId}/finalize`, timelineData || {})
},
createStoryboard(data: {
episode_id: number;
storyboard_number: number;
title?: string;
description?: string;
action?: string;
dialogue?: string;
scene_id?: number;
duration: number;
}) {
return request.post('/storyboards', data)
},
deleteStoryboard(storyboardId: number) {
return request.delete(`/storyboards/${storyboardId}`)
}
}
================================================
FILE: web/src/api/frame.ts
================================================
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 GenerateFramePromptResponse {
task_id: string
status: string
message: string
}
// 生成帧提示词请求
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`)
}
================================================
FILE: web/src/api/generation.ts
================================================
import type {
GenerateCharactersRequest
} from '../types/generation'
import request from '../utils/request'
export const generationAPI = {
generateCharacters(data: GenerateCharactersRequest) {
return request.post<{ task_id: string; status: string; message: string }>('/generation/characters', data)
},
generateStoryboard(episodeId: string, model?: string) {
return request.post<{ task_id: string; status: string; message: string }>(`/episodes/${episodeId}/storyboards`, { model })
},
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}`)
}
}
================================================
FILE: web/src/api/image.ts
================================================
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}`)
},
// 上传图片并创建图片生成记录
uploadImage(data: {
storyboard_id: number
drama_id: number
frame_type: string
image_url: string
prompt?: string
}) {
return request.post('/images/upload', data)
}
}
================================================
FILE: web/src/api/prop.ts
================================================
import request from '../utils/request'
import type { Prop, CreatePropRequest, UpdatePropRequest } from '../types/prop'
export const propAPI = {
list(dramaId: string | number) {
return request.get('/dramas/' + dramaId + '/props')
},
create(data: CreatePropRequest) {
return request.post('/props', data)
},
update(id: number, data: UpdatePropRequest) {
return request.put('/props/' + id, data)
},
delete(id: number) {
return request.delete('/props/' + id)
},
extractFromScript(episodeId: number) {
return request.post<{ task_id: string }>(`/episodes/${episodeId}/props/extract`)
},
generateImage(id: number) {
return request.post<{ task_id: string }>(`/props/${id}/generate`)
},
associateWithStoryboard(storyboardId: number, propIds: number[]) {
return request.post(`/storyboards/${storyboardId}/props`, { prop_ids: propIds })
}
}
================================================
FILE: web/src/api/settings.ts
================================================
import request from '../utils/request'
export const settingsAPI = {
// 获取系统语言
getLanguage() {
return request.get<{ language: string }>('/settings/language')
},
// 更新系统语言
updateLanguage(language: 'zh' | 'en') {
return request.put<{ message: string; language: string }>('/settings/language', { language })
}
}
================================================
FILE: web/src/api/task.ts
================================================
import request from '../utils/request'
export interface AsyncTask {
id: string
type: string
status: 'pending' | 'processing' | 'completed' | 'failed'
progress: number
message: string
result?: any
error?: string
created_at: string
}
export const taskAPI = {
getStatus(taskId: string) {
return request.get(`/tasks/${taskId}`)
}
}
================================================
FILE: web/src/api/video.ts
================================================
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}`)
}
}
================================================
FILE: web/src/api/videoMerge.ts
================================================
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}`)
}
}
================================================
FILE: web/src/assets/styles/element/index.scss
================================================
/*just override what you need*/
@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
$bg-color: (
'page': #0a0a0a,
'': #141414,
'overlay': #1d1e1f,
),
$fill-color: (
'': #262727,
'light': #1d1e1f,
'lighter': #141414,
'extra-light': #191919,
'dark': #3a3a3a,
'darker': #4a4a4a,
'blank': #1a1a1a,
)
);
================================================
FILE: web/src/assets/styles/main.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ========================================
CSS Variables for Theme / 主题 CSS 变量
Modern minimalist design system
======================================== */
:root {
/* Background colors / 背景色 */
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f1f5f9;
--bg-elevated: #ffffff;
--bg-overlay: rgba(15, 23, 42, 0.5);
/* Text colors / 文字色 */
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--text-inverse: #ffffff;
/* Border colors / 边框色 */
--border-primary: #e2e8f0;
--border-secondary: #cbd5e1;
--border-focus: #0ea5e9;
/* Primary accent / 主强调色 */
--accent: #0ea5e9;
--accent-hover: #0284c7;
--accent-light: #e0f2fe;
--accent-dark: #0369a1;
/* Status colors / 状态色 */
--success: #10b981;
--success-light: #d1fae5;
--warning: #f59e0b;
--warning-light: #fef3c7;
--error: #ef4444;
--error-light: #fee2e2;
--info: #3b82f6;
--info-light: #dbeafe;
/* Shadows / 阴影 - refined for depth */
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.03);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.05);
--shadow-glow: 0 0 20px rgba(14, 165, 233, 0.15);
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-card-hover: 0 8px 16px -4px rgb(0 0 0 / 0.1), 0 4px 6px -2px rgb(0 0 0 / 0.05);
/* Transition / 过渡 */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
/* Border radius / 圆角 */
--radius-xs: 0.25rem;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.25rem;
--radius-full: 9999px;
/* Spacing scale / 间距比例 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
}
/* Dark mode theme / 深色模式主题 - 参考深色UI设计 */
.dark {
/* Background - 深邃的蓝黑色调 */
--bg-primary: #0c1015;
--bg-secondary: #12181f;
--bg-card: #181f28;
--bg-card-hover: #1e2730;
--bg-elevated: #1a2129;
--bg-overlay: rgba(0, 0, 0, 0.8);
/* Text - 清晰的层次对比 */
--text-primary: #e8edf3;
--text-secondary: #8b9bb0;
--text-muted: #5a6a7e;
--text-inverse: #0c1015;
/* Border - 微妙的边框 */
--border-primary: #252d38;
--border-secondary: #323d4d;
--border-focus: #22d3ee;
/* Accent - 青色强调色 */
--accent: #22d3ee;
--accent-hover: #06b6d4;
--accent-light: rgba(34, 211, 238, 0.12);
--accent-dark: #67e8f9;
/* Status colors / 状态色 */
--success: #34d399;
--success-light: rgba(52, 211, 153, 0.12);
--warning: #fbbf24;
--warning-light: rgba(251, 191, 36, 0.12);
--error: #f87171;
--error-light: rgba(248, 113, 113, 0.12);
--info: #60a5fa;
--info-light: rgba(96, 165, 250, 0.12);
/* Shadows - 更深的阴影 */
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.4);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.5), 0 1px 2px -1px rgb(0 0 0 / 0.5);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.6), 0 2px 4px -2px rgb(0 0 0 / 0.5);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.7), 0 4px 6px -4px rgb(0 0 0 / 0.6);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.8), 0 8px 10px -6px rgb(0 0 0 / 0.7);
--shadow-glow: 0 0 20px rgba(34, 211, 238, 0.25);
--shadow-card: 0 2px 4px 0 rgb(0 0 0 / 0.3);
--shadow-card-hover: 0 8px 20px -4px rgb(0 0 0 / 0.5), 0 0 0 1px rgba(34, 211, 238, 0.15);
--el-fill-color-blank: #181f28;
--el-border-color: #4d4d4d;
--el-border-color-light: #2a333d;
--el-fill-color-light: #2a333d;
--el-bg-color-overlay: #181f28;
--el-text-color-regular: #e8edf3;
--el-descriptions-table-border: #4d4d4d;
--el-border-color-lighter: #4d4d4d;
--el-text-color-primary: #e8edf3;
}
/* ========================================
Base Styles / 基础样式
======================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
transition:
background-color var(--transition-normal),
color var(--transition-normal);
}
#app {
width: 100%;
height: 100%;
}
/* ========================================
Element Plus Overrides / Element Plus 样式覆盖
Modern minimalist design overrides
======================================== */
/* 单行打点 */
.overflow-tooltip {
display: inline-block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
}
.el-select-dropdown__item {
line-height: unset;
}
/* Button overrides / 按钮样式覆盖 */
.el-button {
--el-button-border-radius: var(--radius-lg);
font-weight: 500;
transition: all var(--transition-fast);
border: none;
letter-spacing: -0.01em;
}
.el-button--default {
background: var(--bg-card);
border: 1px solid var(--border-primary);
color: var(--text-primary);
}
.el-button--default:hover {
background: var(--bg-card-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.el-button--primary {
--el-button-bg-color: var(--accent);
--el-button-border-color: var(--accent);
--el-button-hover-bg-color: var(--accent-hover);
--el-button-hover-border-color: var(--accent-hover);
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.25);
}
.el-button--primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.35);
}
.el-button--primary:active {
transform: translateY(0);
}
.el-button--danger {
background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25);
}
.el-button--danger:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35);
}
.el-button--success {
background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25);
}
.el-button.is-text {
color: var(--text-secondary);
}
.el-button.is-text:hover {
color: var(--accent);
background: var(--accent-light);
}
.el-button.is-circle {
background: var(--bg-card);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
}
.el-button.is-circle:hover {
background: var(--bg-card-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
/* Back button / 返回按钮 */
.back-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.back-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
border-color: var(--border-secondary);
}
.back-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Card overrides / 卡片样式覆盖 */
.el-card {
--el-card-bg-color: var(--bg-card);
--el-card-border-color: var(--border-primary);
--el-card-border-radius: var(--radius-xl);
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-card);
transition: all var(--transition-normal);
}
.el-card:hover {
box-shadow: var(--shadow-card-hover);
}
.el-card__header {
border-bottom: 1px solid var(--border-primary);
padding: var(--space-4) var(--space-5);
}
.el-card__body {
padding: var(--space-5);
}
.dark .el-card {
--el-card-bg-color: var(--bg-card);
--el-card-border-color: var(--border-primary);
}
/* Dialog overrides / 对话框样式覆盖 */
.el-dialog {
--el-dialog-bg-color: var(--bg-card);
--el-dialog-border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xl);
border: 1px solid var(--border-primary);
}
.el-dialog__header {
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--border-primary);
margin-right: 0;
}
.el-dialog__title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.el-dialog__body {
padding: var(--space-6);
}
.el-dialog__footer {
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--border-primary);
}
.dark .el-dialog {
--el-dialog-bg-color: var(--bg-card);
}
.dark .el-dialog__title {
color: var(--text-primary);
}
/* Input overrides / 输入框样式覆盖 */
.el-input__wrapper {
--el-input-bg-color: var(--bg-secondary);
--el-input-border-color: var(--border-primary);
border-radius: var(--radius-lg) !important;
box-shadow: 0 0 0 1px var(--border-primary) inset !important;
transition: all var(--transition-fast);
padding: 0 var(--space-3);
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--border-secondary) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 2px var(--accent) inset !important;
}
.el-input__inner {
color: var(--text-primary);
font-size: 0.875rem;
}
.el-input__inner::placeholder {
color: var(--text-muted);
}
.el-textarea__inner {
--el-input-bg-color: var(--bg-secondary);
border-radius: var(--radius-lg) !important;
box-shadow: 0 0 0 1px var(--border-primary) inset;
transition: all var(--transition-fast);
padding: var(--space-3);
color: var(--text-primary);
font-size: 0.875rem;
}
.el-textarea__inner:hover {
box-shadow: 0 0 0 1px var(--border-secondary) inset;
}
.el-textarea__inner:focus {
box-shadow: 0 0 0 2px var(--accent) inset;
}
.el-textarea__inner::placeholder {
color: var(--text-muted);
}
.dark .el-input__wrapper {
background-color: var(--bg-secondary);
}
.dark .el-input__inner {
color: var(--text-primary);
}
.dark .el-textarea__inner {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
/* Select overrides / 选择器样式覆盖 */
.el-select .el-input__wrapper {
background: var(--bg-secondary);
}
.el-select-dropdown {
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-lg);
}
.el-select-dropdown__item {
font-size: 0.875rem;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
margin: 2px var(--space-1);
}
.el-select-dropdown__item.is-selected {
background: var(--accent-light);
color: var(--accent);
font-weight: 500;
}
.el-select-dropdown__item:hover {
background: var(--bg-card-hover);
}
.dark .el-select-dropdown {
background: var(--bg-elevated);
border-color: var(--border-primary);
}
.dark .el-select-dropdown__item:hover {
background: var(--bg-card-hover);
}
/* Tag overrides / 标签样式覆盖 */
.el-tag {
--el-tag-border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.75rem;
padding: 0 var(--space-2);
height: 1.5rem;
line-height: 1.5rem;
border: none;
}
.el-tag--info {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.el-tag--primary {
background: var(--accent-light);
color: var(--accent);
}
.el-tag--success {
background: var(--success-light);
color: var(--success);
}
.el-tag--warning {
background: var(--warning-light);
color: var(--warning);
}
.el-tag--danger {
background: var(--error-light);
color: var(--error);
}
/* Tabs overrides / 标签页样式覆盖 */
.el-tabs__header {
margin-bottom: var(--space-6);
}
.el-tabs__nav-wrap::after {
display: none;
}
.el-tabs__item {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-secondary);
padding: 0 var(--space-5);
height: 2.5rem;
line-height: 2.5rem;
transition: color var(--transition-fast);
}
.el-tabs__item:hover {
color: var(--text-primary);
}
.el-tabs__item.is-active {
color: var(--accent);
font-weight: 600;
}
.el-tabs__active-bar {
background: var(--accent);
height: 2px;
border-radius: var(--radius-full);
}
.dark .el-tabs__item {
color: var(--text-secondary);
}
.dark .el-tabs__item.is-active {
color: var(--accent);
}
/* Table overrides / 表格样式覆盖 */
.el-table {
--el-table-bg-color: var(--bg-card);
--el-table-header-bg-color: var(--bg-secondary);
--el-table-tr-bg-color: var(--bg-card);
--el-table-row-hover-bg-color: var(--bg-card-hover);
--el-table-border-color: var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.el-table th.el-table__cell {
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.el-table td.el-table__cell {
font-size: 0.875rem;
color: var(--text-primary);
}
.dark .el-table {
--el-table-bg-color: var(--bg-card);
--el-table-header-bg-color: var(--bg-secondary);
--el-table-tr-bg-color: var(--bg-card);
--el-table-row-hover-bg-color: var(--bg-card-hover);
--el-fill-color-lighter: var(--bg-secondary);
}
.dark .el-table th.el-table__cell,
.dark .el-table td.el-table__cell {
border-color: var(--border-primary);
}
.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: var(--bg-secondary);
}
/* Pagination overrides / 分页样式覆盖 */
.el-pagination {
--el-pagination-bg-color: transparent;
--el-pagination-button-bg-color: var(--bg-card);
gap: var(--space-1);
}
.el-pager li {
min-width: 2rem;
height: 2rem;
line-height: 2rem;
font-weight: 500;
font-size: 0.8125rem;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.el-pager li:hover {
color: var(--accent);
background: var(--accent-light);
}
.el-pager li.is-active {
background: var(--accent);
color: white;
font-weight: 600;
}
.el-pagination button {
min-width: 2rem;
height: 2rem;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.el-pagination button:hover:not(:disabled) {
color: var(--accent);
background: var(--accent-light);
}
.el-pagination button:disabled {
opacity: 0.4;
}
.dark .el-pagination {
--el-pagination-text-color: var(--text-secondary);
--el-pagination-button-color: var(--text-primary);
}
.dark .el-pagination button,
.dark .el-pager li {
background-color: transparent;
color: var(--text-secondary);
}
.dark .el-pager li:hover {
background: var(--accent-light);
}
.dark .el-pager li.is-active {
background: var(--accent);
color: var(--text-inverse);
}
/* Empty state overrides / 空状态样式覆盖 */
.el-empty {
padding: var(--space-12) var(--space-6);
}
.el-empty__description p {
color: var(--text-muted);
font-size: 0.875rem;
}
.dark .el-empty__description p {
color: var(--text-muted);
}
/* Alert overrides / 提示框样式覆盖 */
.el-alert {
border-radius: var(--radius-lg);
border: none;
padding: var(--space-4);
}
.el-alert--info {
background: var(--info-light);
}
.el-alert--success {
background: var(--success-light);
}
.el-alert--warning {
background: var(--warning-light);
}
.el-alert--error {
background: var(--error-light);
}
.dark .el-alert--info {
--el-alert-bg-color: var(--info-light);
}
/* Form overrides / 表单样式覆盖 */
.el-form-item__label {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.dark .el-form-item__label {
color: var(--text-primary);
}
/* Descriptions overrides / 描述列表样式覆盖 */
.el-descriptions {
--el-descriptions-item-bordered-label-background: var(--bg-secondary);
}
.el-descriptions__label {
font-weight: 500;
color: var(--text-secondary);
}
.el-descriptions__content {
color: var(--text-primary);
}
.dark .el-descriptions__label,
.dark .el-descriptions__content {
background: var(--bg-secondary);
}
/* Message Box overrides / 消息框样式覆盖 */
.el-message-box {
border-radius: var(--radius-xl);
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-xl);
}
.dark .el-message-box {
background: var(--bg-card);
}
/* Popconfirm overrides / 确认弹窗样式覆盖 */
.el-popconfirm {
border-radius: var(--radius-lg);
}
.dark .el-popconfirm {
--el-popconfirm-bg-color: var(--bg-card);
}
/* Loading overrides / 加载样式覆盖 */
.el-loading-mask {
background: var(--bg-overlay);
backdrop-filter: blur(4px);
}
.el-loading-spinner .circular {
width: 32px;
height: 32px;
}
.el-loading-spinner .path {
stroke: var(--accent);
}
/* Switch overrides / 开关样式覆盖 */
.el-switch {
--el-switch-on-color: var(--accent);
}
.dark .el-switch__core {
background: var(--bg-secondary);
border-color: var(--border-primary);
}
/* Tooltip overrides / 提示框样式覆盖 */
.el-tooltip__trigger {
outline: none;
}
/* Avatar overrides / 头像样式覆盖 */
.el-avatar {
--el-avatar-bg-color: var(--accent);
}
/* Scrollbar overrides / 滚动条样式覆盖 */
.el-scrollbar__thumb {
background: var(--border-secondary);
border-radius: var(--radius-full);
}
.el-scrollbar__thumb:hover {
background: var(--text-muted);
}
/* 图片样式 */
.el-image-viewer__close {
background-color: var(--text-muted);
/* border: 1px solid var(--border-primary); */
}
.el-image-viewer__actions {
background-color: #0f172a;
/* border: 1px solid var(--border-primary); */
}
/* ========================================
Utility Classes / 工具类
======================================== */
.page-container {
min-height: 100vh;
background-color: var(--bg-primary);
/* padding: var(--space-2) var(--space-3); */
transition: background-color var(--transition-normal);
}
/* @media (min-width: 768px) {
.page-container {
padding: var(--space-3) var(--space-4);
}
} */
/* @media (min-width: 1024px) {
.page-container {
padding: var(--space-4) var(--space-5);
}
} */
.content-wrapper {
margin: 0 auto;
width: 100%;
}
/* ========================================
Layout Components / 布局组件
======================================== */
/* Glass morphism card / 玻璃态卡片 */
.glass-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
transition: all var(--transition-normal);
}
.dark .glass-card {
background: rgba(26, 35, 50, 0.8);
}
.glass-card:hover {
box-shadow: var(--shadow-card-hover);
}
/* Gradient backgrounds / 渐变背景 */
.gradient-primary {
background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%);
}
.gradient-success {
background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
}
.gradient-warning {
background: linear-gradient(135deg, var(--warning) 0%, #d97706 100%);
}
.gradient-error {
background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%);
}
/* Animated gradient / 动画渐变 */
.gradient-animated {
background: linear-gradient(-45deg, #0ea5e9, #06b6d4, #8b5cf6, #ec4899);
background-size: 400% 400%;
animation: gradient-shift 15s ease infinite;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Glow effect / 发光效果 */
.glow-primary {
box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
}
.glow-success {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
}
/* Skeleton loading / 骨架屏加载 */
.skeleton {
background: linear-gradient(90deg, var(--bg-card-hover) 25%, var(--bg-secondary) 50%, var(--bg-card-hover) 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: var(--radius-md);
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Hover lift effect / 悬停提升效果 */
.hover-lift {
transition:
transform var(--transition-normal),
box-shadow var(--transition-normal);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Interactive scale / 交互缩放 */
.interactive-scale {
transition: transform var(--transition-fast);
}
.interactive-scale:hover {
transform: scale(1.02);
}
.interactive-scale:active {
transform: scale(0.98);
}
/* Focus ring / 焦点环 */
.focus-ring:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Text truncation / 文本截断 */
.truncate-1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.truncate-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Scrollbar styling / 滚动条样式 */
/* 全局滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: var(--radius-full);
transition: background var(--transition-fast);
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* Firefox 滚动条样式 */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-secondary) var(--bg-secondary);
}
/* 自定义滚动条类 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: var(--radius-full);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* Hide scrollbar / 隐藏滚动条 */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* ========================================
Animation Utilities / 动画工具
======================================== */
/* Fade in / 淡入 */
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Slide up / 向上滑入 */
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Scale in / 缩放进入 */
.animate-scale-in {
animation: scale-in 0.2s ease-out;
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Pulse / 脉冲 */
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Spin / 旋转 */
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Bounce / 弹跳 */
.animate-bounce {
animation: bounce 1s infinite;
}
@keyframes bounce {
0%,
100% {
transform: translateY(-5%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: translateY(0);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
/* ========================================
Status Indicators / 状态指示器
======================================== */
.status-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.status-dot.success {
background: var(--success);
}
.status-dot.warning {
background: var(--warning);
}
.status-dot.error {
background: var(--error);
}
.status-dot.info {
background: var(--info);
}
.status-dot.muted {
background: var(--text-muted);
}
.status-dot.pulse {
animation: status-pulse 2s infinite;
}
@keyframes status-pulse {
0%,
100% {
box-shadow: 0 0 0 0 currentColor;
opacity: 1;
}
50% {
box-shadow: 0 0 0 4px currentColor;
opacity: 0.5;
}
}
/* ========================================
Typography Utilities / 排版工具
======================================== */
.text-gradient {
background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.font-display {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
}
================================================
FILE: web/src/components/LanguageSwitcher.vue
================================================
{{ currentLangText }}
🇨🇳 简体中文
🇺🇸 English
================================================
FILE: web/src/components/common/AIConfigDialog.vue
================================================