mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-08-21 14:57:13 +00:00
feat(release): migrate GeekAI v4.3.0 to open source
- Sync backend and frontend from GeekAI Plus v4.3.0 - Remove commercial License flows and update open-source deployment defaults - Preserve Docker Compose deployment and bump image tags to v4.3.0 BREAKING CHANGE: commercial License configuration and related endpoints are removed
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package adapters
|
||||
|
||||
import "geekai/log"
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
var logger = log.GetLogger()
|
||||
|
||||
// CreateTaskResponse 创建任务响应
|
||||
type CreateTaskResponse struct {
|
||||
TaskId string `json:"task_id"` // 任务ID
|
||||
Channel string `json:"channel"` // 渠道标识
|
||||
Prompt string `json:"prompt"` // 优化后的提示词(如果有)
|
||||
State string `json:"state"` // 任务状态
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// QueryTaskResponse 查询任务响应
|
||||
type QueryTaskResponse struct {
|
||||
TaskId string `json:"task_id"` // 任务ID
|
||||
Status string `json:"status"` // 任务状态
|
||||
Progress int `json:"progress"` // 进度(0-100)
|
||||
VideoURL string `json:"video_url"` // 视频URL
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
ErrMsg string `json:"err_msg"` // 错误信息
|
||||
StatusMsg string `json:"status_msg"` // 状态消息
|
||||
Output string `json:"output"` // 任务输出的原始信息(JSON字符串)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DoubaoAdapter 豆包 Seedance 视频生成适配器(通过 Kapon VolcArk 接入)
|
||||
type DoubaoAdapter struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDoubaoAdapter 创建 Doubao 适配器
|
||||
func NewDoubaoAdapter(db *gorm.DB) *DoubaoAdapter {
|
||||
return &DoubaoAdapter{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *DoubaoAdapter) GetProvider() string {
|
||||
return types.VideoDoubao
|
||||
}
|
||||
|
||||
// doubaoContentItem 请求体中的 content 子项
|
||||
type doubaoContentItem struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL map[string]string `json:"image_url,omitempty"`
|
||||
Extra map[string]interface{} `json:"extra,omitempty"` // 预留扩展
|
||||
}
|
||||
|
||||
// doubaoCreateRequest 创建任务请求体
|
||||
type doubaoCreateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Content []doubaoContentItem `json:"content"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Frames int `json:"frames,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Seed int64 `json:"seed,omitempty"`
|
||||
// 其他官方支持的字段,按需追加
|
||||
}
|
||||
|
||||
// doubaoCreateResponse 创建任务响应
|
||||
type doubaoCreateResponse struct {
|
||||
Id string `json:"id"`
|
||||
PlatformId string `json:"platform_id"`
|
||||
// 其余字段目前用不到,先不展开
|
||||
}
|
||||
|
||||
// doubaoQueryContent 查询任务 content 字段
|
||||
type doubaoQueryContent struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
LastFrameURL string `json:"last_frame_url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// doubaoQueryUsage 查询任务 usage 字段
|
||||
type doubaoQueryUsage struct {
|
||||
VideoTokens int `json:"video_tokens"`
|
||||
}
|
||||
|
||||
// doubaoQueryResponse 查询任务响应
|
||||
type doubaoQueryResponse struct {
|
||||
Id string `json:"id"`
|
||||
PlatformId string `json:"platform_id"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Content doubaoQueryContent `json:"content"`
|
||||
Duration int `json:"duration"`
|
||||
Frames int `json:"framespersecond"`
|
||||
Usage doubaoQueryUsage `json:"usage"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTask 创建豆包 Seedance 视频任务
|
||||
func (a *DoubaoAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
if videoConfig == nil {
|
||||
return CreateTaskResponse{}, errors.New("视频配置为空")
|
||||
}
|
||||
if videoConfig.ApiURL == "" || videoConfig.ApiKey == "" {
|
||||
return CreateTaskResponse{}, errors.New("豆包视频未配置 ApiURL 或 ApiKey")
|
||||
}
|
||||
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, errors.New("invalid params type for Doubao video task")
|
||||
}
|
||||
|
||||
// 模型名称:优先从 params.model 读取,否则默认 doubao-seedance-1-5-pro
|
||||
modelName := "doubao-seedance-1-5-pro"
|
||||
if v, ok := paramsMap["model"].(string); ok && v != "" {
|
||||
modelName = v
|
||||
}
|
||||
|
||||
// 解析基础参数
|
||||
duration := 0
|
||||
if v, ok := paramsMap["duration"].(float64); ok {
|
||||
duration = int(v)
|
||||
}
|
||||
if v, ok := paramsMap["duration"].(int); ok {
|
||||
duration = v
|
||||
}
|
||||
|
||||
ratio := ""
|
||||
if v, ok := paramsMap["aspect_ratio"].(string); ok {
|
||||
ratio = v
|
||||
}
|
||||
|
||||
resolution := ""
|
||||
if v, ok := paramsMap["resolution"].(string); ok {
|
||||
resolution = v
|
||||
}
|
||||
|
||||
var seed int64
|
||||
switch v := paramsMap["seed"].(type) {
|
||||
case float64:
|
||||
seed = int64(v)
|
||||
case int:
|
||||
seed = int64(v)
|
||||
case int64:
|
||||
seed = v
|
||||
}
|
||||
|
||||
// 构建 content 数组:文本提示词为必填
|
||||
content := []doubaoContentItem{
|
||||
{
|
||||
Type: "text",
|
||||
Text: task.Prompt,
|
||||
},
|
||||
}
|
||||
|
||||
// 如果存在 input_reference(图片 URL),则追加 image_url 项,用于 I2V
|
||||
if ref, ok := paramsMap["input_reference"].(string); ok && ref != "" {
|
||||
content = append(content, doubaoContentItem{
|
||||
Type: "image_url",
|
||||
ImageURL: map[string]string{
|
||||
"url": ref,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
reqBody := doubaoCreateRequest{
|
||||
Model: modelName,
|
||||
Content: content,
|
||||
Duration: duration,
|
||||
Ratio: ratio,
|
||||
Resolution: resolution,
|
||||
Seed: seed,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("序列化豆包请求失败: %v", err)
|
||||
}
|
||||
logger.Debugf("DoubaoCreateRequest: %s", string(payload))
|
||||
|
||||
url := fmt.Sprintf("%s/seedance/v3/contents/generations/tasks", videoConfig.ApiURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("创建豆包请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("调用豆包接口失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("读取豆包响应失败: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return CreateTaskResponse{}, fmt.Errorf("豆包接口返回错误状态码: %d, %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp doubaoCreateResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析豆包创建任务响应失败: %v, body=%s", err, string(body))
|
||||
}
|
||||
|
||||
taskId := apiResp.PlatformId
|
||||
if taskId == "" {
|
||||
taskId = apiResp.Id
|
||||
}
|
||||
if taskId == "" {
|
||||
return CreateTaskResponse{}, fmt.Errorf("豆包创建任务响应缺少任务 ID, body=%s", string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: taskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询豆包 Seedance 视频任务状态
|
||||
func (a *DoubaoAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
if videoConfig == nil {
|
||||
return QueryTaskResponse{}, errors.New("视频配置为空")
|
||||
}
|
||||
if videoConfig.ApiURL == "" || videoConfig.ApiKey == "" {
|
||||
return QueryTaskResponse{}, errors.New("豆包视频未配置 ApiURL 或 ApiKey")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/seedance/v3/contents/generations/tasks/%s", videoConfig.ApiURL, taskId)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("创建查询请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("调用豆包查询接口失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("读取豆包查询响应失败: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return QueryTaskResponse{}, fmt.Errorf("豆包查询接口返回错误状态码: %d, %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp doubaoQueryResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析豆包查询任务响应失败: %v, body=%s", err, string(body))
|
||||
}
|
||||
|
||||
status := apiResp.Status
|
||||
progress := 0
|
||||
|
||||
switch status {
|
||||
case "queued":
|
||||
status = types.VideoStatusPending
|
||||
progress = 10
|
||||
case "running":
|
||||
status = types.VideoStatusInProgress
|
||||
progress = 60
|
||||
case "succeeded":
|
||||
status = types.VideoStatusSuccess
|
||||
progress = 100
|
||||
case "failed", "cancelled":
|
||||
status = types.VideoStatusFailed
|
||||
default:
|
||||
// 保持原样或视为 pending
|
||||
status = types.VideoStatusPending
|
||||
}
|
||||
|
||||
errMsg := apiResp.Error
|
||||
if errMsg == "" && status == types.VideoStatusFailed {
|
||||
errMsg = "doubao task failed"
|
||||
}
|
||||
|
||||
result := QueryTaskResponse{
|
||||
TaskId: apiResp.PlatformId,
|
||||
Status: status,
|
||||
Progress: progress,
|
||||
VideoURL: apiResp.Content.VideoURL,
|
||||
Prompt: "",
|
||||
ErrMsg: errMsg,
|
||||
StatusMsg: status,
|
||||
Output: string(body),
|
||||
}
|
||||
|
||||
if result.TaskId == "" {
|
||||
result.TaskId = apiResp.Id
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KelingAdapter 可灵视频生成适配器
|
||||
type KelingAdapter struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewKelingAdapter 创建可灵适配器
|
||||
func NewKelingAdapter(db *gorm.DB) *KelingAdapter {
|
||||
return &KelingAdapter{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *KelingAdapter) GetProvider() string {
|
||||
return "keling"
|
||||
}
|
||||
|
||||
// KelingCreateRequest 可灵创建任务请求
|
||||
type KelingCreateRequest struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
CfgScale float64 `json:"cfg_scale,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Sound bool `json:"sound,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ImageTail string `json:"image_tail,omitempty"`
|
||||
}
|
||||
|
||||
// KelingCreateResponse 可灵创建任务响应
|
||||
type KelingCreateResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// KelingQueryResponse 可灵查询任务响应
|
||||
type KelingQueryResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
TaskResult struct {
|
||||
Images []struct {
|
||||
Index int `json:"index"`
|
||||
URL string `json:"url"`
|
||||
} `json:"images,omitempty"`
|
||||
Videos []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos,omitempty"`
|
||||
} `json:"task_result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *KelingAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, errors.New("invalid params type for KeLing video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
payload := KelingCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if modelName, ok := paramsMap["model_name"].(string); ok {
|
||||
payload.ModelName = modelName
|
||||
}
|
||||
if prompt, ok := paramsMap["prompt"].(string); ok {
|
||||
payload.Prompt = prompt
|
||||
}
|
||||
if negativePrompt, ok := paramsMap["negative_prompt"].(string); ok {
|
||||
payload.NegativePrompt = negativePrompt
|
||||
}
|
||||
if cfgScale, ok := paramsMap["cfg_scale"].(float64); ok {
|
||||
payload.CfgScale = cfgScale
|
||||
}
|
||||
if mode, ok := paramsMap["mode"].(string); ok {
|
||||
payload.Mode = mode
|
||||
}
|
||||
if aspectRatio, ok := paramsMap["aspect_ratio"].(string); ok {
|
||||
payload.AspectRatio = aspectRatio
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(string); ok {
|
||||
payload.Duration = duration
|
||||
}
|
||||
|
||||
if sound, ok := paramsMap["sound"].(bool); ok {
|
||||
payload.Sound = sound
|
||||
}
|
||||
|
||||
// 处理图生视频
|
||||
taskType, ok := paramsMap["task_type"].(string)
|
||||
if ok && taskType == "image2video" {
|
||||
if image, ok := paramsMap["image"].(string); ok {
|
||||
payload.Image = image
|
||||
}
|
||||
if imageTail, ok := paramsMap["image_tail"].(string); ok {
|
||||
payload.ImageTail = imageTail
|
||||
}
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to marshal payload: %v", err)
|
||||
}
|
||||
logger.Debugf("KelingCreateRequest: %+v", string(jsonPayload))
|
||||
|
||||
// 发送请求
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s", videoConfig.ApiURL, taskType)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{Timeout: time.Duration(30) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 处理响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return CreateTaskResponse{}, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResponse KelingCreateResponse
|
||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if apiResponse.Code != 0 {
|
||||
return CreateTaskResponse{}, fmt.Errorf("API error: %s", apiResponse.Message)
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: apiResponse.Data.TaskID,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *KelingAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
// 从 taskId 中提取 action(可灵的 taskId 格式可能包含 action 信息)
|
||||
// 这里需要从任务信息中获取 task_type,暂时使用 text2video 作为默认值
|
||||
action := "text2video"
|
||||
|
||||
// 尝试从 channel 或其他地方获取 action,这里简化处理
|
||||
// 实际应该从任务信息中获取
|
||||
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s/%s", videoConfig.ApiURL, action, taskId)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(30) * time.Second}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var response KelingQueryResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return QueryTaskResponse{}, fmt.Errorf("API error: %s", response.Message)
|
||||
}
|
||||
|
||||
// 转换状态
|
||||
state := response.Data.TaskStatus
|
||||
status := state
|
||||
switch state {
|
||||
case "in_progress", "processing":
|
||||
status = types.VideoStatusInProgress
|
||||
case "completed", "succeed", "success":
|
||||
status = types.VideoStatusSuccess
|
||||
case "failed":
|
||||
status = types.VideoStatusFailed
|
||||
default:
|
||||
status = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
result := QueryTaskResponse{
|
||||
TaskId: response.Data.TaskID,
|
||||
Status: status,
|
||||
ErrMsg: response.Data.TaskStatusMsg,
|
||||
StatusMsg: response.Data.TaskStatusMsg,
|
||||
Output: string(body),
|
||||
}
|
||||
|
||||
// 提取视频URL
|
||||
if len(response.Data.TaskResult.Videos) > 0 {
|
||||
result.VideoURL = response.Data.TaskResult.Videos[0].URL
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LumaAdapter Luma 视频生成适配器
|
||||
type LumaAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewLumaAdapter 创建 Luma 适配器
|
||||
func NewLumaAdapter(db *gorm.DB) *LumaAdapter {
|
||||
return &LumaAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *LumaAdapter) GetProvider() string {
|
||||
return "luma"
|
||||
}
|
||||
|
||||
// LumaCreateRequest Luma 创建任务请求
|
||||
type LumaCreateRequest struct {
|
||||
ModelName string `json:"model_name"`
|
||||
UserPrompt string `json:"user_prompt"`
|
||||
ExpandPrompt bool `json:"expand_prompt,omitempty"`
|
||||
Loop bool `json:"loop,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"` // 图生视频
|
||||
ImageEndURL string `json:"image_end_url,omitempty"` // 图生视频
|
||||
Duration string `json:"duration,omitempty"` // 视频时长
|
||||
Resolution string `json:"resolution,omitempty"` // 视频分辨率
|
||||
}
|
||||
|
||||
// LumaCreateResponse Luma 创建任务响应
|
||||
type LumaCreateResponse struct {
|
||||
Id string `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
State string `json:"state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// LumaQueryResponse Luma 查询任务响应
|
||||
type LumaQueryResponse struct {
|
||||
Id string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Video struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
} `json:"video"`
|
||||
Prompt string `json:"prompt"`
|
||||
Thumbnail struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"thumbnail"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *LumaAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, errors.New("invalid params type for Luma video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := LumaCreateRequest{
|
||||
UserPrompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if expandPrompt, ok := paramsMap["expand_prompt"].(bool); ok {
|
||||
reqBody.ExpandPrompt = expandPrompt
|
||||
}
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.ModelName = model
|
||||
}
|
||||
if loop, ok := paramsMap["loop"].(bool); ok {
|
||||
reqBody.Loop = loop
|
||||
}
|
||||
if imageURL, ok := paramsMap["image_url"].(string); ok {
|
||||
reqBody.ImageURL = imageURL
|
||||
}
|
||||
if imageEndURL, ok := paramsMap["image_end_url"].(string); ok {
|
||||
reqBody.ImageEndURL = imageEndURL
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(string); ok {
|
||||
reqBody.Duration = duration
|
||||
}
|
||||
if resolution, ok := paramsMap["resolution"].(string); ok {
|
||||
reqBody.Resolution = resolution
|
||||
}
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/luma/generations", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res LumaCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.Id,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: res.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: res.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *LumaAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/luma/generations/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res LumaQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
switch res.State {
|
||||
case "completed", "succeed", "success":
|
||||
res.State = types.VideoStatusSuccess
|
||||
case "in_progress", "running":
|
||||
res.State = types.VideoStatusInProgress
|
||||
case "failed":
|
||||
res.State = types.VideoStatusFailed
|
||||
default:
|
||||
res.State = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.Id,
|
||||
Status: res.State,
|
||||
VideoURL: res.Video.DownloadURL,
|
||||
Prompt: res.Prompt,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniMaxAdapter MiniMax 视频生成适配器
|
||||
type MiniMaxAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewMiniMaxAdapter 创建 MiniMax 适配器
|
||||
func NewMiniMaxAdapter(db *gorm.DB) *MiniMaxAdapter {
|
||||
return &MiniMaxAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *MiniMaxAdapter) GetProvider() string {
|
||||
return "minimax"
|
||||
}
|
||||
|
||||
// MiniMaxCreateRequest MiniMax 创建任务请求
|
||||
type MiniMaxCreateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
FirstFrameImage string `json:"first_frame_image,omitempty"`
|
||||
LastFrameImage string `json:"last_frame_image,omitempty"`
|
||||
PromptOptimizer bool `json:"prompt_optimizer,omitempty"`
|
||||
}
|
||||
|
||||
// MiniMaxCreateResponse MiniMax 创建任务响应
|
||||
type MiniMaxCreateResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
} `json:"base_resp"`
|
||||
}
|
||||
|
||||
// MiniMaxFile MiniMax 文件信息
|
||||
type MiniMaxFile struct {
|
||||
Bytes int `json:"bytes"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
FileId int64 `json:"file_id"`
|
||||
Filename string `json:"filename"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
// MiniMaxQueryResponse MiniMax 查询任务响应
|
||||
type MiniMaxQueryResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
FileId string `json:"file_id,omitempty"` // 顶层 file_id 可能是字符串
|
||||
File *MiniMaxFile `json:"file,omitempty"` // file 对象包含详细信息
|
||||
VideoWidth int `json:"video_width,omitempty"`
|
||||
VideoHeight int `json:"video_height,omitempty"`
|
||||
VideoURL string `json:"video_url,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
ErrMsg string `json:"err_msg,omitempty"`
|
||||
StatusMsg string `json:"status_msg,omitempty"`
|
||||
BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
} `json:"base_resp"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *MiniMaxAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for MiniMax video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := MiniMaxCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.Model = model
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(float64); ok {
|
||||
reqBody.Duration = int(duration)
|
||||
} else if duration, ok := paramsMap["duration"].(int); ok {
|
||||
reqBody.Duration = duration
|
||||
}
|
||||
if resolution, ok := paramsMap["resolution"].(string); ok {
|
||||
reqBody.Resolution = resolution
|
||||
}
|
||||
if firstFrameImage, ok := paramsMap["first_frame_image"].(string); ok {
|
||||
reqBody.FirstFrameImage = firstFrameImage
|
||||
}
|
||||
if lastFrameImage, ok := paramsMap["last_frame_image"].(string); ok {
|
||||
reqBody.LastFrameImage = lastFrameImage
|
||||
}
|
||||
if promptOptimizer, ok := paramsMap["prompt_optimizer"].(bool); ok {
|
||||
reqBody.PromptOptimizer = promptOptimizer
|
||||
} else {
|
||||
reqBody.PromptOptimizer = true // 默认值
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/minimax/v1/video_generation", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res MiniMaxCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
if res.BaseResp.StatusCode != 0 {
|
||||
return CreateTaskResponse{}, fmt.Errorf("API 返回错误:%s", res.BaseResp.StatusMsg)
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *MiniMaxAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
// MiniMax 查询接口
|
||||
apiURL := fmt.Sprintf("%s/minimax/v1/query/video_generation?task_id=%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res MiniMaxQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
if res.BaseResp.StatusCode != 0 {
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回错误:%s", res.BaseResp.StatusMsg)
|
||||
}
|
||||
|
||||
// 转换状态(处理大小写)
|
||||
state := strings.ToLower(res.Status)
|
||||
switch state {
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "failed":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 获取视频URL,优先从 file.download_url 获取
|
||||
videoURL := res.VideoURL
|
||||
if res.File != nil && res.File.DownloadURL != "" {
|
||||
videoURL = res.File.DownloadURL
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Status: state,
|
||||
VideoURL: videoURL,
|
||||
Prompt: res.Prompt,
|
||||
ErrMsg: res.ErrMsg,
|
||||
StatusMsg: res.StatusMsg,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"geekai/utils"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SoraAdapter Sora 视频生成适配器
|
||||
type SoraAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewSoraAdapter 创建 Sora 适配器
|
||||
func NewSoraAdapter(db *gorm.DB) *SoraAdapter {
|
||||
return &SoraAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *SoraAdapter) GetProvider() string {
|
||||
return "sora"
|
||||
}
|
||||
|
||||
// SoraCreateRequest Sora 创建任务请求
|
||||
type SoraCreateRequest struct {
|
||||
Model string `json:"model"` // 模型名称:sora-2, sora-2-pro
|
||||
Prompt string `json:"prompt"` // 提示词
|
||||
Size string `json:"size,omitempty"` // 分辨率:1280x720, 720x1280, 1792x1024, 1024x1792
|
||||
InputReference interface{} `json:"input_reference,omitempty"` // 图生视频的参考图片,官方为对象 {"image_url": "..."},也兼容字符串 URL
|
||||
Seconds string `json:"seconds,omitempty"` // 视频时长(秒),默认4秒
|
||||
Watermark bool `json:"watermark,omitempty"` // 是否添加水印
|
||||
}
|
||||
|
||||
// SoraCreateResponse Sora 创建任务响应
|
||||
type SoraCreateResponse struct {
|
||||
ID string `json:"id"` // 任务ID
|
||||
Object string `json:"object"` // 对象类型,固定为 "video"
|
||||
Model string `json:"model"` // 模型名称
|
||||
Status string `json:"status"` // 状态:queued, in_progress, completed, failed
|
||||
CreatedAt int64 `json:"created_at"` // 创建时间戳
|
||||
Seconds string `json:"seconds"` // 视频时长
|
||||
Size string `json:"size"` // 分辨率
|
||||
Error *SoraError `json:"error,omitempty"` // 错误信息(成功时为null)
|
||||
}
|
||||
|
||||
// SoraQueryResponse Sora 查询任务响应
|
||||
type SoraQueryResponse struct {
|
||||
ID string `json:"id"` // 任务ID
|
||||
Object string `json:"object"` // 对象类型,固定为 "video"
|
||||
Model string `json:"model"` // 模型名称
|
||||
Status string `json:"status"` // 状态:queued, in_progress, completed, failed
|
||||
Progress int `json:"progress"` // 进度(0-100)
|
||||
CreatedAt int64 `json:"created_at"` // 创建时间戳
|
||||
Seconds string `json:"seconds"` // 视频时长
|
||||
Size string `json:"size"` // 分辨率
|
||||
Error *SoraError `json:"error,omitempty"` // 错误信息(成功时为null)
|
||||
VideoURL string `json:"video_url"` // 视频URL(成功时生成)
|
||||
}
|
||||
|
||||
type SoraError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *SoraAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for Sora video task")
|
||||
}
|
||||
|
||||
// 是否调用官方 Sora 接口
|
||||
isOfficial := false
|
||||
if v, ok := paramsMap["is_official"].(bool); ok {
|
||||
isOfficial = v
|
||||
}
|
||||
|
||||
// 提取通用参数
|
||||
model, ok := paramsMap["model"].(string)
|
||||
if !ok || model == "" {
|
||||
return CreateTaskResponse{}, fmt.Errorf("model 参数必填")
|
||||
}
|
||||
|
||||
size, _ := paramsMap["size"].(string)
|
||||
|
||||
seconds := "10" // 默认 10 秒
|
||||
if v, ok := paramsMap["seconds"].(string); ok && v != "" {
|
||||
seconds = v
|
||||
} else if duration, ok := paramsMap["duration"].(float64); ok {
|
||||
seconds = fmt.Sprintf("%.0f", duration)
|
||||
} else if duration, ok := paramsMap["duration"].(int); ok {
|
||||
seconds = fmt.Sprintf("%d", duration)
|
||||
}
|
||||
|
||||
watermark := false
|
||||
if v, ok := paramsMap["watermark"].(bool); ok {
|
||||
watermark = v
|
||||
}
|
||||
|
||||
// 处理图生视频(input_reference 参数)
|
||||
// 支持单个字符串或数组的第一个元素
|
||||
var imageURL string
|
||||
if inputRef, ok := paramsMap["input_reference"].(string); ok && inputRef != "" {
|
||||
imageURL = inputRef
|
||||
} else if images, ok := paramsMap["images"].([]interface{}); ok && len(images) > 0 {
|
||||
// 兼容旧的 images 参数格式
|
||||
if imgStr, ok := images[0].(string); ok && imgStr != "" {
|
||||
imageURL = imgStr
|
||||
}
|
||||
} else if image, ok := paramsMap["image"].(string); ok && image != "" {
|
||||
// 兼容 image 参数
|
||||
imageURL = image
|
||||
}
|
||||
|
||||
// 官方 Sora:使用 multipart/form-data 携带文件
|
||||
if isOfficial && imageURL != "" {
|
||||
return a.createOfficialSoraTask(task, videoConfig, model, size, seconds, imageURL)
|
||||
}
|
||||
|
||||
// 其他场景:保持原来的 JSON 调用,input_reference 继续传 URL 字符串
|
||||
reqBody := SoraCreateRequest{
|
||||
Model: model,
|
||||
Prompt: task.Prompt,
|
||||
Size: size,
|
||||
Seconds: seconds,
|
||||
Watermark: watermark,
|
||||
}
|
||||
|
||||
if imageURL != "" {
|
||||
reqBody.InputReference = imageURL
|
||||
}
|
||||
|
||||
// 发送 JSON 请求
|
||||
apiURL := fmt.Sprintf("%s/v1/videos", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res SoraCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态:queued -> pending
|
||||
state := res.Status
|
||||
if state == "queued" || state == "in_progress" || state == "" {
|
||||
state = "pending"
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.ID,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: state,
|
||||
CreatedAt: time.Unix(res.CreatedAt, 0).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createOfficialSoraTask 调用官方 Sora API,使用 multipart/form-data 携带图片文件
|
||||
func (a *SoraAdapter) createOfficialSoraTask(task types.VideoTask, videoConfig *types.VideoConfig, model, size, seconds, imageURL string) (CreateTaskResponse, error) {
|
||||
if videoConfig == nil || videoConfig.ApiURL == "" || videoConfig.ApiKey == "" {
|
||||
return CreateTaskResponse{}, fmt.Errorf("Sora 视频配置不完整")
|
||||
}
|
||||
|
||||
imgData, err := downloadImageBytes(imageURL)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("下载参考图片失败:%v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
// 文本字段
|
||||
if err = writer.WriteField("prompt", task.Prompt); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
if err = writer.WriteField("model", model); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
if size != "" {
|
||||
if err = writer.WriteField("size", size); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
}
|
||||
if seconds != "" {
|
||||
if err = writer.WriteField("seconds", seconds); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// 文件字段
|
||||
fileWriter, err := writer.CreateFormFile("input_reference", "image")
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
if _, err = fileWriter.Write(imgData); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
if err = writer.Close(); err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/v1/videos", videoConfig.ApiURL)
|
||||
req, err := http.NewRequest(http.MethodPost, apiURL, &buf)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+videoConfig.ApiKey)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
client := &http.Client{Timeout: 3 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求官方 Sora API 出错:%v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求官方 Sora API 出错:%d, %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res SoraCreateResponse
|
||||
if err = json.Unmarshal(body, &res); err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析官方 Sora API 数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
state := res.Status
|
||||
if state == "queued" || state == "in_progress" || state == "" {
|
||||
state = "pending"
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.ID,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: state,
|
||||
CreatedAt: time.Unix(res.CreatedAt, 0).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// downloadImageBytes 下载远程图片并返回二进制内容,用于 multipart 文件上传
|
||||
func downloadImageBytes(imageURL string) ([]byte, error) {
|
||||
body, _, err := utils.FetchURLBytes(context.Background(), imageURL, "", 3*time.Minute, 2, 32<<20)
|
||||
return body, err
|
||||
}
|
||||
|
||||
// downloadImageAsDataURL 下载远程图片并转为 data URL,避免向官方 Sora 直接传地址
|
||||
// QueryTask 查询任务状态
|
||||
func (a *SoraAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/v1/videos/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res SoraQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态:queued -> pending, completed -> success
|
||||
state := res.Status
|
||||
switch state {
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "failed":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 处理错误信息
|
||||
errMsg := ""
|
||||
if res.Error != nil {
|
||||
errMsg = res.Error.Message
|
||||
} else {
|
||||
errMsg = fmt.Sprintf("进度: %d%%", res.Progress)
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.ID,
|
||||
Status: state,
|
||||
Progress: res.Progress,
|
||||
VideoURL: res.VideoURL,
|
||||
Prompt: "", // Sora API 响应中不包含 prompt 字段
|
||||
ErrMsg: errMsg,
|
||||
StatusMsg: fmt.Sprintf("进度: %d%%", res.Progress),
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VideoAdapter 视频生成适配器接口
|
||||
type VideoAdapter interface {
|
||||
// CreateTask 创建视频生成任务
|
||||
CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error)
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error)
|
||||
|
||||
// GetProvider 获取服务提供商名称(不带版本号:veo, sora, luma)
|
||||
GetProvider() string
|
||||
}
|
||||
|
||||
// VeoAdapter Veo 视频生成适配器
|
||||
type VeoAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewVeoAdapter 创建 Veo 适配器
|
||||
func NewVeoAdapter(db *gorm.DB) *VeoAdapter {
|
||||
return &VeoAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *VeoAdapter) GetProvider() string {
|
||||
return "veo"
|
||||
}
|
||||
|
||||
// VeoCreateRequest Veo 创建任务请求
|
||||
type VeoCreateRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
EnhancePrompt bool `json:"enhance_prompt,omitempty"`
|
||||
EnableUpsample bool `json:"enable_upsample,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
Images []string `json:"images,omitempty"` // 图生视频时使用
|
||||
}
|
||||
|
||||
// VeoCreateResponse Veo 创建任务响应
|
||||
type VeoCreateResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
}
|
||||
|
||||
// VeoQueryResponse Veo 查询任务响应
|
||||
type VeoQueryResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
SubmitTime int64 `json:"submit_time"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
FinishTime int64 `json:"finish_time"`
|
||||
Progress string `json:"progress"`
|
||||
Data VeoQueryData `json:"data"`
|
||||
SearchItem string `json:"search_item"`
|
||||
}
|
||||
|
||||
// VeoQueryData Veo 查询响应中的 data 字段
|
||||
type VeoQueryData struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *VeoAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for Veo video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := VeoCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.Model = model
|
||||
}
|
||||
if enhancePrompt, ok := paramsMap["enhance_prompt"].(bool); ok {
|
||||
reqBody.EnhancePrompt = enhancePrompt
|
||||
}
|
||||
if enableUpsample, ok := paramsMap["enable_upsample"].(bool); ok {
|
||||
reqBody.EnableUpsample = enableUpsample
|
||||
}
|
||||
if aspectRatio, ok := paramsMap["aspect_ratio"].(string); ok {
|
||||
reqBody.AspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
// 处理图生视频(images 参数)
|
||||
if images, ok := paramsMap["images"].([]interface{}); ok {
|
||||
imageUrls := make([]string, 0)
|
||||
for _, img := range images {
|
||||
if imgStr, ok := img.(string); ok {
|
||||
imageUrls = append(imageUrls, imgStr)
|
||||
}
|
||||
}
|
||||
if len(imageUrls) > 0 {
|
||||
reqBody.Images = imageUrls
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res VeoCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: types.VideoStatusPending,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *VeoAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res VeoQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态(SUCCESS -> success, FAILED -> failed, 其他保持原样)
|
||||
state := strings.ToLower(res.Status)
|
||||
switch state {
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "failed":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 解析进度(从 "100%" 转换为 100)
|
||||
progress := 0
|
||||
if res.Progress != "" {
|
||||
// 移除 % 符号并转换为整数
|
||||
progressStr := strings.TrimSuffix(res.Progress, "%")
|
||||
if p, err := fmt.Sscanf(progressStr, "%d", &progress); err == nil && p == 1 {
|
||||
// 成功解析
|
||||
}
|
||||
}
|
||||
|
||||
// 从 data.output 中提取视频 URL
|
||||
videoURL := res.Data.Output
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Status: state,
|
||||
Progress: progress,
|
||||
VideoURL: videoURL,
|
||||
ErrMsg: res.FailReason,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package adapters
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WanAdapter Wan(通义万相)视频生成适配器
|
||||
type WanAdapter struct {
|
||||
db *gorm.DB
|
||||
httpClient *req.Client
|
||||
}
|
||||
|
||||
// NewWanAdapter 创建 Wan 适配器
|
||||
func NewWanAdapter(db *gorm.DB) *WanAdapter {
|
||||
return &WanAdapter{
|
||||
db: db,
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider 获取服务提供商名称
|
||||
func (a *WanAdapter) GetProvider() string {
|
||||
return "wan"
|
||||
}
|
||||
|
||||
// WanCreateRequest Wan 创建任务请求
|
||||
type WanCreateRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
PromptExtend bool `json:"prompt_extend,omitempty"`
|
||||
}
|
||||
|
||||
// WanCreateResponse Wan 创建任务响应
|
||||
type WanCreateResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
}
|
||||
|
||||
// WanQueryResponse Wan 查询任务响应
|
||||
type WanQueryResponse struct {
|
||||
TaskId string `json:"task_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
SubmitTime int64 `json:"submit_time"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
FinishTime int64 `json:"finish_time"`
|
||||
Progress string `json:"progress"`
|
||||
Data WanQueryData `json:"data"`
|
||||
SearchItem string `json:"search_item"`
|
||||
}
|
||||
|
||||
// WanQueryData Wan 查询响应中的 data 字段
|
||||
type WanQueryData struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
// CreateTask 创建视频生成任务
|
||||
func (a *WanAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoConfig) (CreateTaskResponse, error) {
|
||||
// 解析任务参数
|
||||
paramsMap, ok := task.Params.(map[string]any)
|
||||
if !ok {
|
||||
return CreateTaskResponse{}, fmt.Errorf("invalid params type for Wan video task")
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
reqBody := WanCreateRequest{
|
||||
Prompt: task.Prompt,
|
||||
}
|
||||
|
||||
// 从 params 中提取参数
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
reqBody.Model = model
|
||||
}
|
||||
if duration, ok := paramsMap["duration"].(float64); ok {
|
||||
reqBody.Duration = int(duration)
|
||||
} else if duration, ok := paramsMap["duration"].(int); ok {
|
||||
reqBody.Duration = duration
|
||||
}
|
||||
if resolution, ok := paramsMap["resolution"].(string); ok {
|
||||
reqBody.Resolution = resolution
|
||||
}
|
||||
if images, ok := paramsMap["images"].([]any); ok {
|
||||
imageUrls := make([]string, 0)
|
||||
for _, img := range images {
|
||||
if imgStr, ok := img.(string); ok {
|
||||
imageUrls = append(imageUrls, imgStr)
|
||||
}
|
||||
}
|
||||
if len(imageUrls) > 0 {
|
||||
reqBody.Images = imageUrls
|
||||
}
|
||||
}
|
||||
if negativePrompt, ok := paramsMap["negative_prompt"].(string); ok {
|
||||
reqBody.NegativePrompt = negativePrompt
|
||||
}
|
||||
if promptExtend, ok := paramsMap["prompt_extend"].(bool); ok {
|
||||
reqBody.PromptExtend = promptExtend
|
||||
}
|
||||
|
||||
logger.Debugf("WanCreateRequest: %+v", reqBody)
|
||||
|
||||
// 发送请求
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations", videoConfig.ApiURL)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return CreateTaskResponse{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res WanCreateResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return CreateTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Channel: videoConfig.ApiURL,
|
||||
Prompt: task.Prompt,
|
||||
State: "pending",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryTask 查询任务状态
|
||||
func (a *WanAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/v2/videos/generations/%s", videoConfig.ApiURL, taskId)
|
||||
r, err := a.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+videoConfig.ApiKey).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
return QueryTaskResponse{}, fmt.Errorf("API 返回失败:%d, %s", r.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var res WanQueryResponse
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return QueryTaskResponse{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// 转换状态(SUCCESS -> success, FAILED -> failed, 其他保持原样)
|
||||
state := strings.ToLower(res.Status)
|
||||
switch state {
|
||||
case "in_progress", "running":
|
||||
state = types.VideoStatusInProgress
|
||||
case "completed", "succeed", "success":
|
||||
state = types.VideoStatusSuccess
|
||||
case "failed", "failure":
|
||||
state = types.VideoStatusFailed
|
||||
default:
|
||||
state = types.VideoStatusPending
|
||||
}
|
||||
|
||||
// 解析进度(从 "100%" 转换为 100)
|
||||
progress := 0
|
||||
if res.Progress != "" {
|
||||
// 移除 % 符号并转换为整数
|
||||
progressStr := strings.TrimSuffix(res.Progress, "%")
|
||||
if p, err := fmt.Sscanf(progressStr, "%d", &progress); err == nil && p == 1 {
|
||||
// 成功解析
|
||||
}
|
||||
}
|
||||
|
||||
// 从 data.output 中提取视频 URL
|
||||
videoURL := res.Data.Output
|
||||
|
||||
// 构建响应
|
||||
response := QueryTaskResponse{
|
||||
TaskId: res.TaskId,
|
||||
Status: state,
|
||||
Progress: progress,
|
||||
VideoURL: videoURL,
|
||||
ErrMsg: res.FailReason,
|
||||
}
|
||||
|
||||
// 如果有原始数据,转换为 JSON 字符串
|
||||
if len(body) > 0 {
|
||||
response.Output = string(body)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package video
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetVideoConfig 从数据库获取视频配置
|
||||
func GetVideoConfig(db *gorm.DB) (*types.VideoConfig, error) {
|
||||
var config model.Config
|
||||
err := db.Where("name", types.ConfigKeyVideo).First(&config).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New("视频配置不存在,请在管理后台配置")
|
||||
}
|
||||
return nil, fmt.Errorf("获取视频配置失败: %v", err)
|
||||
}
|
||||
|
||||
var videoConfig types.VideoConfig
|
||||
err = utils.JsonDecode(config.Value, &videoConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析视频配置失败: %v", err)
|
||||
}
|
||||
|
||||
return &videoConfig, nil
|
||||
}
|
||||
|
||||
// GetModelPowerConfig 获取指定模型的算力配置
|
||||
func GetModelPowerConfig(db *gorm.DB, modelKey string) (*types.VideoModelPower, error) {
|
||||
config, err := GetVideoConfig(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modelPower, ok := config.VideoPowers[modelKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("模型 %s 的算力配置不存在", modelKey)
|
||||
}
|
||||
|
||||
return &modelPower, nil
|
||||
}
|
||||
|
||||
// CalculatePower 根据 modelKey 和 priceKey 计算算力
|
||||
// modelKey: 模型标识(如 "veo-2.0", "sora-2.0")
|
||||
// priceKey: 价格键(如 "fixed", "5_720P", "std_5_sound" 等)
|
||||
func CalculatePower(db *gorm.DB, modelKey string, priceKey string) (int, error) {
|
||||
if priceKey == "" {
|
||||
return 0, errors.New("priceKey 不能为空")
|
||||
}
|
||||
|
||||
modelPower, err := GetModelPowerConfig(db, modelKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
power, ok := modelPower.PowerConfig[priceKey]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("模型 %s 的价格配置 %s 不存在", modelKey, priceKey)
|
||||
}
|
||||
|
||||
if power <= 0 {
|
||||
return 0, fmt.Errorf("模型 %s 的价格配置 %s 的值无效", modelKey, priceKey)
|
||||
}
|
||||
|
||||
return power, nil
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
package video
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
|
||||
type Service struct {
|
||||
httpClient *req.Client
|
||||
db *gorm.DB
|
||||
uploadManager *oss.UploaderManager
|
||||
taskQueue *store.RedisQueue
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Client, userService *service.UserService) *Service {
|
||||
return &Service{
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
db: db,
|
||||
taskQueue: store.NewRedisQueue("Video_Task_Queue", redisCli),
|
||||
uploadManager: manager,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PushTask(task types.VideoTask) {
|
||||
logger.Infof("add a new Video task to the task list: %+v", task)
|
||||
if err := s.taskQueue.RPush(task); err != nil {
|
||||
logger.Errorf("push video task to queue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的任务加载到队列
|
||||
var jobs []model.VideoJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.VideoTask
|
||||
err := utils.JsonDecode(v.TaskInfo, &task)
|
||||
if err != nil {
|
||||
logger.Errorf("decode task info with error: %v", err)
|
||||
continue
|
||||
}
|
||||
task.Id = v.Id
|
||||
s.PushTask(task)
|
||||
}
|
||||
logger.Info("Starting Video job consumer...")
|
||||
go func() {
|
||||
for {
|
||||
var task types.VideoTask
|
||||
err := s.taskQueue.LPop(&task)
|
||||
if err != nil {
|
||||
logger.Errorf("taking task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if task.Type == types.VideoLuma {
|
||||
// translate prompt
|
||||
if utils.HasChinese(task.Prompt) {
|
||||
content, err := utils.OpenAIRequest(s.db, fmt.Sprintf(service.TranslatePromptTemplate, task.Prompt), task.TranslateModelId)
|
||||
if err == nil {
|
||||
task.Prompt = content
|
||||
} else {
|
||||
logger.Warnf("error with translate prompt: %v", err)
|
||||
}
|
||||
}
|
||||
var r LumaRespVo
|
||||
r, err = s.LumaCreate(task)
|
||||
if err != nil {
|
||||
logger.Errorf("create task with error: %v", err)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"err_msg": err.Error(),
|
||||
"progress": service.FailTaskProgress,
|
||||
"cover_url": "/images/failed.jpg",
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新任务信息
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"task_id": r.Id,
|
||||
"channel": r.Channel,
|
||||
"prompt_ext": r.Prompt,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
s.PushTask(task)
|
||||
}
|
||||
} else if task.Type == types.VideoKeLing {
|
||||
var r KeLingRespVo
|
||||
r, err = s.KeLingCreate(task)
|
||||
logger.Debugf("ke ling create task result: %+v", r)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("create task with error: %v", err)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"err_msg": err.Error(),
|
||||
"progress": service.FailTaskProgress,
|
||||
"cover_url": "/images/failed.jpg",
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新任务信息
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"task_id": r.Data.TaskID,
|
||||
"channel": r.Channel,
|
||||
"prompt_ext": task.Prompt,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
s.PushTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) DownloadFiles() {
|
||||
go func() {
|
||||
var items []model.VideoJob
|
||||
for {
|
||||
res := s.db.Where("progress", 102).Find(&items)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range items {
|
||||
if v.WaterURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("try download video: %s", v.WaterURL)
|
||||
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.WaterURL, ".mp4", true)
|
||||
if err != nil {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("download video success: %s", videoURL)
|
||||
v.WaterURL = videoURL
|
||||
|
||||
if v.VideoURL != "" {
|
||||
logger.Infof("try download no water video: %s", v.VideoURL)
|
||||
videoURL, err = s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, ".mp4", true)
|
||||
if err != nil {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
logger.Infof("download no water video success: %s", videoURL)
|
||||
v.VideoURL = videoURL
|
||||
v.Progress = 100
|
||||
s.db.Updates(&v)
|
||||
|
||||
// Convert TaskInfo to VideoTask
|
||||
var videoTask types.VideoTask
|
||||
if err := json.Unmarshal([]byte(v.TaskInfo), &videoTask); err != nil {
|
||||
logger.Errorf("failed to unmarshal task info to VideoTask: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SyncTaskProgress 异步拉取任务
|
||||
func (s *Service) SyncTaskProgress() {
|
||||
go func() {
|
||||
var jobs []model.VideoJob
|
||||
for {
|
||||
res := s.db.Where("progress < ?", 100).Where("task_id <> ?", "").Find(&jobs)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
if job.Type == types.VideoLuma {
|
||||
task, err := s.QueryLumaTask(job.TaskId, job.Channel)
|
||||
if err != nil {
|
||||
logger.Errorf("query task with error: %v", err)
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress, // 102 表示资源未下载完成,
|
||||
"err_msg": err.Error(),
|
||||
"cover_url": "/images/failed.jpg",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("task: %+v", task)
|
||||
if task.State == "completed" { // 更新任务信息
|
||||
data := map[string]interface{}{
|
||||
"progress": 102, // 102 表示资源未下载完成,
|
||||
"water_url": task.Video.Url,
|
||||
"raw_data": utils.JsonEncode(task),
|
||||
"prompt_ext": task.Prompt,
|
||||
"cover_url": task.Thumbnail.Url,
|
||||
}
|
||||
if task.Video.DownloadUrl != "" {
|
||||
data["video_url"] = task.Video.DownloadUrl
|
||||
}
|
||||
err = s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(data).Error
|
||||
if err != nil {
|
||||
logger.Errorf("更新数据库失败:%v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else if job.Type == types.VideoKeLing {
|
||||
// Convert TaskInfo to VideoTask
|
||||
var videoTask types.VideoTask
|
||||
if err := json.Unmarshal([]byte(job.TaskInfo), &videoTask); err != nil {
|
||||
logger.Errorf("failed to unmarshal task info to VideoTask: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Type assert task.Params to KeLingVideoParams
|
||||
paramsMap, ok := videoTask.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert map to KeLingVideoParams
|
||||
paramsBytes, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var params types.KeLingVideoParams
|
||||
if err := json.Unmarshal(paramsBytes, ¶ms); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := s.QueryKeLingTask(job.TaskId, job.Channel, params.TaskType)
|
||||
if err != nil {
|
||||
logger.Errorf("query task with error: %v", err)
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress, // 102 表示资源未下载完成,
|
||||
"err_msg": err.Error(),
|
||||
"cover_url": "/images/failed.jpg",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("task: %+v", task)
|
||||
if task.TaskStatus == "succeed" { // 更新任务信息
|
||||
data := map[string]interface{}{
|
||||
"progress": 102, // 102 表示资源未下载完成,
|
||||
"water_url": task.TaskResult.Videos[0].URL,
|
||||
"raw_data": utils.JsonEncode(task),
|
||||
"prompt_ext": job.Prompt,
|
||||
"cover_url": "",
|
||||
}
|
||||
if len(task.TaskResult.Videos) > 0 {
|
||||
data["video_url"] = task.TaskResult.Videos[0].URL
|
||||
}
|
||||
err = s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(data).Error
|
||||
if err != nil {
|
||||
logger.Errorf("更新数据库失败:%v", err)
|
||||
continue
|
||||
}
|
||||
} else if task.TaskStatus == "failed" {
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress,
|
||||
"err_msg": task.TaskStatusMsg,
|
||||
"cover_url": "/images/failed.jpg",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.Type,
|
||||
Remark: fmt.Sprintf("%s 任务失败,退回算力。任务ID:%s,Err:%s", job.Type, job.TaskId, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 更新任务状态
|
||||
s.db.Model(&job).UpdateColumn("power", 0)
|
||||
}
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type LumaTaskVo struct {
|
||||
Id string `json:"id"`
|
||||
Liked interface{} `json:"liked"`
|
||||
State string `json:"state"`
|
||||
Video struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
DownloadUrl string `json:"download_url"`
|
||||
} `json:"video"`
|
||||
Prompt string `json:"prompt"`
|
||||
UserId string `json:"user_id"`
|
||||
BatchId string `json:"batch_id"`
|
||||
Thumbnail struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"thumbnail"`
|
||||
VideoRaw struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"video_raw"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastFrame struct {
|
||||
Url string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"last_frame"`
|
||||
}
|
||||
|
||||
type LumaRespVo struct {
|
||||
Id string `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
State string `json:"state"`
|
||||
QueueState interface{} `json:"queue_state"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Video interface{} `json:"video"`
|
||||
VideoRaw interface{} `json:"video_raw"`
|
||||
Liked interface{} `json:"liked"`
|
||||
EstimateWaitSeconds interface{} `json:"estimate_wait_seconds"`
|
||||
Thumbnail interface{} `json:"thumbnail"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) LumaCreate(task types.VideoTask) (LumaRespVo, error) {
|
||||
// 读取 API KEY
|
||||
var apiKey model.ApiKey
|
||||
session := s.db.Session(&gorm.Session{}).Where("type", "luma").Where("enabled", true)
|
||||
if task.Channel != "" {
|
||||
session = session.Where("api_url", task.Channel)
|
||||
}
|
||||
tx := session.Order("last_used_at DESC").First(&apiKey)
|
||||
if tx.Error != nil {
|
||||
return LumaRespVo{}, errors.New("no available API KEY for Luma")
|
||||
}
|
||||
|
||||
// Type assert task.Params to LumaVideoParams
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return LumaRespVo{}, errors.New("invalid params type for Luma video task")
|
||||
}
|
||||
|
||||
// Convert map to LumaVideoParams
|
||||
paramsBytes, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("failed to marshal params: %v", err)
|
||||
}
|
||||
|
||||
var params types.LumaVideoParams
|
||||
if err := json.Unmarshal(paramsBytes, ¶ms); err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("failed to unmarshal params: %v", err)
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"user_prompt": task.Prompt,
|
||||
"expand_prompt": params.PromptOptimize,
|
||||
"loop": params.Loop,
|
||||
"image_url": params.StartImgURL, // 图生视频
|
||||
"image_end_url": params.EndImgURL, // 图生视频
|
||||
}
|
||||
|
||||
var res LumaRespVo
|
||||
apiURL := fmt.Sprintf("%s/luma/generations", apiKey.ApiURL)
|
||||
logger.Debugf("API URL: %s, request body: %+v", apiURL, reqBody)
|
||||
r, err := req.C().R().
|
||||
SetHeader("Authorization", "Bearer "+apiKey.Value).
|
||||
SetBody(reqBody).
|
||||
Post(apiURL)
|
||||
if err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("请求 API 出错:%v", err)
|
||||
}
|
||||
|
||||
if r.StatusCode != 200 && r.StatusCode != 201 {
|
||||
return LumaRespVo{}, fmt.Errorf("请求 API 出错:%d, %s", r.StatusCode, r.String())
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return LumaRespVo{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
// update the last_use_at for api key
|
||||
apiKey.LastUsedAt = time.Now().Unix()
|
||||
session.Updates(&apiKey)
|
||||
res.Channel = apiKey.ApiURL
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryLumaTask(taskId string, channel string) (LumaTaskVo, error) {
|
||||
// 读取 API KEY
|
||||
var apiKey model.ApiKey
|
||||
err := s.db.Session(&gorm.Session{}).Where("type", "luma").
|
||||
Where("api_url", channel).
|
||||
Where("enabled", true).
|
||||
Order("last_used_at DESC").First(&apiKey).Error
|
||||
if err != nil {
|
||||
return LumaTaskVo{}, errors.New("no available API KEY for Luma")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/luma/generations/%s", apiKey.ApiURL, taskId)
|
||||
var res LumaTaskVo
|
||||
r, err := req.C().R().SetHeader("Authorization", "Bearer "+apiKey.Value).Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return LumaTaskVo{}, fmt.Errorf("请求 API 失败:%v", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
return LumaTaskVo{}, fmt.Errorf("API 返回失败:%v", r.String())
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return LumaTaskVo{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type KeLingRespVo struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
} `json:"data"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) KeLingCreate(task types.VideoTask) (KeLingRespVo, error) {
|
||||
var apiKey model.ApiKey
|
||||
session := s.db.Session(&gorm.Session{}).Where("type", "keling").Where("enabled", true)
|
||||
if task.Channel != "" {
|
||||
session = session.Where("api_url", task.Channel)
|
||||
}
|
||||
tx := session.Order("last_used_at DESC").First(&apiKey)
|
||||
if tx.Error != nil {
|
||||
return KeLingRespVo{}, errors.New("no available API KEY for keling")
|
||||
}
|
||||
|
||||
// Type assert task.Params to KeLingVideoParams
|
||||
paramsMap, ok := task.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
return KeLingRespVo{}, errors.New("invalid params type for KeLing video task")
|
||||
}
|
||||
|
||||
// Convert map to KeLingVideoParams
|
||||
paramsBytes, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to marshal params: %v", err)
|
||||
}
|
||||
|
||||
var params types.KeLingVideoParams
|
||||
if err := json.Unmarshal(paramsBytes, ¶ms); err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to unmarshal params: %v", err)
|
||||
}
|
||||
|
||||
// 2. 构建API请求参数
|
||||
payload := map[string]interface{}{
|
||||
"model_name": params.Model,
|
||||
"prompt": task.Prompt,
|
||||
"negative_prompt": params.NegPrompt,
|
||||
"cfg_scale": params.CfgScale,
|
||||
"mode": params.Mode,
|
||||
"aspect_ratio": params.AspectRatio,
|
||||
"duration": params.Duration,
|
||||
}
|
||||
|
||||
// 只有当 CameraControl 的类型不为空时,才处理摄像机控制参数
|
||||
if params.CameraControl.Type != "" {
|
||||
cameraControl := map[string]interface{}{
|
||||
"type": params.CameraControl.Type,
|
||||
}
|
||||
|
||||
// 只有在 simple 类型时才添加 config 参数
|
||||
if params.CameraControl.Type == "simple" {
|
||||
cameraControl["config"] = params.CameraControl.Config
|
||||
}
|
||||
|
||||
payload["camera_control"] = cameraControl
|
||||
}
|
||||
|
||||
// 处理图生视频
|
||||
if params.TaskType == "image2video" {
|
||||
payload["image"] = params.Image
|
||||
payload["image_tail"] = params.ImageTail
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
// 3. 准备HTTP请求
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s", apiKey.ApiURL, params.TaskType)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonPayload))
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey.Value)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 4. 发送请求
|
||||
client := &http.Client{Timeout: time.Duration(30) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 5. 处理响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return KeLingRespVo{}, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResponse = KeLingRespVo{}
|
||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||||
return KeLingRespVo{}, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
// 设置 API 通道
|
||||
apiResponse.Channel = apiKey.ApiURL
|
||||
return apiResponse, nil
|
||||
}
|
||||
|
||||
// VideoCallbackData 表示视频生成任务的回调数据
|
||||
type VideoCallbackData struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
TaskResult TaskResult `json:"task_result"`
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
Images []CallBackImageResult `json:"images,omitempty"`
|
||||
Videos []CallBackVideoResult `json:"videos,omitempty"`
|
||||
}
|
||||
|
||||
type CallBackImageResult struct {
|
||||
Index int `json:"index"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type CallBackVideoResult struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
func (s *Service) QueryKeLingTask(taskId string, channel string, action string) (VideoCallbackData, error) {
|
||||
var apiKey model.ApiKey
|
||||
err := s.db.Session(&gorm.Session{}).Where("type", "keling").
|
||||
//Where("api_url", channel).
|
||||
Where("enabled", true).
|
||||
Order("last_used_at DESC").First(&apiKey).Error
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, errors.New("no available API KEY for keling")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/kling/v1/videos/%s/%s", apiKey.ApiURL, action, taskId)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey.Value)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return VideoCallbackData{}, fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data VideoCallbackData `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return VideoCallbackData{}, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return VideoCallbackData{}, fmt.Errorf("API error: %s", response.Message)
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package video
|
||||
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
|
||||
// * Use of this source code is governed by a Apache-2.0 license
|
||||
// * that can be found in the LICENSE file.
|
||||
// * @Author yangjian102621@163.com
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/log"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/service/video/adapters"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = log.GetLogger()
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
uploadManager *oss.UploaderManager
|
||||
taskQueue *store.RedisQueue
|
||||
userService *service.UserService
|
||||
adapters map[string]adapters.VideoAdapter // provider -> adapter
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Client, userService *service.UserService) *Service {
|
||||
service := &Service{
|
||||
db: db,
|
||||
taskQueue: store.NewRedisQueue("Video_Task_Queue", redisCli),
|
||||
uploadManager: manager,
|
||||
userService: userService,
|
||||
adapters: make(map[string]VideoAdapter),
|
||||
}
|
||||
|
||||
// 注册所有适配器
|
||||
service.registerAdapters()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// VideoAdapter 类型别名,指向 adapters.VideoAdapter
|
||||
type VideoAdapter = adapters.VideoAdapter
|
||||
|
||||
// registerAdapters 注册所有视频生成适配器
|
||||
func (s *Service) registerAdapters() {
|
||||
// 注册 Veo 适配器
|
||||
veoAdapter := adapters.NewVeoAdapter(s.db)
|
||||
s.adapters[veoAdapter.GetProvider()] = veoAdapter
|
||||
|
||||
// 注册 Sora 适配器
|
||||
soraAdapter := adapters.NewSoraAdapter(s.db)
|
||||
s.adapters[soraAdapter.GetProvider()] = soraAdapter
|
||||
|
||||
// 注册 Luma 适配器
|
||||
lumaAdapter := adapters.NewLumaAdapter(s.db)
|
||||
s.adapters[lumaAdapter.GetProvider()] = lumaAdapter
|
||||
|
||||
// 注册可灵适配器
|
||||
kelingAdapter := adapters.NewKelingAdapter(s.db)
|
||||
s.adapters[kelingAdapter.GetProvider()] = kelingAdapter
|
||||
|
||||
// 注册 MiniMax 适配器
|
||||
minimaxAdapter := adapters.NewMiniMaxAdapter(s.db)
|
||||
s.adapters[minimaxAdapter.GetProvider()] = minimaxAdapter
|
||||
|
||||
// 注册 Wan 适配器
|
||||
wanAdapter := adapters.NewWanAdapter(s.db)
|
||||
s.adapters[wanAdapter.GetProvider()] = wanAdapter
|
||||
|
||||
// 注册 Doubao 适配器
|
||||
doubaoAdapter := adapters.NewDoubaoAdapter(s.db)
|
||||
s.adapters[doubaoAdapter.GetProvider()] = doubaoAdapter
|
||||
}
|
||||
|
||||
// getAdapter 获取指定 provider 的适配器
|
||||
func (s *Service) getAdapter(provider string) (adapters.VideoAdapter, error) {
|
||||
adapter, ok := s.adapters[provider]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的视频生成服务提供商: %s", provider)
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
// getVideoConfig 获取视频配置
|
||||
func (s *Service) getVideoConfig() (*types.VideoConfig, error) {
|
||||
return GetVideoConfig(s.db)
|
||||
}
|
||||
|
||||
// CreateTask 统一的创建任务方法
|
||||
func (s *Service) CreateTask(task types.VideoTask) (adapters.CreateTaskResponse, error) {
|
||||
// 获取适配器
|
||||
adapter, err := s.getAdapter(task.Type)
|
||||
if err != nil {
|
||||
return adapters.CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 获取视频配置
|
||||
videoConfig, err := s.getVideoConfig()
|
||||
if err != nil {
|
||||
return adapters.CreateTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 调用适配器创建任务
|
||||
return adapter.CreateTask(task, videoConfig)
|
||||
}
|
||||
|
||||
// QueryTask 统一的查询任务方法
|
||||
func (s *Service) QueryTask(provider string, taskId string, channel string, modelKey string) (adapters.QueryTaskResponse, error) {
|
||||
// 获取适配器
|
||||
adapter, err := s.getAdapter(provider)
|
||||
if err != nil {
|
||||
return adapters.QueryTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 获取视频配置
|
||||
videoConfig, err := s.getVideoConfig()
|
||||
if err != nil {
|
||||
return adapters.QueryTaskResponse{}, err
|
||||
}
|
||||
|
||||
// 调用适配器查询任务
|
||||
return adapter.QueryTask(taskId, channel, videoConfig)
|
||||
}
|
||||
|
||||
func (s *Service) PushTask(task types.VideoTask) {
|
||||
logger.Infof("[video] push task to queue jobId=%d type=%s", task.Id, task.Type)
|
||||
if err := s.taskQueue.RPush(task); err != nil {
|
||||
logger.Errorf("[video] push task to queue failed jobId=%d: %v", task.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的任务加载到队列
|
||||
var jobs []model.VideoJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.VideoTask
|
||||
err := utils.JsonDecode(v.Params, &task)
|
||||
if err != nil {
|
||||
logger.Errorf("decode task info with error: %v", err)
|
||||
continue
|
||||
}
|
||||
task.Id = v.Id
|
||||
s.PushTask(task)
|
||||
}
|
||||
logger.Infof("[video] job consumer started, loaded %d pending jobs from DB", len(jobs))
|
||||
go func() {
|
||||
for {
|
||||
var task types.VideoTask
|
||||
err := s.taskQueue.LPop(&task)
|
||||
if err != nil {
|
||||
logger.Errorf("taking task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("[video] submitting task jobId=%d type=%s prompt=%q", task.Id, task.Type, task.Prompt)
|
||||
r, err := s.CreateTask(task)
|
||||
if err != nil {
|
||||
logger.Errorf("[video] submit failed jobId=%d type=%s: %v", task.Id, task.Type, err)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"err_msg": err.Error(),
|
||||
"status": types.VideoStatusFailed,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("[video] submit success jobId=%d type=%s taskId=%s channel=%s", task.Id, task.Type, r.TaskId, r.Channel)
|
||||
err = s.db.Model(&model.VideoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"task_id": r.TaskId,
|
||||
"channel": r.Channel,
|
||||
"status": types.VideoStatusPending,
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("update task with error: %v", err)
|
||||
s.PushTask(task)
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) DownloadFiles() {
|
||||
go func() {
|
||||
var items []model.VideoJob
|
||||
logger.Info("[video] download files started")
|
||||
for {
|
||||
err := s.db.Where("status", types.VideoStatusDownloading).Find(&items).Error
|
||||
if err != nil {
|
||||
logger.Errorf("get downloading tasks with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range items {
|
||||
if v.VideoURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Infof("try download video: %s", v.VideoURL)
|
||||
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, ".mp4", true)
|
||||
if err != nil {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("download video success: %s", videoURL)
|
||||
s.db.Model(&model.VideoJob{Id: v.Id}).UpdateColumns(map[string]any{
|
||||
"video_url": videoURL,
|
||||
"status": types.VideoStatusSuccess,
|
||||
"progress": 100,
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SyncTaskProgress 异步拉取任务
|
||||
func (s *Service) SyncTaskProgress() {
|
||||
go func() {
|
||||
logger.Info("[video] task status poller started")
|
||||
var jobs []model.VideoJob
|
||||
for {
|
||||
res := s.db.Where("status IN ?", []string{types.VideoStatusInProgress, types.VideoStatusPending}).Where("task_id <> ?", "").Find(&jobs)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
if len(jobs) > 0 {
|
||||
logger.Infof("[video] polling task status, in_progress count=%d", len(jobs))
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
// 检查任务是否超时(超过 2 小时)
|
||||
if time.Since(job.CreatedAt) > 2*time.Hour {
|
||||
logger.Warnf("[video] task timeout jobId=%d taskId=%s created_at=%s", job.Id, job.TaskId, job.CreatedAt.Format(time.RFC3339))
|
||||
err := s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": types.VideoStatusFailed,
|
||||
"err_msg": "任务超时",
|
||||
}).Error
|
||||
if err != nil {
|
||||
logger.Errorf("[video] update timeout task failed jobId=%d: %v", job.Id, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
modelKey := ""
|
||||
var videoTask types.VideoTask
|
||||
if err := json.Unmarshal([]byte(job.Params), &videoTask); err == nil {
|
||||
if paramsMap, ok := videoTask.Params.(map[string]any); ok {
|
||||
if model, ok := paramsMap["model"].(string); ok {
|
||||
modelKey = model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("[video] querying task jobId=%d taskId=%s provider=%s", job.Id, job.TaskId, job.Type)
|
||||
task, err := s.QueryTask(job.Type, job.TaskId, job.Channel, modelKey)
|
||||
if err != nil {
|
||||
logger.Errorf("[video] query failed jobId=%d taskId=%s: %v", job.Id, job.TaskId, err)
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": types.VideoStatusFailed,
|
||||
"err_msg": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("[video] task status jobId=%d taskId=%s status=%s", job.Id, job.TaskId, task.Status)
|
||||
logger.Debugf("[video] output=%s", task.Output)
|
||||
|
||||
if task.Status == types.VideoStatusSuccess {
|
||||
data := map[string]any{
|
||||
"status": types.VideoStatusDownloading,
|
||||
"progress": 100,
|
||||
"output": task.Output,
|
||||
}
|
||||
if task.VideoURL != "" {
|
||||
data["video_url"] = task.VideoURL
|
||||
}
|
||||
err = s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(data).Error
|
||||
if err != nil {
|
||||
logger.Errorf("更新数据库失败:%v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("[video] task completed jobId=%d taskId=%s", job.Id, job.TaskId)
|
||||
} else if task.Status == "failed" {
|
||||
logger.Warnf("[video] task failed jobId=%d taskId=%s err=%s", job.Id, job.TaskId, task.ErrMsg)
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": types.VideoStatusFailed,
|
||||
"err_msg": task.ErrMsg,
|
||||
})
|
||||
} else {
|
||||
s.db.Model(&model.VideoJob{Id: job.Id}).UpdateColumns(map[string]any{
|
||||
"status": task.Status,
|
||||
"progress": task.Progress,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Select("id", "user_id", "power", "task_id", "err_msg", "type").
|
||||
Where("status", types.VideoStatusFailed).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.Type,
|
||||
Remark: fmt.Sprintf("%s 任务失败,退回算力。任务ID:%s,Err:%s", job.Type, job.TaskId, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// 更新任务状态
|
||||
s.db.Model(&job).UpdateColumn("power", 0)
|
||||
}
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user