feat(relay):新增阿里wan 3.0视频模型支持
This commit is contained in:
1 parent
d7bd0cfb4e
commit
4297739e23
15 files changed
+2466
-20
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -595,6 +595,14 @@ func registerRelayRoutes(server *ghttp.Server) {
|
||||
group.POST("/video_generation", relay.HandleMiniMaxVideoV1Submit)
|
||||
group.GET("/query/video_generation", relay.HandleMiniMaxVideoV1Retrieve)
|
||||
})
|
||||
|
||||
// 阿里 DashScope 官方视频协议端点(wan2.x / wan3.0 通用,阿里 SDK 换 base_url 直连;
|
||||
// 查询回放官方响应形态,轮询计费由网关后台完成)
|
||||
server.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
|
||||
group.POST("/services/aigc/video-generation/video-synthesis", relay.HandleAliVideoSubmit)
|
||||
group.GET("/tasks/{task_id}", relay.HandleAliVideoRetrieve)
|
||||
})
|
||||
}
|
||||
|
||||
// registerLandingPage 在未内嵌前端(非 embedweb 构建)时,为后端根路径注册项目介绍页,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/monitor"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
relay_constant "github.com/qianfree/team-api/relay/constant"
|
||||
relay_handler "github.com/qianfree/team-api/relay/handler"
|
||||
)
|
||||
|
||||
// 阿里 DashScope 官方视频协议入站胶水(/api/v1 端点):
|
||||
// 创建请求在此完成官方请求体解析与通用任务体转换,复用任务提交管线
|
||||
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
|
||||
// 查询直连 relay_handler 的阿里视频处理器(回放 DashScope 官方响应形态)。
|
||||
// 协议文档:docs/协议文档/阿里系列/视频-wan3.0.md
|
||||
|
||||
// HandleAliVideoSubmit 处理 POST /api/v1/services/aigc/video-generation/video-synthesis
|
||||
// (创建视频生成任务,wan2.x / wan3.0 通用)
|
||||
func HandleAliVideoSubmit(r *ghttp.Request) {
|
||||
body := r.GetBody()
|
||||
if len(body) == 0 {
|
||||
writeVideosError(r, 400, "request body is empty", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 官方请求体 → 归一结构(input.media[] 原样保留)
|
||||
req, taskErr := relay_handler.ParseAliVideoCreateRequest(body)
|
||||
if taskErr != nil {
|
||||
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
|
||||
return
|
||||
}
|
||||
if taskErr := relay_handler.ValidateAliVideoCreateRequest(req); taskErr != nil {
|
||||
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
|
||||
return
|
||||
}
|
||||
|
||||
// 转成任务框架通用请求体(适配器消费 media/metadata + 双写通用词汇)
|
||||
taskBody, err := relay_handler.BuildAliVideoTaskBody(req)
|
||||
if err != nil {
|
||||
writeVideosError(r, 500, "build task body failed", "")
|
||||
return
|
||||
}
|
||||
|
||||
rc := &relay_handler.TaskRelayContext{
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
Scope: r.GetCtxVar("ApiKeyScope").String(),
|
||||
ClientIP: r.GetClientIp(),
|
||||
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
|
||||
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
|
||||
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
|
||||
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
|
||||
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
|
||||
// 阿里 DashScope 官方协议:{"output":{"task_id":...}} 响应 + task_ 前缀 ID
|
||||
// (协议标识与 relay/handler.aliVideoProtocol 对齐,对齐 videos.go 胶水层字面量惯例)
|
||||
Protocol: "ali_native",
|
||||
RelayMode: int(relay_constant.RelayModeAliVideo),
|
||||
}
|
||||
|
||||
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
|
||||
channelMeta, err := selectTaskChannel(r, taskBody)
|
||||
if err != nil {
|
||||
writeTaskChannelSelectionError(r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
|
||||
capture := NewResponseCaptureWriter(r.Response.Writer)
|
||||
rc.Writer = capture
|
||||
|
||||
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
|
||||
|
||||
relay_handler.HandleTaskSubmit(
|
||||
r.Context(), taskBody, r.URL.Path, r.Header,
|
||||
rc, taskDataProvider, taskBillingProvider, channelMeta,
|
||||
)
|
||||
|
||||
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
|
||||
if rc.TaskID != "" {
|
||||
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
|
||||
} else {
|
||||
monitor.UnregisterRequest(rc.RequestID)
|
||||
}
|
||||
|
||||
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
|
||||
go recordTaskSubmitAudit(r, rc, capture, taskBody)
|
||||
}
|
||||
|
||||
// HandleAliVideoRetrieve 处理 GET /api/v1/tasks/{task_id}(查询视频任务,回放官方响应)
|
||||
func HandleAliVideoRetrieve(r *ghttp.Request) {
|
||||
taskID := r.Get("task_id").String()
|
||||
if taskID == "" {
|
||||
writeVideosError(r, 400, "task id is required", "")
|
||||
return
|
||||
}
|
||||
|
||||
rc := &relay_handler.TaskRelayContext{
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
}
|
||||
|
||||
relay_handler.HandleAliVideoRetrieve(r.Context(), taskID, rc, taskDataProvider)
|
||||
}
|
||||
@@ -110,9 +110,33 @@ func (GenericScheme) ValidateSchemeConfig(cfg json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SettleTaskCost 通用引擎无素材计量语义:忽略 usage,回退预扣口径
|
||||
// (与 estimateTaskCost 的估算公式一致,结算保持「预扣即终价,token 重算另走 RecalculateByTokens」)。
|
||||
func (g GenericScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, _ *rcommon.TaskMaterialUsage) decimal.Decimal {
|
||||
// SettleTaskCost 通用引擎的结算口径:
|
||||
// - per_second(按秒计费):上游返回实际输出秒数(usage.OutputSeconds > 0)时按
|
||||
// 「实际秒数 × 矩阵单价 × 租户乘数 × 时段乘数」结算,多退少补——查价键优先
|
||||
// usage.Resolution(实际输出分辨率,如阿里 wan3.0 usage.SR),回退 ratios 的
|
||||
// spec.resolution(提交时请求档位)。实际时长与请求时长不一致(智能时长模式、
|
||||
// 上游按内容微调)时以官方计量为准;usage 缺失回退预扣口径。
|
||||
// - 其余模式(token / per_request / tiered)无素材计量语义:忽略 usage,回退预扣口径
|
||||
// (与 estimateTaskCost 的估算公式一致,结算保持「预扣即终价,token 重算另走 RecalculateByTokens」)。
|
||||
func (g GenericScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal {
|
||||
if pricing.BillingMode == "per_second" && usage != nil && usage.OutputSeconds > 0 {
|
||||
duration := usage.OutputSeconds
|
||||
// 结算秒数钳制上限与预扣一致,防御异常大值刷扣
|
||||
if duration > maxTaskDurationSeconds {
|
||||
duration = maxTaskDurationSeconds
|
||||
}
|
||||
spec, _ := ratioString(ratios, "spec.resolution")
|
||||
if usage.Resolution != "" {
|
||||
spec = usage.Resolution
|
||||
}
|
||||
if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 {
|
||||
return NewFromFloat(price).
|
||||
Mul(NewFromFloat(duration)).
|
||||
Mul(NewFromFloat(pricing.TenantMultiplier)).
|
||||
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
}
|
||||
// 矩阵为空/全零:落到下方回退(占位/预扣口径),与预扣分支的兜底行为一致
|
||||
}
|
||||
return g.EstimateTaskCost(pricing, ratios, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
rcommon "github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
// generic per_second 按实际输出秒数结算(方案 A)的行为测试。
|
||||
// 矩阵:480P=0.02 / 720P=0.05 / 1080P=0.10,"*" 兜底 0.05(对齐 wan3.0 三档定价形态)。
|
||||
|
||||
// perSecondTestPricing 构造 generic per_second 测试定价(乘数恒 1,租户折扣单测另行覆盖)
|
||||
func perSecondTestPricing(tenantMul float64) *PricingResult {
|
||||
return &PricingResult{
|
||||
BillingMode: "per_second",
|
||||
TenantMultiplier: tenantMul,
|
||||
PerSecondPrices: map[string]float64{"480P": 0.02, "720P": 0.05, "1080P": 0.10, "*": 0.05},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenericPerSecond_SettleByActualSeconds 结算口径:上游返回实际输出秒数时按
|
||||
// 「实际秒数 × 档位单价 × 租户 × 时段」结算,多退少补;usage 缺失回退预扣口径。
|
||||
func TestGenericPerSecond_SettleByActualSeconds(t *testing.T) {
|
||||
pricing := perSecondTestPricing(1.0)
|
||||
scheme := GenericScheme{}
|
||||
|
||||
// 实际 5.13s(上游浮点秒)× 720P 档:请求 5s 预扣与实际有零头差,按实际结算
|
||||
cost := scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 5.0, "spec.resolution": "720P",
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5.13, Resolution: "720P"})
|
||||
assertDecimal(t, cost, 5.13*0.05, "settle by actual seconds")
|
||||
|
||||
// usage.Resolution 优先于 ratios 提交时档位(请求未指定档位,实际输出 480P)
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 5.0,
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "480P"})
|
||||
assertDecimal(t, cost, 5*0.02, "usage resolution wins over ratios")
|
||||
|
||||
// usage.Resolution 缺失回退 ratios 提交时档位;实际秒数长于请求时补扣
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 5.0, "spec.resolution": "1080P",
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 12})
|
||||
assertDecimal(t, cost, 12*0.10, "fallback resolution, supplement longer actual")
|
||||
|
||||
// 智能时长场景:预扣按上限 30s 冻结,实际生成 12s 结算退差
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 30.0, "spec.resolution": "720P",
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 12, Resolution: "720P"})
|
||||
assertDecimal(t, cost, 12*0.05, "smart duration refunds difference")
|
||||
|
||||
// 异常大秒数钳制 120s(与预扣钳制同源,防 usage 异常刷扣)
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 5.0, "spec.resolution": "720P",
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 99999, Resolution: "720P"})
|
||||
assertDecimal(t, cost, 120*0.05, "clamp abnormal seconds")
|
||||
|
||||
// 档位未命中矩阵 → "*" 兜底价
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{},
|
||||
&rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "360P"})
|
||||
assertDecimal(t, cost, 5*0.05, "unknown resolution falls back to wildcard")
|
||||
|
||||
// usage 为 nil:回退预扣口径(请求 5s × 720P)
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 5.0, "spec.resolution": "720P",
|
||||
}, nil)
|
||||
assertDecimal(t, cost, 5*0.05, "nil usage falls back to estimate")
|
||||
|
||||
// usage 存在但 OutputSeconds=0:同样回退预扣口径
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 5.0, "spec.resolution": "720P",
|
||||
}, &rcommon.TaskMaterialUsage{Resolution: "720P"})
|
||||
assertDecimal(t, cost, 5*0.05, "zero output seconds falls back to estimate")
|
||||
|
||||
// 矩阵为空/全零:落到预扣分支的占位兜底(与预扣行为一致)
|
||||
empty := perSecondTestPricing(1.0)
|
||||
empty.PerSecondPrices = nil
|
||||
cost = scheme.SettleTaskCost(empty, map[string]any{
|
||||
"spec.duration": 5.0, "spec.resolution": "720P",
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "720P"})
|
||||
assertDecimal(t, cost, imagePlaceholderPreDeduct, "empty matrix falls back to placeholder")
|
||||
}
|
||||
|
||||
// TestGenericPerSecond_SettleTenantMultiplier 租户折扣作用于实际秒数结算。
|
||||
func TestGenericPerSecond_SettleTenantMultiplier(t *testing.T) {
|
||||
pricing := perSecondTestPricing(0.5)
|
||||
scheme := GenericScheme{}
|
||||
|
||||
cost := scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 5.0, "spec.resolution": "720P",
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5.13, Resolution: "720P"})
|
||||
assertDecimal(t, cost, 5.13*0.05*0.5, "tenant multiplier applies to actual seconds")
|
||||
}
|
||||
|
||||
// TestGenericPerSecond_NonPerSecondModesIgnoreUsage 回归:非 per_second 模式(token 等)
|
||||
// 的结算不受 usage 影响,保持「预扣即终价」。
|
||||
func TestGenericPerSecond_NonPerSecondModesIgnoreUsage(t *testing.T) {
|
||||
scheme := GenericScheme{}
|
||||
|
||||
// token 模式 + usage 带秒数:忽略 usage,走预扣口径(无时长信号 → 占位兜底)
|
||||
tokenPricing := &PricingResult{BillingMode: "token", OutputPrice: 3.0, TenantMultiplier: 1.0}
|
||||
cost := scheme.SettleTaskCost(tokenPricing, map[string]any{},
|
||||
&rcommon.TaskMaterialUsage{OutputSeconds: 100})
|
||||
assertDecimal(t, cost, imagePlaceholderPreDeduct, "token mode ignores usage")
|
||||
}
|
||||
@@ -438,9 +438,11 @@ func (b *TaskBillingProviderImpl) RecalculateByMaterials(ctx context.Context, te
|
||||
// 取价失败不阻断结算(调用方回落预扣金额),仅返回 ok=false
|
||||
return Zero, false, err
|
||||
}
|
||||
// 仅显式配置了计费方案的模型适用:generic 任务保持「预扣即终价」不被重估
|
||||
// (素材计费语义由方案声明,未声明素材的模型不应因 usage 附带字段改变结算来源)
|
||||
if pricing.Scheme == "" {
|
||||
// 仅显式配置了计费方案、或 generic per_second(消费上游实际输出秒数结算)的模型适用:
|
||||
// 其余 generic 任务保持「预扣即终价」不被重估(素材计费语义由方案声明,未声明素材的
|
||||
// 模型不应因 usage 附带字段改变结算来源)。per_second 放行是安全的:存量按秒渠道
|
||||
// (kling/volcengine/sora)适配器均不产出 MaterialUsage,结算链路到不了这里,行为不变
|
||||
if pricing.Scheme == "" && pricing.BillingMode != "per_second" {
|
||||
return Zero, false, nil
|
||||
}
|
||||
costD := LookupScheme(pricing.Scheme).SettleTaskCost(pricing, ratios, usage)
|
||||
|
||||
@@ -84,6 +84,7 @@ type TaskMaterialUsage struct {
|
||||
InputImageCount int // 输入图片张数
|
||||
InputAudioSeconds float64 // 输入音频时长(秒)
|
||||
OutputSeconds float64 // 输出(生成)时长(秒)
|
||||
Resolution string // 实际输出分辨率规格(如 "720P",对齐 per_second 矩阵键);空 = 上游未返回
|
||||
}
|
||||
|
||||
// TaskStatusEnum 任务状态枚举
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
RelayModeResponsesCompact // POST /v1/responses/compact
|
||||
RelayModeVideos // OpenAI Videos 协议 /v1/videos(创建/查询/删除/内容下载)
|
||||
RelayModeMiniMaxVideo // MiniMax 官方协议 /v2/video_generation(H3 v2 提交/查询/删除)
|
||||
RelayModeAliVideo // 阿里 DashScope 官方协议 /api/v1/services/aigc/video-generation/video-synthesis(提交/查询)
|
||||
)
|
||||
|
||||
// Path2RelayMode 根据请求路径判断 RelayMode
|
||||
@@ -159,6 +160,8 @@ func (m RelayMode) String() string {
|
||||
return "videos"
|
||||
case RelayModeMiniMaxVideo:
|
||||
return "minimax_video"
|
||||
case RelayModeAliVideo:
|
||||
return "ali_video"
|
||||
case RelayModeMjSubmit:
|
||||
return "mj_submit"
|
||||
case RelayModeMjFetch:
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
)
|
||||
|
||||
// 阿里 DashScope 官方视频协议(POST /api/v1/services/aigc/video-generation/video-synthesis)入站转换层。
|
||||
// 原生请求体(model/input.prompt/input.media[]/parameters)解析校验后转成任务框架通用请求体,
|
||||
// 复用任务提交管线(selectTaskChannel 渠道调度 + HandleTaskSubmit 计费/提交/落库);
|
||||
// 查询端点 GET /api/v1/tasks/{task_id} 回放 DashScope 官方响应形态(协议文档
|
||||
// docs/协议文档/阿里系列/视频-wan3.0.md),阿里 dashscope SDK 换 base_url 即可直连。
|
||||
//
|
||||
// 转换产物在保留原生字段(media[]/negative_prompt/audio_url)之外,双写通用词汇
|
||||
// (prompt/seconds/metadata)——参数倍率规则的匹配源是归一化任务体(与 MiniMax 官方
|
||||
// 协议入站同一约定),保证按通用路径配置的倍率规则对官方协议入站同样生效。
|
||||
|
||||
// aliVideoProtocol TaskRelayContext.Protocol 的阿里 DashScope 官方视频协议标识
|
||||
const aliVideoProtocol = "ali_native"
|
||||
|
||||
// aliVideoCreateRequest DashScope 官方创建请求的归一形态(JSON 解析后的产物)
|
||||
type aliVideoCreateRequest struct {
|
||||
Model string
|
||||
Prompt string
|
||||
NegativePrompt string
|
||||
AudioURL string
|
||||
Media []aliVideoMedia
|
||||
Parameters aliVideoParameters
|
||||
}
|
||||
|
||||
// aliVideoMedia wan3.0 input.media 数组元素
|
||||
type aliVideoMedia struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// aliVideoParameters DashScope parameters(wan2.x 与 wan3.0 字段并集)
|
||||
type aliVideoParameters struct {
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"` // wan3.0:-1 = 智能时长
|
||||
Audio *bool `json:"audio,omitempty"` // wan3.0:输出音频开关
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
PromptExtend *bool `json:"prompt_extend,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
}
|
||||
|
||||
// jsonAliVideoInput 官方请求的 input 节(wan2.x 扁平字段 + wan3.0 media 数组并集)
|
||||
type jsonAliVideoInput struct {
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt"`
|
||||
AudioURL string `json:"audio_url"`
|
||||
Media []aliVideoMedia `json:"media"`
|
||||
}
|
||||
|
||||
// ParseAliVideoCreateRequest 解析官方创建请求。
|
||||
// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。
|
||||
func ParseAliVideoCreateRequest(body []byte) (*aliVideoCreateRequest, *common.TaskError) {
|
||||
var raw struct {
|
||||
Model string `json:"model"`
|
||||
Input jsonAliVideoInput `json:"input"`
|
||||
Parameters aliVideoParameters `json:"parameters"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()}
|
||||
}
|
||||
|
||||
return &aliVideoCreateRequest{
|
||||
Model: strings.TrimSpace(raw.Model),
|
||||
Prompt: raw.Input.Prompt,
|
||||
NegativePrompt: raw.Input.NegativePrompt,
|
||||
AudioURL: raw.Input.AudioURL,
|
||||
Media: raw.Input.Media,
|
||||
Parameters: raw.Parameters,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateAliVideoCreateRequest 请求必填校验(对齐官方约束:model 必填,prompt 与 media 必填其一)
|
||||
func ValidateAliVideoCreateRequest(req *aliVideoCreateRequest) *common.TaskError {
|
||||
if req.Model == "" {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
|
||||
}
|
||||
if strings.TrimSpace(req.Prompt) == "" && len(req.Media) == 0 {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "one of input.prompt / input.media is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildAliVideoTaskBody 把官方创建请求转成任务框架通用请求体(JSON 字节)。
|
||||
// media 数组放顶层原样透传(适配器 buildWan3Media 消费);通用词汇(seconds/metadata)
|
||||
// 双写保证参数倍率规则与 per_second 预扣信号(spec.duration/spec.resolution)同源。
|
||||
func BuildAliVideoTaskBody(req *aliVideoCreateRequest) ([]byte, error) {
|
||||
body := map[string]any{
|
||||
"model": req.Model,
|
||||
"prompt": req.Prompt,
|
||||
}
|
||||
if len(req.Media) > 0 {
|
||||
body["media"] = req.Media
|
||||
}
|
||||
|
||||
metadata := map[string]any{}
|
||||
if req.NegativePrompt != "" {
|
||||
body["negative_prompt"] = req.NegativePrompt
|
||||
metadata["negative_prompt"] = req.NegativePrompt
|
||||
}
|
||||
if req.AudioURL != "" {
|
||||
body["audio_url"] = req.AudioURL
|
||||
metadata["audio_url"] = req.AudioURL
|
||||
}
|
||||
|
||||
p := req.Parameters
|
||||
if p.Resolution != "" {
|
||||
body["resolution"] = p.Resolution
|
||||
metadata["resolution"] = p.Resolution
|
||||
}
|
||||
if p.Ratio != "" {
|
||||
body["ratio"] = p.Ratio
|
||||
metadata["ratio"] = p.Ratio
|
||||
}
|
||||
if p.Size != "" {
|
||||
metadata["size"] = p.Size
|
||||
}
|
||||
// duration 保留原值(含 -1 智能时长信号,供适配器预扣按上限冻结);正数另写通用 seconds
|
||||
if p.Duration != nil {
|
||||
metadata["duration"] = *p.Duration
|
||||
if *p.Duration > 0 {
|
||||
body["seconds"] = strconv.Itoa(*p.Duration)
|
||||
}
|
||||
}
|
||||
if p.Audio != nil {
|
||||
metadata["sound"] = map[bool]string{true: "on", false: "off"}[*p.Audio]
|
||||
metadata["generate_audio"] = *p.Audio
|
||||
}
|
||||
if p.Seed != nil {
|
||||
metadata["seed"] = *p.Seed
|
||||
}
|
||||
if p.PromptExtend != nil {
|
||||
metadata["prompt_extend"] = *p.PromptExtend
|
||||
}
|
||||
if p.Watermark != nil {
|
||||
metadata["watermark"] = *p.Watermark
|
||||
}
|
||||
if len(metadata) > 0 {
|
||||
body["metadata"] = metadata
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal ali video task body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ==================== 查询端点回放 ====================
|
||||
|
||||
// aliTaskResponse DashScope 任务查询响应形态(GET /api/v1/tasks/{task_id} 的回放产物)。
|
||||
// usage 用 RawMessage 原样保留上游计量(duration/output_video_duration/fps/SR/ratio)
|
||||
type aliTaskResponse struct {
|
||||
Output struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
SubmitTime string `json:"submit_time,omitempty"`
|
||||
ScheduledTime string `json:"scheduled_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
OrigPrompt string `json:"orig_prompt,omitempty"`
|
||||
VideoURL string `json:"video_url,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
} `json:"output"`
|
||||
Usage json.RawMessage `json:"usage,omitempty"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// aliStatusFromTask 平台任务状态 → DashScope 官方状态机映射
|
||||
func aliStatusFromTask(status string) string {
|
||||
switch status {
|
||||
case "IN_PROGRESS":
|
||||
return "RUNNING"
|
||||
case "SUCCESS":
|
||||
return "SUCCEEDED"
|
||||
case "FAILURE":
|
||||
return "FAILED"
|
||||
default:
|
||||
// NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 PENDING
|
||||
return "PENDING"
|
||||
}
|
||||
}
|
||||
|
||||
// buildAliTaskResponse 平台任务 → DashScope 官方查询响应。
|
||||
// 优先回放 task.Data 缓存的上游查询响应(含 usage/orig_prompt/时间线,保真度最高),
|
||||
// 仅覆写 output.task_id 为平台公开任务 ID、task_status 以平台状态为准(上游响应原文
|
||||
// 可能滞后一轮轮询);task.Data 不可解析(提交后首拍轮询前为提交响应形态或空)时
|
||||
// 构造最小响应。失败任务补 code/message(上游已带则保留)
|
||||
func buildAliTaskResponse(task *common.AsyncTask) *aliTaskResponse {
|
||||
resp := &aliTaskResponse{}
|
||||
if len(task.Data) > 0 {
|
||||
_ = json.Unmarshal(task.Data, resp)
|
||||
}
|
||||
status := aliStatusFromTask(task.Status)
|
||||
|
||||
resp.Output.TaskID = task.PublicTaskID
|
||||
resp.Output.TaskStatus = status
|
||||
resp.RequestID = task.RequestID
|
||||
|
||||
if status == "FAILED" {
|
||||
if resp.Output.Code == "" {
|
||||
resp.Output.Code = "TaskFailed"
|
||||
}
|
||||
if resp.Output.Message == "" {
|
||||
resp.Output.Message = task.FailReason
|
||||
if resp.Output.Message == "" {
|
||||
resp.Output.Message = "video generation failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleAliVideoRetrieve GET /api/v1/tasks/{task_id} 查询管线(回放 DashScope 响应形态)。
|
||||
// 租户双键隔离(user_id + tenant_id)复用 GetTaskByPublicIDAndUser;平台门禁:该端点只服务
|
||||
// ali 平台任务,防止异构平台任务 ID 被塞进 DashScope 响应结构回放(对齐 MiniMax 官方端点做法)
|
||||
func HandleAliVideoRetrieve(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
rc *TaskRelayContext,
|
||||
dataProvider common.TaskDataProvider,
|
||||
) {
|
||||
task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, taskID, rc.UserID, rc.TenantID)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "AliVideoRetrieve: query task failed, taskID=%s, err=%v", taskID, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "")
|
||||
return
|
||||
}
|
||||
if task == nil || task.Platform != string(constant.TaskPlatformAli) {
|
||||
writeTaskError(rc.Writer, http.StatusNotFound, "task not found", "")
|
||||
return
|
||||
}
|
||||
writeJSON(rc.Writer, http.StatusOK, buildAliTaskResponse(task))
|
||||
}
|
||||
|
||||
// writeAliVideoSubmitResponse 提交成功响应:官方 DashScope 形态
|
||||
// ({"output":{"task_status":"PENDING","task_id":...},"request_id":...})
|
||||
func writeAliVideoSubmitResponse(w http.ResponseWriter, publicTaskID, requestID string, now time.Time) {
|
||||
_ = now // 提交响应形态无时间字段,参数保留对齐其他协议 writer 的签名习惯
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"output": map[string]any{
|
||||
"task_status": "PENDING",
|
||||
"task_id": publicTaskID,
|
||||
},
|
||||
"request_id": requestID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
func TestParseAliVideoCreateRequest_Full(t *testing.T) {
|
||||
body := `{
|
||||
"model": "wan3.0-video",
|
||||
"input": {
|
||||
"prompt": "一只小猫在月光下的屋顶上奔跑",
|
||||
"media": [
|
||||
{"type": "first_frame", "url": "https://example.com/f.png"},
|
||||
{"type": "reference_image", "url": "https://example.com/r.png"}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"resolution": "1080P", "ratio": "16:9", "duration": 5,
|
||||
"audio": false, "seed": 42, "prompt_extend": true, "watermark": false
|
||||
}
|
||||
}`
|
||||
req, taskErr := ParseAliVideoCreateRequest([]byte(body))
|
||||
if taskErr != nil {
|
||||
t.Fatalf("unexpected error: %+v", taskErr)
|
||||
}
|
||||
if req.Model != "wan3.0-video" || req.Prompt != "一只小猫在月光下的屋顶上奔跑" {
|
||||
t.Fatalf("unexpected request: %+v", req)
|
||||
}
|
||||
if len(req.Media) != 2 || req.Media[0].Type != "first_frame" || req.Media[1].URL != "https://example.com/r.png" {
|
||||
t.Fatalf("unexpected media: %+v", req.Media)
|
||||
}
|
||||
if req.Parameters.Resolution != "1080P" || req.Parameters.Ratio != "16:9" {
|
||||
t.Fatalf("unexpected parameters: %+v", req.Parameters)
|
||||
}
|
||||
if req.Parameters.Duration == nil || *req.Parameters.Duration != 5 {
|
||||
t.Fatalf("unexpected duration: %+v", req.Parameters.Duration)
|
||||
}
|
||||
if req.Parameters.Audio == nil || *req.Parameters.Audio {
|
||||
t.Fatalf("unexpected audio: %+v", req.Parameters.Audio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAliVideoCreateRequest_LegacyWan2(t *testing.T) {
|
||||
// wan2.x 旧格式(扁平 input,无 media)同样可解析
|
||||
body := `{"model":"wan2.2-t2v-plus","input":{"prompt":"p","negative_prompt":"np","audio_url":"https://a.mp3"},"parameters":{"size":"1024*576"}}`
|
||||
req, taskErr := ParseAliVideoCreateRequest([]byte(body))
|
||||
if taskErr != nil {
|
||||
t.Fatalf("unexpected error: %+v", taskErr)
|
||||
}
|
||||
if req.Model != "wan2.2-t2v-plus" || req.Prompt != "p" || req.NegativePrompt != "np" || req.AudioURL != "https://a.mp3" {
|
||||
t.Fatalf("unexpected request: %+v", req)
|
||||
}
|
||||
if len(req.Media) != 0 {
|
||||
t.Fatalf("wan2 request must not carry media: %+v", req.Media)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAliVideoCreateRequest_InvalidJSON(t *testing.T) {
|
||||
if _, taskErr := ParseAliVideoCreateRequest([]byte(`{invalid`)); taskErr == nil || taskErr.StatusCode != 400 {
|
||||
t.Fatalf("expected 400 task error, got %+v", taskErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAliVideoCreateRequest(t *testing.T) {
|
||||
// 缺 model
|
||||
if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{Prompt: "p"}); taskErr == nil {
|
||||
t.Error("missing model should be rejected")
|
||||
}
|
||||
// prompt 与 media 都缺(文档约束:必填其一)
|
||||
if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{Model: "wan3.0-video"}); taskErr == nil {
|
||||
t.Error("missing both prompt and media should be rejected")
|
||||
}
|
||||
// 纯 media(无 prompt)合法
|
||||
if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{
|
||||
Model: "wan3.0-video",
|
||||
Media: []aliVideoMedia{{Type: "reference_video", URL: "https://v.mp4"}},
|
||||
}); taskErr != nil {
|
||||
t.Errorf("media-only request should be accepted: %+v", taskErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAliVideoTaskBody(t *testing.T) {
|
||||
duration := 5
|
||||
audio := false
|
||||
body, err := BuildAliVideoTaskBody(&aliVideoCreateRequest{
|
||||
Model: "wan3.0-video",
|
||||
Prompt: "p",
|
||||
Media: []aliVideoMedia{{Type: "first_frame", URL: "https://f.png"}},
|
||||
Parameters: aliVideoParameters{
|
||||
Resolution: "480P", Ratio: "16:9", Duration: &duration, Audio: &audio,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// 顶层保留:model/prompt/media/通用 seconds
|
||||
if m["model"] != "wan3.0-video" || m["prompt"] != "p" || m["seconds"] != "5" {
|
||||
t.Fatalf("unexpected top-level body: %s", body)
|
||||
}
|
||||
media, ok := m["media"].([]any)
|
||||
if !ok || len(media) != 1 {
|
||||
t.Fatalf("expected media passthrough: %s", body)
|
||||
}
|
||||
// metadata 双写:duration/resolution/ratio/sound/generate_audio
|
||||
meta, _ := m["metadata"].(map[string]any)
|
||||
if meta["duration"] != float64(5) || meta["resolution"] != "480P" || meta["ratio"] != "16:9" {
|
||||
t.Fatalf("unexpected metadata: %v", meta)
|
||||
}
|
||||
if meta["sound"] != "off" || meta["generate_audio"] != false {
|
||||
t.Fatalf("audio switch not normalized: %v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAliVideoTaskBody_SmartDuration(t *testing.T) {
|
||||
// duration=-1(智能时长):metadata 保留 -1 信号(适配器预扣按 30s 上限冻结),不写 seconds
|
||||
minusOne := -1
|
||||
body, err := BuildAliVideoTaskBody(&aliVideoCreateRequest{
|
||||
Model: "wan3.0-video",
|
||||
Prompt: "p",
|
||||
Parameters: aliVideoParameters{Duration: &minusOne},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
if _, has := m["seconds"]; has {
|
||||
t.Fatalf("smart duration must not emit seconds: %s", body)
|
||||
}
|
||||
meta, _ := m["metadata"].(map[string]any)
|
||||
if meta["duration"] != float64(-1) {
|
||||
t.Fatalf("smart duration signal lost: %v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAliTaskResponse(t *testing.T) {
|
||||
now := time.Now()
|
||||
submitted := now.Add(-time.Minute)
|
||||
|
||||
// 终态成功:回放上游查询响应(usage/orig_prompt 保真),覆写 task_id/status
|
||||
task := &common.AsyncTask{
|
||||
PublicTaskID: "task_pub_1",
|
||||
RequestID: "req_1",
|
||||
Platform: "ali",
|
||||
Status: "SUCCESS",
|
||||
CreatedAt: submitted,
|
||||
Data: []byte(`{"output":{"task_id":"up-stream-id","task_status":"RUNNING","video_url":"https://v.mp4","orig_prompt":"a cat"},"usage":{"video_count":1,"duration":5.0,"output_video_duration":5.13,"fps":30,"SR":720,"ratio":"16:9"},"request_id":"up-req"}`),
|
||||
}
|
||||
resp := buildAliTaskResponse(task)
|
||||
if resp.Output.TaskID != "task_pub_1" || resp.Output.TaskStatus != "SUCCEEDED" {
|
||||
t.Fatalf("unexpected replay: %+v", resp.Output)
|
||||
}
|
||||
if resp.Output.VideoURL != "https://v.mp4" || resp.Output.OrigPrompt != "a cat" {
|
||||
t.Fatalf("upstream fields must be preserved: %+v", resp.Output)
|
||||
}
|
||||
var usage map[string]any
|
||||
if err := json.Unmarshal(resp.Usage, &usage); err != nil {
|
||||
t.Fatalf("usage not preserved: %v", err)
|
||||
}
|
||||
if usage["output_video_duration"] != 5.13 || usage["SR"] != float64(720) {
|
||||
t.Fatalf("usage metering lost: %v", usage)
|
||||
}
|
||||
if resp.RequestID != "req_1" {
|
||||
t.Fatalf("request id should be platform's: %s", resp.RequestID)
|
||||
}
|
||||
|
||||
// 非终态(提交后首拍,Data 为提交响应形态):构造 PENDING
|
||||
pending := &common.AsyncTask{
|
||||
PublicTaskID: "task_pub_2",
|
||||
RequestID: "req_2",
|
||||
Platform: "ali",
|
||||
Status: "SUBMITTED",
|
||||
Data: []byte(`{"output":{"task_status":"PENDING","task_id":"up-2"},"request_id":"up-req-2"}`),
|
||||
}
|
||||
resp = buildAliTaskResponse(pending)
|
||||
if resp.Output.TaskID != "task_pub_2" || resp.Output.TaskStatus != "PENDING" {
|
||||
t.Fatalf("unexpected pending replay: %+v", resp.Output)
|
||||
}
|
||||
|
||||
// Data 为空:构造最小响应
|
||||
empty := &common.AsyncTask{PublicTaskID: "task_pub_3", RequestID: "req_3", Platform: "ali", Status: "IN_PROGRESS"}
|
||||
resp = buildAliTaskResponse(empty)
|
||||
if resp.Output.TaskID != "task_pub_3" || resp.Output.TaskStatus != "RUNNING" {
|
||||
t.Fatalf("unexpected empty-data replay: %+v", resp.Output)
|
||||
}
|
||||
|
||||
// 失败任务:上游 code/message 保留;缺失时用平台 FailReason 兜底
|
||||
failed := &common.AsyncTask{
|
||||
PublicTaskID: "task_pub_4", RequestID: "req_4", Platform: "ali", Status: "FAILURE",
|
||||
FailReason: "upstream quota exceeded",
|
||||
Data: []byte(`{"output":{"task_id":"up-4","task_status":"FAILED","code":"Throttling"}}`),
|
||||
}
|
||||
resp = buildAliTaskResponse(failed)
|
||||
if resp.Output.Code != "Throttling" || resp.Output.Message != "upstream quota exceeded" {
|
||||
t.Fatalf("unexpected failure replay: %+v", resp.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
)
|
||||
|
||||
// 端到端集成测试:阿里 DashScope 官方协议 / OpenAI Videos 协议 → 转换 → 任务提交管线
|
||||
// (ali 适配器 + mock 上游)→ task_ 前缀 ID + DashScope 官方响应形态 + 落库 platform=ali
|
||||
// + 上游收到 media 数组与映射参数。
|
||||
|
||||
// newAliUpstream 构造 mock 阿里 DashScope 上游(校验端点/异步头,记录请求体)
|
||||
func newAliUpstream(t *testing.T, received *map[string]any) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/api/v1/services/aigc/video-generation/video-synthesis") {
|
||||
t.Errorf("unexpected upstream path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-DashScope-Async") != "enable" {
|
||||
t.Errorf("missing X-DashScope-Async header: %v", r.Header)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, received); err != nil {
|
||||
t.Errorf("upstream body not json: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"output": map[string]any{"task_status": "PENDING", "task_id": "ali-up-1"},
|
||||
"request_id": "up-req-1",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func newAliChannelMeta(upstreamURL string) *common.ChannelMeta {
|
||||
return &common.ChannelMeta{
|
||||
ChannelID: 21,
|
||||
ChannelType: int(constant.ProviderAli),
|
||||
ChannelName: "mock-ali",
|
||||
BaseURL: upstreamURL,
|
||||
ApiKey: "sk-test",
|
||||
UpstreamModelName: "wan3.0-video",
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTaskSubmit_AliNativeProtocol_FullPipeline(t *testing.T) {
|
||||
var upstreamReceived map[string]any
|
||||
upstream := newAliUpstream(t, &upstreamReceived)
|
||||
defer upstream.Close()
|
||||
|
||||
// 官方请求体 → 归一 → 通用任务体(与胶水层 HandleAliVideoSubmit 相同链路)
|
||||
raw := `{
|
||||
"model": "wan3.0-video",
|
||||
"input": {
|
||||
"prompt": "小猫奔跑",
|
||||
"media": [{"type": "first_frame", "url": "https://example.com/f.png"}]
|
||||
},
|
||||
"parameters": {"resolution": "480P", "ratio": "16:9", "duration": 5, "audio": false}
|
||||
}`
|
||||
req, taskErr := ParseAliVideoCreateRequest([]byte(raw))
|
||||
if taskErr != nil {
|
||||
t.Fatalf("parse: %+v", taskErr)
|
||||
}
|
||||
taskBody, err := BuildAliVideoTaskBody(req)
|
||||
if err != nil {
|
||||
t.Fatalf("build task body: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{
|
||||
TenantID: 1,
|
||||
UserID: 2,
|
||||
ApiKeyID: 3,
|
||||
RequestID: "req_ali_test",
|
||||
Writer: rec,
|
||||
Protocol: aliVideoProtocol,
|
||||
RelayMode: int(constant.RelayModeAliVideo),
|
||||
}
|
||||
data := &stubVideosSubmitProvider{}
|
||||
|
||||
HandleTaskSubmit(context.Background(), taskBody, "/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
http.Header{}, rc, data, &stubVideosBillingProvider{}, newAliChannelMeta(upstream.URL))
|
||||
|
||||
// 客户端响应:DashScope 官方形态
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
resp := decodeVideosJSON(t, rec)
|
||||
output, _ := resp["output"].(map[string]any)
|
||||
if output == nil || output["task_status"] != "PENDING" {
|
||||
t.Fatalf("unexpected dashscope output: %s", rec.Body.String())
|
||||
}
|
||||
taskID, _ := output["task_id"].(string)
|
||||
if !strings.HasPrefix(taskID, "task_") {
|
||||
t.Fatalf("expected task_ prefixed id, got %q", taskID)
|
||||
}
|
||||
if resp["request_id"] != "req_ali_test" {
|
||||
t.Fatalf("expected platform request id: %s", rec.Body.String())
|
||||
}
|
||||
if rc.TaskID != taskID {
|
||||
t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, taskID)
|
||||
}
|
||||
|
||||
// 落库任务:ali 平台 + 上游任务 ID
|
||||
if data.created == nil || data.created.Platform != "ali" {
|
||||
t.Fatalf("expected ali task record, got %+v", data.created)
|
||||
}
|
||||
var pd struct {
|
||||
UpstreamTaskID string `json:"upstream_task_id"`
|
||||
}
|
||||
if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil {
|
||||
t.Fatalf("private data unmarshal: %v", err)
|
||||
}
|
||||
if pd.UpstreamTaskID != "ali-up-1" {
|
||||
t.Fatalf("expected upstream task id captured, got %q", pd.UpstreamTaskID)
|
||||
}
|
||||
|
||||
// 上游收到:media 数组透传 + parameters 映射(resolution/ratio/duration/audio)
|
||||
input, _ := upstreamReceived["input"].(map[string]any)
|
||||
if input == nil || input["prompt"] != "小猫奔跑" {
|
||||
t.Fatalf("upstream input: %v", upstreamReceived["input"])
|
||||
}
|
||||
media, _ := input["media"].([]any)
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("expected media array forwarded, got %v", input["media"])
|
||||
}
|
||||
first, _ := media[0].(map[string]any)
|
||||
if first["type"] != "first_frame" || first["url"] != "https://example.com/f.png" {
|
||||
t.Fatalf("unexpected media item: %v", media[0])
|
||||
}
|
||||
params, _ := upstreamReceived["parameters"].(map[string]any)
|
||||
if params == nil || params["resolution"] != "480P" || params["ratio"] != "16:9" ||
|
||||
params["duration"] != float64(5) || params["audio"] != false {
|
||||
t.Fatalf("upstream parameters: %v", upstreamReceived["parameters"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTaskSubmit_OpenAIVideosToWan3_Pipeline(t *testing.T) {
|
||||
// OpenAI Videos 协议(multipart)→ BuildVideosTaskBody → ali 适配器:
|
||||
// input_reference → media[{type:first_frame}]、seconds → duration、size 短边 → resolution、
|
||||
// sound → audio
|
||||
var upstreamReceived map[string]any
|
||||
upstream := newAliUpstream(t, &upstreamReceived)
|
||||
defer upstream.Close()
|
||||
|
||||
mBody := []byte(`{"model":"wan3.0-video","prompt":"小猫对着镜头打哈欠","seconds":"8","size":"1280x720","aspect_ratio":"9:16","sound":"on","input_reference":{"image_url":"https://example.com/ref.png"}}`)
|
||||
req, taskErr := ParseVideosCreateRequest(mBody, "application/json")
|
||||
if taskErr != nil {
|
||||
t.Fatalf("parse: %+v", taskErr)
|
||||
}
|
||||
taskBody, err := BuildVideosTaskBody(req)
|
||||
if err != nil {
|
||||
t.Fatalf("build task body: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
rc := &TaskRelayContext{
|
||||
TenantID: 1,
|
||||
UserID: 2,
|
||||
ApiKeyID: 3,
|
||||
RequestID: "req_ali_openai_test",
|
||||
Writer: rec,
|
||||
Protocol: aliVideoProtocol,
|
||||
RelayMode: int(constant.RelayModeAliVideo),
|
||||
}
|
||||
data := &stubVideosSubmitProvider{}
|
||||
|
||||
HandleTaskSubmit(context.Background(), taskBody, "/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
http.Header{}, rc, data, &stubVideosBillingProvider{}, newAliChannelMeta(upstream.URL))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if data.created == nil || data.created.Platform != "ali" {
|
||||
t.Fatalf("expected ali task record, got %+v", data.created)
|
||||
}
|
||||
|
||||
// 上游收到映射后的 wan3.0 参数
|
||||
input, _ := upstreamReceived["input"].(map[string]any)
|
||||
media, _ := input["media"].([]any)
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("expected first_frame media from input_reference, got %v", input["media"])
|
||||
}
|
||||
first, _ := media[0].(map[string]any)
|
||||
if first["type"] != "first_frame" {
|
||||
t.Fatalf("input_reference should map to first_frame: %v", media[0])
|
||||
}
|
||||
params, _ := upstreamReceived["parameters"].(map[string]any)
|
||||
if params == nil || params["duration"] != float64(8) {
|
||||
t.Fatalf("seconds → duration mapping lost: %v", upstreamReceived["parameters"])
|
||||
}
|
||||
if params["resolution"] != "720P" {
|
||||
t.Fatalf("size short-edge → resolution mapping lost: %v", upstreamReceived["parameters"])
|
||||
}
|
||||
if params["ratio"] != "9:16" {
|
||||
t.Fatalf("aspect_ratio → ratio mapping lost: %v", upstreamReceived["parameters"])
|
||||
}
|
||||
if params["audio"] != true {
|
||||
t.Fatalf("sound → audio mapping lost: %v", upstreamReceived["parameters"])
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,8 @@ func HandleTaskSubmit(
|
||||
dbgAttempt.MarkFinal(nil)
|
||||
|
||||
// 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象;MiniMax 官方协议返回 {"task_id"}
|
||||
//(v1 含 base_resp 信封);legacy 保持原格式)
|
||||
//(v1 含 base_resp 信封);阿里 DashScope 官方协议返回 {"output":{"task_id":...}};
|
||||
// legacy 保持原格式)
|
||||
if rc.Protocol == videosProtocolOpenAI {
|
||||
writeVideosSubmitResponse(rc.Writer, publicTaskID, modelName, now, rc.RequestEcho)
|
||||
return
|
||||
@@ -382,6 +383,10 @@ func HandleTaskSubmit(
|
||||
writeMiniMaxV1SubmitResponse(rc.Writer, publicTaskID)
|
||||
return
|
||||
}
|
||||
if rc.Protocol == aliVideoProtocol {
|
||||
writeAliVideoSubmitResponse(rc.Writer, publicTaskID, rc.RequestID, now)
|
||||
return
|
||||
}
|
||||
respBody := map[string]any{
|
||||
"id": publicTaskID,
|
||||
"status": "SUBMITTED",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
@@ -21,6 +22,10 @@ func init() {
|
||||
|
||||
// ==================== 请求/响应结构体 ====================
|
||||
|
||||
// wan3MaxDurationSeconds wan3.0 智能时长模式(duration=-1)的输出上限(秒)。
|
||||
// 智能时长下输出长度不可预知,预扣按该上限冻结,结算按上游 usage.output_video_duration 多退少补
|
||||
const wan3MaxDurationSeconds = 30.0
|
||||
|
||||
// dashScopeVideoRequest DashScope 视频生成请求
|
||||
type dashScopeVideoRequest struct {
|
||||
Model string `json:"model"`
|
||||
@@ -29,9 +34,18 @@ type dashScopeVideoRequest struct {
|
||||
}
|
||||
|
||||
type dashScopeVideoInput struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
AudioURL string `json:"audio_url,omitempty"`
|
||||
// Media wan3.0 全能参考模式的媒体素材数组(first_frame/last_frame/reference_image/
|
||||
// reference_video/reference_audio/file/link);wan2.x 旧格式不填
|
||||
Media []dashScopeMedia `json:"media,omitempty"`
|
||||
}
|
||||
|
||||
// dashScopeMedia wan3.0 input.media 数组元素
|
||||
type dashScopeMedia struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type dashScopeVideoParams struct {
|
||||
@@ -42,6 +56,8 @@ type dashScopeVideoParams struct {
|
||||
PromptExtend *bool `json:"prompt_extend,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
// Audio wan3.0 输出视频是否包含音频(开关价格相同)
|
||||
Audio *bool `json:"audio,omitempty"`
|
||||
}
|
||||
|
||||
// aliMetadata Ali DashScope metadata 参数结构体(用于 UnmarshalMetadata 映射)
|
||||
@@ -55,6 +71,7 @@ type aliMetadata struct {
|
||||
PromptExtend *bool `json:"prompt_extend,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
// Audio wan3.0 输出音频开关("on"/"off" 通用词汇由 buildVideoRequest 归一,此处不承载)
|
||||
}
|
||||
|
||||
// dashScopeImageRequest DashScope 图片生成请求
|
||||
@@ -95,7 +112,7 @@ type dashScopeTaskResponse struct {
|
||||
Usage struct {
|
||||
Duration float64 `json:"duration"`
|
||||
VideoCount int `json:"video_count"`
|
||||
OutputVideoDuration int `json:"output_video_duration"`
|
||||
OutputVideoDuration float64 `json:"output_video_duration"`
|
||||
SR int `json:"SR"`
|
||||
Ratio string `json:"ratio"`
|
||||
ImageCount int `json:"image_count"`
|
||||
@@ -119,14 +136,14 @@ func (a *AliAdaptor) Init(info *common.RelayInfo) {
|
||||
}
|
||||
|
||||
// detectVideo 根据模型名判断是否为视频生成。
|
||||
// 注意:万相图片模型(-t2i 文生图、-i2i 图像编辑)即使以 wan2. 开头也不是视频,必须先排除,
|
||||
// 注意:万相图片模型(-t2i 文生图、-i2i 图像编辑)即使以 wan2./wan3. 开头也不是视频,必须先排除,
|
||||
// 否则会被误判为视频而 POST 到 video-synthesis 端点,被上游以 "url error" 拒绝。
|
||||
func (a *AliAdaptor) detectVideo(modelName string) bool {
|
||||
m := strings.ToLower(modelName)
|
||||
if strings.Contains(m, "-t2i") || strings.Contains(m, "-i2i") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(m, "wan2.") || strings.HasPrefix(m, "wanx2.1-t2v")
|
||||
return strings.HasPrefix(m, "wan2.") || strings.HasPrefix(m, "wan3.") || strings.HasPrefix(m, "wanx2.1-t2v")
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
|
||||
@@ -140,6 +157,10 @@ func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, bod
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateBilling 估算任务费用(提交前)。
|
||||
// 注意:此处自行从 body 提取模型名判定视频/图片,不依赖 BuildRequestBody 的 a.isVideo 副作用——
|
||||
// 管线中 EstimateBilling(第 6 步)先于 BuildRequestBody(第 7 步)执行,旧实现读 a.isVideo
|
||||
// 恒为 false,视频时长信号从未上报(预扣恒按引擎默认 5s 估),本次修复。
|
||||
func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
|
||||
ratios := map[string]any{"base": 1.0}
|
||||
var req map[string]any
|
||||
@@ -147,16 +168,29 @@ func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bod
|
||||
return ratios
|
||||
}
|
||||
|
||||
if a.isVideo {
|
||||
// duration 影响计费:spec.duration 为真实秒数(per_second 用),duration 为存量信号
|
||||
modelName, _ := req["model"].(string)
|
||||
if a.detectVideo(modelName) {
|
||||
metadata := taskchannel.ExtractMetadata(req)
|
||||
var meta struct {
|
||||
Duration *int `json:"duration"`
|
||||
}
|
||||
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 {
|
||||
ratios["duration"] = float64(*meta.Duration)
|
||||
ratios["spec.duration"] = float64(*meta.Duration)
|
||||
var meta aliMetadata
|
||||
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil {
|
||||
// 分辨率事实值:per_second 矩阵查价键(480P/720P/1080P 原值)
|
||||
if meta.Resolution != "" {
|
||||
ratios["spec.resolution"] = meta.Resolution
|
||||
}
|
||||
// 时长事实值:duration>0 用请求值;duration=-1(wan3.0 智能时长)按输出上限冻结,
|
||||
// 结算以 usage.output_video_duration 多退少补
|
||||
if meta.Duration != nil {
|
||||
d := float64(*meta.Duration)
|
||||
if d == -1 {
|
||||
d = wan3MaxDurationSeconds
|
||||
}
|
||||
if d > 0 {
|
||||
ratios["duration"] = d
|
||||
ratios["spec.duration"] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
// 顶层 seconds(OpenAI Videos 通用词汇)优先于 metadata
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d > 0 {
|
||||
ratios["duration"] = float64(d)
|
||||
@@ -250,8 +284,28 @@ func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]an
|
||||
}
|
||||
}
|
||||
|
||||
// wan3.0 专属适配:媒体素材数组 + 音频开关 + size 档位映射。
|
||||
// wan2.x 保持旧格式不动(其 size 是原生尺寸语义,不可参与档位映射)
|
||||
if strings.HasPrefix(strings.ToLower(modelName), "wan3.") {
|
||||
dsReq.Input.Media = buildWan3Media(req, metadata)
|
||||
if sound, ok := metadata["sound"].(string); ok && sound != "" {
|
||||
audio := sound == "on"
|
||||
params.Audio = &audio
|
||||
}
|
||||
if params.Resolution == "" && meta.Size != "" {
|
||||
params.Resolution = resolutionFromSize(meta.Size)
|
||||
}
|
||||
// ratio 兜底:通用词汇 aspect_ratio(OpenAI Videos 双写键,部分链路只写该键)
|
||||
if params.Ratio == "" {
|
||||
if ar, ok := metadata["aspect_ratio"].(string); ok {
|
||||
params.Ratio = ar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if params.Resolution != "" || params.Ratio != "" || params.Size != "" ||
|
||||
params.Duration != nil || params.PromptExtend != nil || params.Watermark != nil || params.Seed != nil {
|
||||
params.Duration != nil || params.PromptExtend != nil || params.Watermark != nil ||
|
||||
params.Seed != nil || params.Audio != nil {
|
||||
dsReq.Parameters = params
|
||||
}
|
||||
|
||||
@@ -262,6 +316,71 @@ func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]an
|
||||
return strings.NewReader(string(data)), nil
|
||||
}
|
||||
|
||||
// buildWan3Media 构建 wan3.0 input.media 数组(全能参考模式)。
|
||||
// 优先级:顶层 media(阿里原生入站原样透传)> metadata.image(OpenAI input_reference,
|
||||
// 首帧语义对齐 Sora)> 顶层 images[](通用多图词汇 → 参考图)。均无则返回 nil(纯文生视频)
|
||||
func buildWan3Media(req map[string]any, metadata map[string]any) []dashScopeMedia {
|
||||
if raw, ok := req["media"].([]any); ok && len(raw) > 0 {
|
||||
media := make([]dashScopeMedia, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t, _ := m["type"].(string)
|
||||
u, _ := m["url"].(string)
|
||||
if t == "" || u == "" {
|
||||
continue
|
||||
}
|
||||
media = append(media, dashScopeMedia{Type: t, URL: u})
|
||||
}
|
||||
if len(media) > 0 {
|
||||
return media
|
||||
}
|
||||
}
|
||||
if img, ok := metadata["image"].(string); ok && img != "" {
|
||||
return []dashScopeMedia{{Type: "first_frame", URL: img}}
|
||||
}
|
||||
if imgs, ok := req["images"].([]any); ok && len(imgs) > 0 {
|
||||
media := make([]dashScopeMedia, 0, len(imgs))
|
||||
for _, item := range imgs {
|
||||
if u, ok := item.(string); ok && u != "" {
|
||||
media = append(media, dashScopeMedia{Type: "reference_image", URL: u})
|
||||
}
|
||||
}
|
||||
if len(media) > 0 {
|
||||
return media
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolutionFromSize 从尺寸字符串(如 "1280x720")按短边映射 wan3.0 分辨率档位。
|
||||
// 竖版尺寸(720x1280)同样按短边归档;解析失败返回空串(上游取默认 1080P)
|
||||
func resolutionFromSize(size string) string {
|
||||
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(size)), "x", 2)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
w, errW := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
h, errH := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if errW != nil || errH != nil || w <= 0 || h <= 0 {
|
||||
return ""
|
||||
}
|
||||
short := w
|
||||
if h < short {
|
||||
short = h
|
||||
}
|
||||
switch {
|
||||
case short <= 500:
|
||||
return "480P"
|
||||
case short <= 800:
|
||||
return "720P"
|
||||
default:
|
||||
return "1080P"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) buildImageRequest(info *common.RelayInfo, req map[string]any, modelName string) (io.Reader, error) {
|
||||
dsReq := dashScopeImageRequest{
|
||||
Input: dashScopeImageInput{},
|
||||
@@ -409,6 +528,14 @@ func (a *AliAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
if resp.Output.VideoURL != "" {
|
||||
info.ResultURL = resp.Output.VideoURL
|
||||
}
|
||||
// 素材计量(wan3.0 按秒计费):实际输出秒数 + 实际分辨率,
|
||||
// 供 generic per_second 结算按「实际秒数 × 实际档位单价」多退少补(方案 A)
|
||||
if resp.Usage.OutputVideoDuration > 0 {
|
||||
info.MaterialUsage = &common.TaskMaterialUsage{
|
||||
OutputSeconds: resp.Usage.OutputVideoDuration,
|
||||
Resolution: resolutionFromSR(resp.Usage.SR),
|
||||
}
|
||||
}
|
||||
// 图片结果
|
||||
if len(resp.Output.Results) > 0 {
|
||||
info.ResultURL = resp.Output.Results[0].URL
|
||||
@@ -448,3 +575,12 @@ func parseInt(s string) (int, error) {
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// resolutionFromSR 上游 usage.SR(分辨率短边整数,如 720)→ per_second 矩阵键("720P")。
|
||||
// 0/异常值返回空串(结算查价回退 ratios 的 spec.resolution)
|
||||
func resolutionFromSR(sr int) string {
|
||||
if sr <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(sr) + "P"
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
func newTestRelayInfo() *common.RelayInfo {
|
||||
// ChannelMeta 必须非空(BuildRequestBody 读取 IsModelMapped/UpstreamModelName)
|
||||
return &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}}
|
||||
}
|
||||
|
||||
func TestDetectVideo(t *testing.T) {
|
||||
a := &AliAdaptor{}
|
||||
video := []string{
|
||||
"wan3.0-video", "wan3.0-video-prime", "WAN3.0-VIDEO",
|
||||
"wan2.7-t2v-2026-04-25", "wan2.2-t2v-plus", "wanx2.1-t2v-turbo",
|
||||
}
|
||||
for _, m := range video {
|
||||
if !a.detectVideo(m) {
|
||||
t.Errorf("%s should be detected as video", m)
|
||||
}
|
||||
}
|
||||
image := []string{
|
||||
"wanx2.1-t2i-ediff", "wanx2.5-image-edit", "wanx-v1",
|
||||
"qwen-image-generation", "flux-dev",
|
||||
}
|
||||
for _, m := range image {
|
||||
if a.detectVideo(m) {
|
||||
t.Errorf("%s should be detected as image", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:EstimateBilling 不再依赖 BuildRequestBody 的 isVideo 副作用(管线中前者先于后者执行,
|
||||
// 旧实现视频时长信号从未上报),此处直接对原始 body 求值。
|
||||
func TestEstimateBilling_VideoSpecSignals(t *testing.T) {
|
||||
a := &AliAdaptor{}
|
||||
|
||||
// wan3.0 + metadata duration/resolution → spec.* 上报
|
||||
ratios := a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
|
||||
`{"model":"wan3.0-video","prompt":"p","metadata":{"duration":8,"resolution":"720P"}}`))
|
||||
if ratios["spec.duration"] != 8.0 || ratios["duration"] != 8.0 {
|
||||
t.Fatalf("spec.duration signal lost: %v", ratios)
|
||||
}
|
||||
if ratios["spec.resolution"] != "720P" {
|
||||
t.Fatalf("spec.resolution signal lost: %v", ratios)
|
||||
}
|
||||
|
||||
// 智能时长 -1:按 wan3.0 上限 30s 冻结(结算按实际秒数多退少补)
|
||||
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
|
||||
`{"model":"wan3.0-video","prompt":"p","metadata":{"duration":-1,"resolution":"1080P"}}`))
|
||||
if ratios["spec.duration"] != 30.0 {
|
||||
t.Fatalf("smart duration should freeze at 30s: %v", ratios)
|
||||
}
|
||||
if ratios["spec.resolution"] != "1080P" {
|
||||
t.Fatalf("spec.resolution signal lost: %v", ratios)
|
||||
}
|
||||
|
||||
// 顶层 seconds(OpenAI Videos 通用词汇)优先于 metadata
|
||||
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
|
||||
`{"model":"wan3.0-video","prompt":"p","seconds":"12","metadata":{"duration":5}}`))
|
||||
if ratios["spec.duration"] != 12.0 {
|
||||
t.Fatalf("seconds should override metadata duration: %v", ratios)
|
||||
}
|
||||
|
||||
// 未传时长:不产出 spec.duration(引擎按默认 5s 估)
|
||||
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
|
||||
`{"model":"wan3.0-video","prompt":"p"}`))
|
||||
if _, has := ratios["spec.duration"]; has {
|
||||
t.Fatalf("no duration signal expected: %v", ratios)
|
||||
}
|
||||
|
||||
// 图片模型:n>1 产出 count,不产出时长/分辨率信号
|
||||
ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte(
|
||||
`{"model":"wanx-v1","prompt":"p","n":4}`))
|
||||
if ratios["count"] != 4.0 {
|
||||
t.Fatalf("image count signal lost: %v", ratios)
|
||||
}
|
||||
if _, has := ratios["spec.duration"]; has {
|
||||
t.Fatalf("image model must not carry duration signal: %v", ratios)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoRequest_Wan3NativeMedia(t *testing.T) {
|
||||
a := &AliAdaptor{}
|
||||
body := []byte(`{
|
||||
"model": "wan3.0-video",
|
||||
"prompt": "p",
|
||||
"media": [
|
||||
{"type": "reference_video", "url": "https://v.mp4"},
|
||||
{"type": "reference_audio", "url": "https://a.mp3"}
|
||||
],
|
||||
"metadata": {"resolution": "480P", "ratio": "16:9", "duration": 5, "sound": "off"}
|
||||
}`)
|
||||
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var req dashScopeVideoRequest
|
||||
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(req.Input.Media) != 2 || req.Input.Media[0].Type != "reference_video" || req.Input.Media[1].URL != "https://a.mp3" {
|
||||
t.Fatalf("media passthrough lost: %+v", req.Input.Media)
|
||||
}
|
||||
if req.Parameters == nil || req.Parameters.Resolution != "480P" || req.Parameters.Ratio != "16:9" {
|
||||
t.Fatalf("parameters lost: %+v", req.Parameters)
|
||||
}
|
||||
if req.Parameters.Audio == nil || *req.Parameters.Audio {
|
||||
t.Fatalf("sound=off should map to audio=false: %+v", req.Parameters.Audio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoRequest_OpenAIVocabulary(t *testing.T) {
|
||||
a := &AliAdaptor{}
|
||||
// OpenAI Videos 通用词汇:metadata.image → first_frame;size 短边 → resolution;sound → audio
|
||||
body := []byte(`{
|
||||
"model": "wan3.0-video",
|
||||
"prompt": "p",
|
||||
"seconds": "8",
|
||||
"images": ["https://x/1.png"],
|
||||
"metadata": {"image": "https://x/ref.png", "size": "1280x720", "sound": "on", "aspect_ratio": "9:16"}
|
||||
}`)
|
||||
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var req dashScopeVideoRequest
|
||||
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(req.Input.Media) != 1 || req.Input.Media[0].Type != "first_frame" || req.Input.Media[0].URL != "https://x/ref.png" {
|
||||
t.Fatalf("metadata.image should map to first_frame: %+v", req.Input.Media)
|
||||
}
|
||||
if req.Parameters == nil || req.Parameters.Duration == nil || *req.Parameters.Duration != 8 {
|
||||
t.Fatalf("seconds → duration lost: %+v", req.Parameters)
|
||||
}
|
||||
if req.Parameters.Resolution != "720P" {
|
||||
t.Fatalf("size short-edge → resolution lost: %+v", req.Parameters)
|
||||
}
|
||||
if req.Parameters.Ratio != "9:16" {
|
||||
t.Fatalf("aspect_ratio → ratio lost: %+v", req.Parameters)
|
||||
}
|
||||
if req.Parameters.Audio == nil || !*req.Parameters.Audio {
|
||||
t.Fatalf("sound=on should map to audio=true: %+v", req.Parameters.Audio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoRequest_MultiImagesFallback(t *testing.T) {
|
||||
a := &AliAdaptor{}
|
||||
// 无 metadata.image 时,顶层 images[] → reference_image 数组
|
||||
body := []byte(`{"model":"wan3.0-video","prompt":"p","images":["https://x/1.png","https://x/2.png"]}`)
|
||||
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var req dashScopeVideoRequest
|
||||
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(req.Input.Media) != 2 || req.Input.Media[0].Type != "reference_image" || req.Input.Media[1].URL != "https://x/2.png" {
|
||||
t.Fatalf("images fallback lost: %+v", req.Input.Media)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:wan2.x 旧格式不引入 wan3.0 专属行为(无 media、无 audio、size 不派生档位)
|
||||
func TestBuildVideoRequest_Wan2Unchanged(t *testing.T) {
|
||||
a := &AliAdaptor{}
|
||||
body := []byte(`{"model":"wan2.2-t2v-plus","prompt":"p","metadata":{"size":"1024x576","duration":5}}`)
|
||||
r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
var req dashScopeVideoRequest
|
||||
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(req.Input.Media) != 0 {
|
||||
t.Fatalf("wan2 request must not carry media: %+v", req.Input.Media)
|
||||
}
|
||||
if req.Parameters != nil && req.Parameters.Audio != nil {
|
||||
t.Fatalf("wan2 request must not carry audio: %+v", req.Parameters)
|
||||
}
|
||||
if req.Parameters == nil || req.Parameters.Resolution != "" {
|
||||
t.Fatalf("wan2 size must not derive resolution: %+v", req.Parameters)
|
||||
}
|
||||
if req.Parameters.Size != "1024*576" {
|
||||
t.Fatalf("wan2 size passthrough broken: %+v", req.Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTaskResult_Wan3MaterialUsage(t *testing.T) {
|
||||
a := &AliAdaptor{}
|
||||
body := []byte(`{
|
||||
"output": {"task_id": "t1", "task_status": "SUCCEEDED", "video_url": "https://v.mp4"},
|
||||
"usage": {"video_count": 1, "duration": 5.0, "output_video_duration": 5.13, "fps": 30, "SR": 720, "ratio": "16:9"}
|
||||
}`)
|
||||
info, err := a.ParseTaskResult(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://v.mp4" {
|
||||
t.Fatalf("unexpected info: %+v", info)
|
||||
}
|
||||
if info.MaterialUsage == nil {
|
||||
t.Fatal("expected material usage for per_second settlement")
|
||||
}
|
||||
if info.MaterialUsage.OutputSeconds != 5.13 {
|
||||
t.Fatalf("output seconds lost: %+v", info.MaterialUsage)
|
||||
}
|
||||
if info.MaterialUsage.Resolution != "720P" {
|
||||
t.Fatalf("SR → resolution normalization broken: %+v", info.MaterialUsage)
|
||||
}
|
||||
|
||||
// usage 缺失(wan2.x 响应):MaterialUsage 为 nil,结算回退预扣口径
|
||||
info, err = a.ParseTaskResult([]byte(`{"output":{"task_id":"t2","task_status":"SUCCEEDED","video_url":"https://v2.mp4"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if info.MaterialUsage != nil {
|
||||
t.Fatalf("no usage → no material usage: %+v", info.MaterialUsage)
|
||||
}
|
||||
|
||||
// SR=0:Resolution 留空(结算查价回退 ratios 档位)
|
||||
info, err = a.ParseTaskResult([]byte(`{"output":{"task_id":"t3","task_status":"SUCCEEDED","video_url":"u"},"usage":{"output_video_duration":5.0,"SR":0}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if info.MaterialUsage == nil || info.MaterialUsage.Resolution != "" {
|
||||
t.Fatalf("zero SR should yield empty resolution: %+v", info.MaterialUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionFromSize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1280x720": "720P",
|
||||
"720x1280": "720P", // 竖版按短边
|
||||
"854x480": "480P",
|
||||
"1920x1080": "1080P",
|
||||
"1080x1920": "1080P",
|
||||
"1024x1024": "1080P", // 正方形短边 1024 → 1080P
|
||||
"invalid": "",
|
||||
"ax720": "",
|
||||
}
|
||||
for size, want := range cases {
|
||||
if got := resolutionFromSize(size); got != want {
|
||||
t.Errorf("resolutionFromSize(%q) = %q, want %q", size, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ const channelName = "Ali"
|
||||
// ModelList 阿里云 DashScope 支持异步生成的模型列表(图片 + 视频)
|
||||
var ModelList = []string{
|
||||
// 视频生成
|
||||
"wan3.0-video",
|
||||
"wan3.0-video-prime",
|
||||
"wan2.7-t2v-2026-04-25",
|
||||
"wan2.6-t2v",
|
||||
"wan2.6-t2v-us",
|
||||
|
||||
Reference in new issue
Block a user