mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-08-31 03:07: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
|
||||
}
|
||||
Reference in New Issue
Block a user