diff --git a/internal/logic/docs/openapi.go b/internal/logic/docs/openapi.go index aec9279..7f81153 100644 --- a/internal/logic/docs/openapi.go +++ b/internal/logic/docs/openapi.go @@ -220,6 +220,8 @@ func buildOpenAPISpec(ctx context.Context) map[string]any { "prompt": map[string]any{"type": "string"}, "seconds": map[string]any{"type": "string", "enum": []string{"4", "8", "12"}, "default": "4"}, "size": map[string]any{"type": "string", "enum": []string{"720x1280", "1280x720", "1024x1792", "1792x1024"}, "default": "720x1280"}, + "aspect_ratio": map[string]any{"type": "string", "description": "扩展字段:屏幕比例,如 16:9 / 9:16 / 1:1;取值原样透传上游,不传由模型取默认档"}, + "sound": map[string]any{"type": "string", "enum": []string{"on", "off"}, "description": "扩展字段:是否包含声音;仅部分模型支持(可灵 / 豆包),其余模型忽略"}, "input_reference": map[string]any{"type": "string", "format": "binary", "description": "可选参考图(图生视频),文件上传或 {\"image_url\": \"...\"} 引用对象;file_id 形态不支持"}, }, }, @@ -233,6 +235,9 @@ func buildOpenAPISpec(ctx context.Context) map[string]any { "prompt": map[string]any{"type": "string"}, "seconds": map[string]any{"type": "string"}, "size": map[string]any{"type": "string"}, + "aspect_ratio": map[string]any{"type": "string", "description": "扩展字段:屏幕比例,如 16:9 / 9:16 / 1:1"}, + "sound": map[string]any{"type": "string", "enum": []string{"on", "off"}, "description": "扩展字段:是否包含声音(也可用布尔形态 generate_audio)"}, + "generate_audio": map[string]any{"type": "boolean", "description": "扩展字段:sound 的等价布尔形态,sound 缺省时生效"}, "input_reference": map[string]any{"type": "object", "properties": map[string]any{"image_url": map[string]any{"type": "string"}}}, }, }, diff --git a/relay/handler/videos_convert.go b/relay/handler/videos_convert.go index 96f5747..f0bfb93 100644 --- a/relay/handler/videos_convert.go +++ b/relay/handler/videos_convert.go @@ -18,8 +18,10 @@ import ( // OpenAI Videos 协议(/v1/videos)入站转换层。 // 官方 SDK(openai-python / openai-node)对 POST /v1/videos 一律发送 multipart/form-data, // 裸 HTTP 客户端常用 JSON——两种编码都要支持;解析后统一转成任务框架的通用请求体 -// {model, prompt, seconds, metadata{duration, aspect_ratio, resolution, image}}, +// {model, prompt, seconds, ratio, metadata{duration, size, ratio, aspect_ratio, sound, generate_audio, image}}, // 由各 taskchannel 适配器按需消费(kling 读顶层 seconds,volcengine 读 metadata.duration 等)。 +// size / aspect_ratio / sound 均为协议扩展字段(官方 Videos 协议无此三项), +// 取值直接透传目标供应商的原生词汇,不做档位换算与白名单校验。 // videosInputReferenceMaxBytes 参考图上传大小上限。 // GoFrame server 默认 ClientMaxBodySize 8MB,base64 膨胀 4/3 后须留余量,取 6MB。 @@ -32,14 +34,18 @@ type videosCreateRequest struct { Seconds string // 统一归一为字符串(官方协议为 "4"/"8"/"12");空 = 未指定 Size string // 原样保留(如 "1280x720");空 = 未指定 InputReference string // 参考图:data URL 或 http(s) URL;空 = 无 + AspectRatio string // 屏幕比例(如 "16:9"/"9:16");空 = 未指定(由适配器取各自默认档) + Sound string // 音频开关,归一为 "on"/"off";空 = 未指定(由上游取默认) } // videosRequestEcho 提交时落 PrivateData.request_echo 的请求回显, // retrieve(GET /v1/videos/{id})时用于回显 prompt/seconds/size(上游响应不含这些字段)。 type videosRequestEcho struct { - Prompt string `json:"prompt"` - Seconds string `json:"seconds,omitempty"` - Size string `json:"size,omitempty"` + Prompt string `json:"prompt"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Sound string `json:"sound,omitempty"` } // jsonVideosCreateRequest JSON 编码的创建请求结构。 @@ -50,6 +56,9 @@ type jsonVideosCreateRequest struct { Seconds json.RawMessage `json:"seconds"` // string 或 number Size string `json:"size"` InputReference json.RawMessage `json:"input_reference"` + AspectRatio string `json:"aspect_ratio"` + Sound json.RawMessage `json:"sound"` // "on"/"off" 或 true/false + GenerateAudio json.RawMessage `json:"generate_audio"` // true/false(generate_audio 词汇,sound 缺省时生效) } // ParseVideosCreateRequest 解析 OpenAI Videos 创建请求(multipart 或 JSON)。 @@ -74,9 +83,10 @@ func parseVideosCreateJSON(body []byte) (*videosCreateRequest, *common.TaskError } out := &videosCreateRequest{ - Model: strings.TrimSpace(req.Model), - Prompt: req.Prompt, - Size: strings.TrimSpace(req.Size), + Model: strings.TrimSpace(req.Model), + Prompt: req.Prompt, + Size: strings.TrimSpace(req.Size), + AspectRatio: strings.TrimSpace(req.AspectRatio), } seconds, terr := normalizeVideosSeconds(req.Seconds) @@ -85,6 +95,19 @@ func parseVideosCreateJSON(body []byte) (*videosCreateRequest, *common.TaskError } out.Seconds = seconds + // 音频开关:sound 更明确,优先;缺省时回退 generate_audio + sound, terr := normalizeVideosSound(req.Sound) + if terr != nil { + return nil, terr + } + if sound == "" { + sound, terr = normalizeVideosSound(req.GenerateAudio) + if terr != nil { + return nil, terr + } + } + out.Sound = sound + ref, terr := parseVideosInputReferenceJSON(req.InputReference) if terr != nil { return nil, terr @@ -115,9 +138,10 @@ func parseVideosCreateMultipart(body []byte, boundary string) (*videosCreateRequ } out := &videosCreateRequest{ - Model: strings.TrimSpace(formValue("model")), - Prompt: formValue("prompt"), - Size: strings.TrimSpace(formValue("size")), + Model: strings.TrimSpace(formValue("model")), + Prompt: formValue("prompt"), + Size: strings.TrimSpace(formValue("size")), + AspectRatio: strings.TrimSpace(formValue("aspect_ratio")), } seconds, terr := normalizeVideosSeconds(json.RawMessage(formValue("seconds"))) @@ -126,6 +150,19 @@ func parseVideosCreateMultipart(body []byte, boundary string) (*videosCreateRequ } out.Seconds = seconds + // multipart 的 part 值是裸字符串,包成 JSON 字符串后再走同一归一逻辑 + sound, terr := normalizeVideosSound(multipartJSONString(formValue("sound"))) + if terr != nil { + return nil, terr + } + if sound == "" { + sound, terr = normalizeVideosSound(multipartJSONString(formValue("generate_audio"))) + if terr != nil { + return nil, terr + } + } + out.Sound = sound + // input_reference:优先文件 part,其次字符串 part(引用对象 JSON) if files := form.File["input_reference"]; len(files) > 0 { fh := files[0] @@ -182,6 +219,53 @@ func normalizeVideosSeconds(raw json.RawMessage) (string, *common.TaskError) { return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid seconds field: must be a string or number"} } +// multipartJSONString 把 multipart part 的裸字符串包成 JSON 字符串字面量, +// 空值返回 nil(视为未传)。这样 multipart 与 JSON 两种编码共用同一套归一逻辑。 +func multipartJSONString(v string) json.RawMessage { + if strings.TrimSpace(v) == "" { + return nil + } + b, err := json.Marshal(v) + if err != nil { + return nil + } + return b +} + +// normalizeVideosSound 归一音频开关:接受 "on"/"off"、"true"/"false"、"1"/"0" +// 以及布尔字面量,统一转成 "on"/"off"(可灵的上游词汇)。空 = 未指定。 +// 不做厂商白名单校验——各上游对音频开关的词汇与默认值不同,归一后由适配器按需消费。 +func normalizeVideosSound(raw json.RawMessage) (string, *common.TaskError) { + if len(raw) == 0 || string(raw) == "null" { + return "", nil + } + + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + if b { + return "on", nil + } + return "off", nil + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + switch strings.ToLower(strings.TrimSpace(s)) { + case "": + return "", nil + case "on", "true", "1", "yes": + return "on", nil + case "off", "false", "0", "no": + return "off", nil + } + } + + return "", &common.TaskError{ + StatusCode: http.StatusBadRequest, + Message: `invalid sound field: must be "on"/"off" or true/false`, + } +} + // parseVideosInputReferenceJSON 解析引用对象形态的 input_reference: // - {"image_url": "https://... 或 data:..."} → 原样透传 // - {"file_id": "..."} → 不支持(平台无 Files API),返回 400 引导改用 image_url/文件上传 @@ -212,7 +296,7 @@ func parseVideosInputReferenceJSON(raw json.RawMessage) (string, *common.TaskErr } // BuildVideosTaskBody 把归一后的创建请求转成任务框架通用请求体(JSON 字节)。 -// 顶层保留 seconds + images,metadata 写入 duration/size/image。 +// 顶层保留 seconds + ratio + images,metadata 写入 duration/size/ratio/aspect_ratio/sound/generate_audio/image。 // size 只借 OpenAI 协议的字段形态、不做任何档位换算:调用方直接传目标供应商的 // 原生分辨率词汇(如 MiniMax 的 768P/2K),原值经 metadata.size 透传给适配器。 func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) { @@ -231,6 +315,18 @@ func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) { if req.Size != "" { metadata["size"] = req.Size } + if req.AspectRatio != "" { + // 比例双写:minimax/volcengine 读 metadata.ratio,kling 读 metadata.aspect_ratio, + // minimax 的 resolveRatio 顶层 ratio 优先级最高,故三处同写 + metadata["ratio"] = req.AspectRatio + metadata["aspect_ratio"] = req.AspectRatio + body["ratio"] = req.AspectRatio + } + if req.Sound != "" { + // 音频开关双写:kling 读 metadata.sound("on"/"off"),volcengine 读 metadata.generate_audio(bool) + metadata["sound"] = req.Sound + metadata["generate_audio"] = req.Sound == "on" + } if req.InputReference != "" { metadata["image"] = req.InputReference // volcengine 图生视频读根级 images 数组(kling 亦有该兜底) @@ -262,8 +358,10 @@ func ValidateVideosCreateRequest(req *videosCreateRequest) *common.TaskError { // 提交时随 PrivateData 落库,retrieve 时回显 prompt/seconds/size) func NewVideosRequestEcho(req *videosCreateRequest) videosRequestEcho { return videosRequestEcho{ - Prompt: req.Prompt, - Seconds: req.Seconds, - Size: req.Size, + Prompt: req.Prompt, + Seconds: req.Seconds, + Size: req.Size, + AspectRatio: req.AspectRatio, + Sound: req.Sound, } } diff --git a/relay/handler/videos_convert_test.go b/relay/handler/videos_convert_test.go index 58fcbba..cc702e7 100644 --- a/relay/handler/videos_convert_test.go +++ b/relay/handler/videos_convert_test.go @@ -223,3 +223,126 @@ func TestValidateVideosCreateRequest(t *testing.T) { t.Fatalf("expected prompt required, got %+v", taskErr) } } + +// TestParseVideosCreateRequest_JSONAspectRatioAndSound 扩展字段(aspect_ratio/sound)解析与归一 +func TestParseVideosCreateRequest_JSONAspectRatioAndSound(t *testing.T) { + cases := []struct { + name string + body string + ratio string + sound string + wantErr bool + }{ + {name: "string on", body: `{"model":"m","prompt":"p","aspect_ratio":"9:16","sound":"on"}`, ratio: "9:16", sound: "on"}, + {name: "string off", body: `{"model":"m","prompt":"p","sound":"off"}`, sound: "off"}, + {name: "bool true", body: `{"model":"m","prompt":"p","sound":true}`, sound: "on"}, + {name: "bool false", body: `{"model":"m","prompt":"p","sound":false}`, sound: "off"}, + {name: "generate_audio fallback", body: `{"model":"m","prompt":"p","generate_audio":true}`, sound: "on"}, + // sound 更明确,同时存在时 sound 优先 + {name: "sound wins over generate_audio", body: `{"model":"m","prompt":"p","sound":"off","generate_audio":true}`, sound: "off"}, + {name: "absent", body: `{"model":"m","prompt":"p"}`}, + {name: "invalid sound", body: `{"model":"m","prompt":"p","sound":"loud"}`, wantErr: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req, taskErr := ParseVideosCreateRequest([]byte(c.body), "application/json") + if c.wantErr { + if taskErr == nil || taskErr.StatusCode != 400 { + t.Fatalf("expected 400, got %+v", taskErr) + } + return + } + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.AspectRatio != c.ratio { + t.Fatalf("expected aspect_ratio %q, got %q", c.ratio, req.AspectRatio) + } + if req.Sound != c.sound { + t.Fatalf("expected sound %q, got %q", c.sound, req.Sound) + } + }) + } +} + +// TestParseVideosCreateRequest_MultipartAspectRatioAndSound multipart 裸字符串 part 走同一归一逻辑 +func TestParseVideosCreateRequest_MultipartAspectRatioAndSound(t *testing.T) { + body, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "kling-v2-master", + "prompt": "a cat", + "aspect_ratio": "1:1", + "sound": "true", + }, "", "", nil) + + req, taskErr := ParseVideosCreateRequest(body, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if req.AspectRatio != "1:1" || req.Sound != "on" { + t.Fatalf("unexpected request: %+v", req) + } +} + +// TestBuildVideosTaskBody_AspectRatioAndSoundDualWrite 比例与音频开关需按各上游词汇双写 +func TestBuildVideosTaskBody_AspectRatioAndSoundDualWrite(t *testing.T) { + req := &videosCreateRequest{ + Model: "kling-v2-master", + Prompt: "a cat", + AspectRatio: "9:16", + Sound: "on", + } + body, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // minimax 的 resolveRatio 顶层 ratio 优先,必须写 + if parsed["ratio"] != "9:16" { + t.Fatalf("expected top-level ratio, got %v", parsed["ratio"]) + } + + meta := parsed["metadata"].(map[string]any) + if meta["ratio"] != "9:16" || meta["aspect_ratio"] != "9:16" { + t.Fatalf("expected metadata ratio + aspect_ratio, got %v", meta) + } + if meta["sound"] != "on" { + t.Fatalf("expected metadata.sound on, got %v", meta["sound"]) + } + if meta["generate_audio"] != true { + t.Fatalf("expected metadata.generate_audio true, got %v", meta["generate_audio"]) + } +} + +// TestBuildVideosTaskBody_SoundOff 关闭音频时 generate_audio 必须为 false(而非缺省) +func TestBuildVideosTaskBody_SoundOff(t *testing.T) { + body, _ := BuildVideosTaskBody(&videosCreateRequest{Model: "m", Prompt: "p", Sound: "off"}) + var parsed map[string]any + json.Unmarshal(body, &parsed) + meta := parsed["metadata"].(map[string]any) + if meta["generate_audio"] != false { + t.Fatalf("expected generate_audio false, got %v", meta["generate_audio"]) + } + if _, ok := parsed["ratio"]; ok { + t.Fatal("expected no ratio without aspect_ratio") + } +} + +// TestNewVideosRequestEcho_ExtendedFields 回显需带上扩展字段(submit/retrieve 回填与排障用) +func TestNewVideosRequestEcho_ExtendedFields(t *testing.T) { + echo := NewVideosRequestEcho(&videosCreateRequest{ + Prompt: "p", Seconds: "8", Size: "768P", AspectRatio: "16:9", Sound: "off", + }) + b, err := json.Marshal(echo) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]any + json.Unmarshal(b, &got) + if got["aspect_ratio"] != "16:9" || got["sound"] != "off" { + t.Fatalf("unexpected echo: %s", b) + } +} diff --git a/web/tenant/src/styles/main.css b/web/tenant/src/styles/main.css index faa91ca..980772c 100644 --- a/web/tenant/src/styles/main.css +++ b/web/tenant/src/styles/main.css @@ -257,6 +257,48 @@ body.modal-open { @apply mt-1 text-xs text-red-500; } +/* ============ Playground 输入容器 ============ */ +/* 把附件片、输入框、操作按钮收进同一个圆角盒子,聚焦时整块高亮。 + 对话、视频两个 Tab 共用,故放在全局而非某个组件的 scoped 里。 */ +.input-shell { + border: 1px solid #e5e7eb; + border-radius: 1rem; + background: #fff; + padding: 0.625rem 0.75rem 0.5rem; + transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease; +} +/* 聚焦与拖拽时整块高亮,而不是只高亮里面的 textarea */ +.input-shell:focus-within { + border-color: rgba(20, 184, 166, 0.55); + box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.09); +} +.input-shell-dragging { + border-color: #2dd4bf; + border-style: dashed; + background: rgba(240, 253, 250, 0.6); +} + +/* 输入区内的小工具按钮(附件 / 清空 等) */ +.input-tool { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border-radius: 0.5rem; + padding: 0.25rem 0.5rem; + color: #6b7280; + font-size: 0.75rem; + line-height: 1.25rem; + transition: background-color 150ms ease, color 150ms ease; +} +.input-tool:hover:not(:disabled) { + background: #f3f4f6; + color: #0d9488; +} +.input-tool:disabled { + cursor: not-allowed; + color: #d1d5db; +} + /* ============ 液态玻璃效果 ============ */ /* 大面积磨砂(header、sticky 栏) */ .glass { diff --git a/web/tenant/src/views/marketplace/MarketplacePage.vue b/web/tenant/src/views/marketplace/MarketplacePage.vue index 8b3e0a7..babb01e 100644 --- a/web/tenant/src/views/marketplace/MarketplacePage.vue +++ b/web/tenant/src/views/marketplace/MarketplacePage.vue @@ -204,18 +204,17 @@ useSeo({
-
+
{{ siteName }} @@ -231,188 +230,179 @@ useSeo({
-
- -
-
- - - - -
-
-

- 共 {{ total }} 个模型 - -

-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
- -

模型列表加载失败

-

网络可能暂时不可用,请稍后重试。

- -
- - -
- -

没有找到匹配的模型

-

换一个关键词,或者清除当前筛选条件后再试。

- -
- - -
- -
- - -
- -
+ +
+
+
+ + +
+ +
+

+ 共 {{ total }} 个模型 + +

+ +
+
+ + +
+ + +
+ + +
+ +
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +

模型列表加载失败

+

网络可能暂时不可用,请稍后重试。

+ +
+ + +
+ +

没有找到匹配的模型

+

换一个关键词,或者清除当前筛选条件后再试。

+ +
+ + +
+ +
+ + +
+ +
+