fix(resource): 签发本地公开资源直链时追加文件扩展名,并对齐 AutoDL 工作流参数 (#341)

Co-authored-by: Danborad <danborad@github.com>
This commit is contained in:
Danborad
2026-08-29 19:31:48 +08:00
committed by GitHub
parent 19b29e243c
commit 1bef8b5f50
9 changed files with 574 additions and 72 deletions

View File

@@ -21,11 +21,20 @@ func TestAutoDLPluginArtifact(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(adapters) != 1 {
if len(adapters) == 0 {
t.Fatalf("loaded adapters = %d", len(adapters))
}
adapter := adapters[0]
if adapter.Metadata().ID != "autodl-comfyui" || adapter.Metadata().Version != "1.0.2" || adapter.Metadata().Execution != "declarative" {
var adapter Adapter
for _, a := range adapters {
if a.Metadata().ID == "autodl-comfyui" {
adapter = a
break
}
}
if adapter == nil {
t.Fatal("autodl-comfyui provider adapter not found")
}
if adapter.Metadata().ID != "autodl-comfyui" || adapter.Metadata().Execution != "declarative" {
t.Fatalf("metadata = %#v", adapter.Metadata())
}
if !adapter.Metadata().RequiresPublicMediaURLs {
@@ -42,11 +51,135 @@ func TestAutoDLPluginArtifact(t *testing.T) {
t.Fatalf("create spec = %#v", create)
}
body, ok := create.Body.(map[string]any)
if !ok || body["prompt"] != "a cinematic test" || body["duration"] != 3 || body["resolution"] != "768p竖" || body["ref_image_0"] != "https://cdn.example/reference.png" {
if !ok || body["prompt"] != "a cinematic test" || body["duration"] != 3 || body["resolution"] != "768p竖" {
t.Fatalf("create body = %#v", create.Body)
}
if _, exists := body["ref_image_1"]; exists {
t.Fatalf("missing reference image was not omitted: %#v", body)
if _, exists := body["ref_image_0"]; exists {
t.Fatalf("no_pic workflow must not send ref_image_0: %#v", body)
}
if _, exists := body["first_frame"]; exists {
t.Fatalf("no_pic workflow must not send first_frame: %#v", body)
}
createV5, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
Model: "minimax_h3_lightx2v_v5_15s", Prompt: "a cinematic test", Duration: 15, Resolution: "768p竖", Images: []MediaReference{{URL: "https://cdn.example/ref0.png"}, {URL: "https://cdn.example/ref1.png"}},
}})
if err != nil {
t.Fatal(err)
}
bodyV5 := createV5.Body.(map[string]any)
if bodyV5["ref_image_0"] != "https://cdn.example/ref0.png" || bodyV5["ref_image_1"] != "https://cdn.example/ref1.png" {
t.Fatalf("v5 create body missing ref images = %#v", bodyV5)
}
if _, exists := bodyV5["first_frame"]; exists {
t.Fatalf("v5 workflow must not send first_frame: %#v", bodyV5)
}
createLightX2V, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
Model: "minimax_h3_lightx2v", Prompt: "a cinematic test", Duration: 5, Resolution: "768p竖", Images: []MediaReference{{URL: "https://cdn.example/first.png"}, {URL: "https://cdn.example/last.png"}},
}})
if err != nil {
t.Fatal(err)
}
bodyLightX2V := createLightX2V.Body.(map[string]any)
if bodyLightX2V["first_frame"] != "https://cdn.example/first.png" || bodyLightX2V["last_frame"] != "https://cdn.example/last.png" {
t.Fatalf("lightx2v body = %#v", bodyLightX2V)
}
if _, exists := bodyLightX2V["ref_image_0"]; exists {
t.Fatalf("lightx2v must not send ref_image_0: %#v", bodyLightX2V)
}
createImageAudio, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
Model: "minimax_h3_image_audio_to_video", Prompt: "should be omitted", Duration: 5, Resolution: "768p横",
Images: []MediaReference{{URL: "https://cdn.example/face.png"}},
Audios: []MediaReference{{URL: "https://cdn.example/voice.mp3"}},
}})
if err != nil {
t.Fatal(err)
}
bodyImageAudio := createImageAudio.Body.(map[string]any)
if bodyImageAudio["ref_image_0"] != "https://cdn.example/face.png" || bodyImageAudio["ref_audio_0"] != "https://cdn.example/voice.mp3" || bodyImageAudio["audio_duration"] != 5 || bodyImageAudio["resolution"] != "768p横" {
t.Fatalf("image_audio body = %#v", bodyImageAudio)
}
if _, exists := bodyImageAudio["prompt"]; exists {
t.Fatalf("image_audio workflow must not send prompt: %#v", bodyImageAudio)
}
if _, exists := bodyImageAudio["duration"]; exists {
t.Fatalf("image_audio workflow must not send duration: %#v", bodyImageAudio)
}
if _, exists := bodyImageAudio["ref_image_1"]; exists {
t.Fatalf("image_audio workflow must not send ref_image_1: %#v", bodyImageAudio)
}
if _, exists := bodyImageAudio["ref_audio_1"]; exists {
t.Fatalf("image_audio workflow must not send ref_audio_1: %#v", bodyImageAudio)
}
createAudioToVideoV2, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
Model: "minimax_h3_image_audio_to_video_v2", Prompt: "cinematic sync", Duration: 8, Resolution: "1080p横",
Images: []MediaReference{{URL: "https://cdn.example/img0.png"}, {URL: "https://cdn.example/img1.png"}},
Audios: []MediaReference{{URL: "https://cdn.example/aud0.mp3"}, {URL: "https://cdn.example/aud1.mp3"}},
}})
if err != nil {
t.Fatal(err)
}
bodyV2 := createAudioToVideoV2.Body.(map[string]any)
if bodyV2["prompt"] != "cinematic sync" || bodyV2["duration"] != 8 || bodyV2["resolution"] != "1080p横" ||
bodyV2["ref_image_0"] != "https://cdn.example/img0.png" || bodyV2["ref_image_1"] != "https://cdn.example/img1.png" ||
bodyV2["ref_audio_0"] != "https://cdn.example/aud0.mp3" || bodyV2["ref_audio_1"] != "https://cdn.example/aud1.mp3" {
t.Fatalf("v2 body = %#v", bodyV2)
}
if _, exists := bodyV2["audio_duration"]; exists {
t.Fatalf("v2 must not send audio_duration: %#v", bodyV2)
}
createB99001, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
Model: "minimax_h3_b99_001", Prompt: "a prompt test", Duration: 5, Resolution: "736p竖",
Images: []MediaReference{{URL: "https://cdn.example/should-be-omitted.png"}},
}})
if err != nil {
t.Fatal(err)
}
bodyB99001 := createB99001.Body.(map[string]any)
if bodyB99001["prompt"] != "a prompt test" || bodyB99001["duration"] != 5 || bodyB99001["resolution"] != "736p竖" {
t.Fatalf("b99_001 body = %#v", bodyB99001)
}
if _, exists := bodyB99001["ref_image_0"]; exists {
t.Fatalf("b99_001 must not send ref_image_0: %#v", bodyB99001)
}
if _, exists := bodyB99001["first_frame"]; exists {
t.Fatalf("b99_001 must not send first_frame: %#v", bodyB99001)
}
createB99002, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
Model: "minimax_h3_b99_002", Prompt: "first to last", Duration: 6, Resolution: "736p横",
Images: []MediaReference{{URL: "https://cdn.example/f0.png"}, {URL: "https://cdn.example/f1.png"}},
}})
if err != nil {
t.Fatal(err)
}
bodyB99002 := createB99002.Body.(map[string]any)
if bodyB99002["prompt"] != "first to last" || bodyB99002["duration"] != 6 || bodyB99002["resolution"] != "736p横" ||
bodyB99002["first_frame"] != "https://cdn.example/f0.png" || bodyB99002["last_frame"] != "https://cdn.example/f1.png" {
t.Fatalf("b99_002 body = %#v", bodyB99002)
}
if _, exists := bodyB99002["ref_image_0"]; exists {
t.Fatalf("b99_002 must not send ref_image_0: %#v", bodyB99002)
}
createB99003, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
Model: "minimax_h3_b99_003_12s", Prompt: "multi image 12s", Duration: 12, Resolution: "736p(1:1)",
Images: []MediaReference{{URL: "https://cdn.example/img0.png"}, {URL: "https://cdn.example/img1.png"}},
}})
if err != nil {
t.Fatal(err)
}
bodyB99003 := createB99003.Body.(map[string]any)
if bodyB99003["prompt"] != "multi image 12s" || bodyB99003["duration"] != 12 || bodyB99003["resolution"] != "736p(1:1)" ||
bodyB99003["ref_image_0"] != "https://cdn.example/img0.png" || bodyB99003["ref_image_1"] != "https://cdn.example/img1.png" {
t.Fatalf("b99_003 body = %#v", bodyB99003)
}
if _, exists := bodyB99003["first_frame"]; exists {
t.Fatalf("b99_003 must not send first_frame: %#v", bodyB99003)
}
failed, err := adapter.ParseCreate(context.Background(), []byte(`{"code":"RequestParameterIsWrong","data":null,"msg":"参数: resolution 的值: 768P 不在 options 列表中"}`))

View File

@@ -372,7 +372,7 @@ func TestDeclarativeManifestKeepsLegacyVideoResolutionTransformCompatible(t *tes
"768P横": "768p横",
}
for input, expected := range tests {
if actual := applyManifestTransform(input, "video-resolution"); actual != expected {
if actual := applyManifestTransform(input, "video-resolution", GenerationRequest{}); actual != expected {
t.Errorf("legacy video resolution transform %q = %#v, want %q", input, actual, expected)
}
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
)
@@ -440,10 +441,38 @@ func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResu
return CreateResult{TaskID: id, Status: status, Result: result, Message: message}
}
var (
manifestMDImageRegex = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
manifestHTMLVideoRegex = regexp.MustCompile(`(?i)<video[^>]+src=['"]([^'"]+)['"]`)
)
func mediaPathValues(payload map[string]any, path string) []string {
value := pathValue(payload, path)
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
return []string{strings.TrimSpace(text)}
trimmed := strings.TrimSpace(text)
if matches := manifestMDImageRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
var res []string
for _, m := range matches {
if len(m) > 1 && m[1] != "" {
res = append(res, m[1])
}
}
if len(res) > 0 {
return res
}
}
if matches := manifestHTMLVideoRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
var res []string
for _, m := range matches {
if len(m) > 1 && m[1] != "" {
res = append(res, m[1])
}
}
if len(res) > 0 {
return res
}
}
return []string{trimmed}
}
items, ok := value.([]any)
if !ok {
@@ -477,9 +506,46 @@ func buildManifestOperation(operation ManifestOperation, request GenerationReque
}
setMapPath(body, key, value)
}
normalizedBody := normalizeManifestValue(body).(map[string]any)
path := strings.ReplaceAll(operation.Path, "{{taskId}}", url.PathEscape(taskID))
path = strings.ReplaceAll(path, "{{model}}", url.PathEscape(request.Model))
return RequestSpec{Method: strings.ToUpper(operation.Method), Path: path, ContentType: defaultValue(operation.ContentType, "application/json"), Body: body}
return RequestSpec{Method: strings.ToUpper(operation.Method), Path: path, ContentType: defaultValue(operation.ContentType, "application/json"), Body: normalizedBody}
}
func normalizeManifestValue(value any) any {
switch v := value.(type) {
case map[string]any:
if len(v) == 0 {
return v
}
isSequential := true
for i := 0; i < len(v); i++ {
if _, ok := v[strconv.Itoa(i)]; !ok {
isSequential = false
break
}
}
if isSequential {
arr := make([]any, len(v))
for i := 0; i < len(v); i++ {
arr[i] = normalizeManifestValue(v[strconv.Itoa(i)])
}
return arr
}
res := make(map[string]any, len(v))
for k, val := range v {
res[k] = normalizeManifestValue(val)
}
return res
case []any:
arr := make([]any, len(v))
for i, val := range v {
arr[i] = normalizeManifestValue(val)
}
return arr
default:
return value
}
}
func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any {
@@ -501,7 +567,10 @@ func manifestExpressionValue(expression string, request GenerationRequest, taskI
value = source
}
for _, transform := range transforms {
value = applyManifestTransform(value, transform)
value = applyManifestTransform(value, transform, request)
if value == nil {
break
}
}
return value
}
@@ -510,9 +579,40 @@ func manifestExpressionValue(expression string, request GenerationRequest, taskI
// platform request. It lets uploaded manifests address media items by index
// without exposing Go structs or adding provider-specific host code.
func manifestRequestValues(request GenerationRequest) map[string]any {
userContent := make([]any, 0)
if strings.TrimSpace(request.Prompt) != "" {
userContent = append(userContent, map[string]any{"type": "text", "text": request.Prompt})
}
for _, img := range request.Images {
val := defaultValue(img.URL, img.DataURL)
if val != "" {
userContent = append(userContent, map[string]any{"type": "image_url", "image_url": map[string]any{"url": val}})
}
}
for _, vid := range request.Videos {
val := defaultValue(vid.URL, vid.DataURL)
if val != "" {
userContent = append(userContent, map[string]any{"type": "video_url", "video_url": map[string]any{"url": val}})
}
}
for _, aud := range request.Audios {
val := defaultValue(aud.URL, aud.DataURL)
if val != "" {
userContent = append(userContent, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": val}})
}
}
messages := make([]any, 0)
if len(userContent) > 0 {
messages = append(messages, map[string]any{
"role": "user",
"content": userContent,
})
}
return map[string]any{
"model": request.Model,
"prompt": request.Prompt,
"messages": messages,
"images": manifestMediaValues(request.Images),
"videos": manifestMediaValues(request.Videos),
"audios": manifestMediaValues(request.Audios),
@@ -528,6 +628,14 @@ func manifestRequestValues(request GenerationRequest) map[string]any {
}
}
func manifestModelID(request GenerationRequest) string {
modelID := strings.TrimSpace(request.Model)
if separator := strings.LastIndex(modelID, "::"); separator >= 0 {
modelID = modelID[separator+2:]
}
return strings.ToLower(modelID)
}
func manifestMediaValues(values []MediaReference) []any {
result := make([]any, 0, len(values))
for _, value := range values {
@@ -541,9 +649,90 @@ func manifestMediaValues(values []MediaReference) []any {
return result
}
func applyManifestTransform(value any, transform string) any {
func applyManifestTransform(value any, transform string, request GenerationRequest) any {
transform = strings.ToLower(strings.TrimSpace(transform))
if transform == "bool" || transform == "boolean" {
switch v := value.(type) {
case bool:
return v
case string:
s := strings.ToLower(strings.TrimSpace(v))
return s == "true" || s == "1" || s == "yes"
case int, int64, float64:
return v != 0
default:
return false
}
}
if transform == "int" || transform == "integer" {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n
}
return 0
default:
return 0
}
}
if transform == "omit_zero" {
switch v := value.(type) {
case int:
if v == 0 {
return nil
}
case int64:
if v == 0 {
return nil
}
case float64:
if v == 0 {
return nil
}
case string:
if strings.TrimSpace(v) == "0" || strings.TrimSpace(v) == "" {
return nil
}
}
}
cleanModel := manifestModelID(request)
if strings.HasPrefix(transform, "omit_unless_model_contains:") {
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_contains:")))
if needle == "" || !strings.Contains(cleanModel, needle) {
return nil
}
}
if strings.HasPrefix(transform, "omit_if_model_contains:") {
needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_contains:")))
if needle != "" && strings.Contains(cleanModel, needle) {
return nil
}
}
if strings.HasPrefix(transform, "omit_unless_model_equals:") {
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_equals:")))
if target == "" || cleanModel != target {
return nil
}
}
if strings.HasPrefix(transform, "omit_if_model_equals:") {
target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_equals:")))
if target != "" && cleanModel == target {
return nil
}
}
text, ok := value.(string)
if transform == "omit_empty" && (!ok || strings.TrimSpace(text) == "") {
return nil
}
if transform == "omit_auto" && (!ok || strings.TrimSpace(text) == "" || strings.EqualFold(strings.TrimSpace(text), "auto") || strings.EqualFold(strings.TrimSpace(text), "default")) {
return nil
}
if !ok {
return value
}
@@ -554,6 +743,11 @@ func applyManifestTransform(value any, transform string) any {
return strings.ToLower(text)
case "upper", "uppercase":
return strings.ToUpper(text)
case "resolution_p":
if regexp.MustCompile(`^\d+$`).MatchString(strings.TrimSpace(text)) {
return strings.TrimSpace(text) + "p"
}
return text
case "video-resolution", "video_resolution", "videoresolution":
// Compatibility for already-installed 1.0.1 manifests. New plugins
// should declare exact enum values and use generic string transforms.