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:
RockYang
2026-08-11 14:51:20 +08:00
parent 37e024acea
commit 9ccff4efbc
219 changed files with 29212 additions and 10802 deletions
+32
View File
@@ -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
}
+197
View File
@@ -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
}
+347
View File
@@ -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
}
+226
View File
@@ -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
}
+220
View File
@@ -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
}
+77
View File
@@ -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
}
-663
View File
@@ -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, &params); 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%sErr:%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, &params); 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, &params); 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
}
+333
View File
@@ -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%sErr:%s", job.Type, job.TaskId, job.ErrMsg),
})
if err != nil {
continue
}
// 更新任务状态
s.db.Model(&job).UpdateColumn("power", 0)
}
time.Sleep(time.Second * 10)
}
}()
}