diff --git a/internal/server/image_options.go b/internal/server/image_options.go new file mode 100644 index 0000000..6ef6903 --- /dev/null +++ b/internal/server/image_options.go @@ -0,0 +1,197 @@ +package server + +import ( + "fmt" + "strconv" + "strings" +) + +var imageAspectRatioSeparators = strings.NewReplacer( + ":", ":", + "/", ":", + "/", ":", + "×", ":", + "*", ":", + "x", ":", + "X", ":", +) + +var canonicalImageAspectRatioLabels = map[string]string{ + "7:3": "21:9", + "16:9": "16:9", + "4:3": "4:3", + "3:2": "3:2", + "1:1": "1:1", + "9:16": "9:16", + "3:4": "3:4", + "2:3": "2:3", + "5:4": "5:4", + "4:5": "4:5", +} + +var seedreamAspectRatioSizes = map[string]string{ + "21:9": "2520x1080", + "16:9": "2048x1152", + "4:3": "2048x1536", + "3:2": "2304x1536", + "1:1": "2048x2048", + "9:16": "1152x2048", + "3:4": "1536x2048", + "2:3": "1536x2304", + "5:4": "1920x1536", + "4:5": "1536x1920", +} + +var exactImageSizeAspectRatios = map[string]string{ + "1024x1024": "1:1", + "1920x1920": "1:1", + "1536x1024": "3:2", + "1024x1536": "2:3", + "1792x1024": "16:9", + "1024x1792": "9:16", +} + +func normalizeImageAspectRatio(value string) string { + width, height, ok := parseAspectRatioDimensions(value) + if !ok { + return strings.TrimSpace(value) + } + return canonicalAspectRatioLabel(width, height) +} + +func parseAspectRatioDimensions(value string) (int, int, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, 0, false + } + + normalized := imageAspectRatioSeparators.Replace(value) + parts := strings.Split(normalized, ":") + if len(parts) != 2 { + return 0, 0, false + } + + width, errWidth := strconv.Atoi(strings.TrimSpace(parts[0])) + height, errHeight := strconv.Atoi(strings.TrimSpace(parts[1])) + if errWidth != nil || errHeight != nil || width <= 0 || height <= 0 { + return 0, 0, false + } + return width, height, true +} + +func canonicalAspectRatioLabel(width, height int) string { + rw, rh := reduceImageRatio(width, height) + key := fmt.Sprintf("%d:%d", rw, rh) + if label, ok := canonicalImageAspectRatioLabels[key]; ok { + return label + } + return key +} + +func reduceImageRatio(width, height int) (int, int) { + g := imageGCD(width, height) + if g == 0 { + return width, height + } + return width / g, height / g +} + +func imageGCD(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + return a +} + +func parseExactImageSize(value string) (int, int, bool) { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return 0, 0, false + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return 0, 0, false + } + width, errWidth := strconv.Atoi(strings.TrimSpace(parts[0])) + height, errHeight := strconv.Atoi(strings.TrimSpace(parts[1])) + if errWidth != nil || errHeight != nil || width <= 0 || height <= 0 { + return 0, 0, false + } + return width, height, true +} + +func normalizeImageSizeToken(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + switch strings.ToUpper(value) { + case "1K", "2K", "4K": + return strings.ToUpper(value) + } + width, height, ok := parseExactImageSize(value) + if !ok { + return value + } + return fmt.Sprintf("%dx%d", width, height) +} + +func deriveAspectRatioFromImageSize(value string) string { + value = normalizeImageSizeToken(value) + if ratio, ok := exactImageSizeAspectRatios[value]; ok { + return ratio + } + width, height, ok := parseExactImageSize(value) + if !ok { + if ratio := normalizeImageAspectRatio(value); ratio != "" { + return ratio + } + return "" + } + return canonicalAspectRatioLabel(width, height) +} + +func geminiImageSizeFromValue(value string) string { + value = normalizeImageSizeToken(value) + switch value { + case "1K", "2K", "4K": + return value + } + width, height, ok := parseExactImageSize(value) + if !ok { + return "" + } + longEdge := width + if height > longEdge { + longEdge = height + } + switch { + case longEdge <= 1024: + return "1K" + case longEdge <= 2560: + return "2K" + default: + return "4K" + } +} + +func seedreamImageSizeFromAspectRatio(value string) string { + if ratio := normalizeImageAspectRatio(value); ratio != "" { + if size, ok := seedreamAspectRatioSizes[ratio]; ok { + return size + } + } + return "" +} + +func supportedSeedreamSize(modelID, value string) bool { + value = strings.ToUpper(strings.TrimSpace(value)) + if value == "" { + return false + } + _, ok := supportedSeedreamSizes(modelID)[value] + return ok +} diff --git a/internal/server/minimax_img2img_test.go b/internal/server/minimax_img2img_test.go new file mode 100644 index 0000000..67ebdc0 --- /dev/null +++ b/internal/server/minimax_img2img_test.go @@ -0,0 +1,114 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestExecuteImageToImageJobUsesGenerationsPathForMiniMax(t *testing.T) { + var gotPath string + var gotBody map[string]any + + var apiSrv *httptest.Server + apiSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/reference.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(tinyPNG(t)) + case "/v1/images/generations": + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "created": 1711234567, + "data": [{"url":"` + strings.TrimRight(apiSrv.URL, "/") + `/generated.png"}] + }`)) + case "/generated.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(tinyPNG(t)) + default: + http.NotFound(w, r) + } + })) + defer apiSrv.Close() + + cfg := Config{ + NewAPIBaseURL: apiSrv.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + PublicBaseURL: "http://127.0.0.1:8080", + } + server, err := NewWithConfig(cfg) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + server.newapi.httpClient = apiSrv.Client() + + sessionToken, _, ok, err := server.store.createSession("sk-test-upstream") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + current, exists, err := server.store.session(sessionToken) + if err != nil { + t.Fatalf("load session: %v", err) + } + if !exists { + t.Fatal("expected stored session") + } + + record, _, err := server.store.createJob( + "", + "image.img2img", + "image-01", + "sync_result", + map[string]any{ + "prompt": "turn this into a poster", + "image": apiSrv.URL + "/reference.png", + "aspect_ratio": "3:4", + "response_format": "url", + }, + "", + "normal", + "", + current, + ) + if err != nil { + t.Fatalf("createJob: %v", err) + } + + server.executeImageToImageJob(record) + + if gotPath != "/v1/images/generations" { + t.Fatalf("expected minimax img2img to use /v1/images/generations, got %q", gotPath) + } + if gotBody["model"] != "image-01" { + t.Fatalf("unexpected model payload: %#v", gotBody["model"]) + } + if gotBody["image"] != apiSrv.URL+"/reference.png" { + t.Fatalf("expected generations payload image field, got %#v", gotBody["image"]) + } + + done, exists, err := server.store.getJob(current.User.ID, record.JobID) + if err != nil { + t.Fatalf("getJob: %v", err) + } + if !exists { + t.Fatal("expected stored job") + } + if done.Status != "done" { + t.Fatalf("expected job done, got %#v", done.Status) + } + if len(done.ArtifactIDs) != 1 { + t.Fatalf("expected one artifact id, got %#v", done.ArtifactIDs) + } +} diff --git a/internal/server/newapi.go b/internal/server/newapi.go index b9f4493..27356dc 100644 --- a/internal/server/newapi.go +++ b/internal/server/newapi.go @@ -391,8 +391,10 @@ func classifyModelIDFallback(modelID string) (string, []string) { strings.Contains(lowerID, "runway"), strings.Contains(lowerID, "kling"), strings.Contains(lowerID, "pixverse"), + strings.Contains(lowerID, "hailuo"), strings.Contains(lowerID, "hunyuan-video"), strings.Contains(lowerID, "video"), + strings.Contains(lowerID, "s2v"), strings.Contains(lowerID, "t2v"), strings.Contains(lowerID, "i2v"): return "video", []string{"text2video", "image2video"} @@ -420,6 +422,9 @@ func (c *newAPIClient) generateImageRefs(ctx context.Context, token, modelID str if useSeedreamImageGenerations(modelID) { return c.generateSeedreamImageRefs(ctx, token, modelID, input, nil) } + if useMiniMaxImageGenerations(modelID) { + return c.generateMiniMaxImageRefs(ctx, token, modelID, input, nil) + } token = c.authorizedToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") @@ -484,6 +489,9 @@ func (c *newAPIClient) generateEditedImageRefs(ctx context.Context, token, model if useSeedreamImageGenerations(modelID) { return c.generateSeedreamImageRefs(ctx, token, modelID, input, []imageEditReference{ref}) } + if useMiniMaxImageGenerations(modelID) { + return c.generateMiniMaxImageRefs(ctx, token, modelID, input, []imageEditReference{ref}) + } token = c.authorizedImageEditToken(token) if token == "" { return nil, nil, errors.New("PopiNewAPI token is not configured") @@ -575,7 +583,11 @@ func (c *newAPIClient) generateSeedreamImageRefs(ctx context.Context, token, mod "prompt": prompt, "response_format": defaultString(strings.TrimSpace(stringValue(input["response_format"])), "url"), } - if size := resolveSeedreamImageSize(modelID, input); size != "" { + size, err := resolveSeedreamImageSize(modelID, input) + if err != nil { + return nil, nil, err + } + if size != "" { payload["size"] = size } if value, ok := input["seed"]; ok { @@ -624,6 +636,89 @@ func (c *newAPIClient) generateSeedreamImageRefs(ctx context.Context, token, mod return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } +func (c *newAPIClient) generateMiniMaxImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { + if !c.enabled() { + return nil, nil, errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return nil, nil, errors.New("PopiNewAPI token is not configured") + } + + prompt := strings.TrimSpace(stringValue(input["prompt"])) + if prompt == "" { + return nil, nil, errors.New("prompt is required") + } + + payload := map[string]any{ + "model": modelID, + "prompt": prompt, + "response_format": defaultString(strings.TrimSpace(stringValue(input["response_format"])), "url"), + } + if value, ok := input["size"]; ok { + payload["size"] = value + } + if value, ok := input["aspect_ratio"]; ok { + payload["aspect_ratio"] = normalizeImageAspectRatio(stringValue(value)) + } + if value, ok := input["n"]; ok { + payload["n"] = value + } + if value, ok := input["watermark"]; ok { + payload["watermark"] = value + } + if value, ok := input["seed"]; ok { + payload["seed"] = value + } + if value, ok := input["style"]; ok { + payload["style"] = value + } + if value, ok := input["prompt_optimizer"]; ok { + payload["prompt_optimizer"] = value + } + if value, ok := input["subject_reference"]; ok { + payload["subject_reference"] = value + } + if len(refs) > 0 { + imageInput, err := encodeImageReferenceInputs(refs) + if err != nil { + return nil, nil, err + } + payload["image"] = imageInput + } else if value, ok := input["image"]; ok { + payload["image"] = value + } else if value, ok := input["image_url"]; ok { + payload["image"] = value + } else if value, ok := input["reference_image_url"]; ok { + payload["image"] = value + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/images/generations", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + + return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) +} + func (c *newAPIClient) generateGeminiImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") @@ -1115,56 +1210,68 @@ func useSeedreamImageGenerations(modelID string) bool { return strings.Contains(lowerID, "seedream") } +func useMiniMaxImageGenerations(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return lowerID == "image-01" || lowerID == "image-01-live" +} + +func useMiniMaxVideoGenerations(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.HasPrefix(lowerID, "t2v-") || + strings.HasPrefix(lowerID, "i2v-") || + strings.HasPrefix(lowerID, "s2v-") || + strings.Contains(lowerID, "minimax-hailuo") +} + func resolveGeminiAspectRatio(input map[string]any) string { - if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { - return aspectRatio + if aspectRatio := normalizeImageAspectRatio(stringValue(input["aspect_ratio"])); aspectRatio != "" { + if _, ok := seedreamAspectRatioSizes[aspectRatio]; ok { + return aspectRatio + } } - size := strings.TrimSpace(stringValue(input["size"])) - if size == "" { - return "" - } - switch size { - case "1024x1024", "1920x1920": - return "1:1" - case "1536x1024": - return "3:2" - case "1024x1536": - return "2:3" - case "1792x1024": - return "16:9" - case "1024x1792": - return "9:16" - default: - return "" + for _, value := range []string{ + stringValue(input["size"]), + stringValue(input["resolution"]), + } { + if aspectRatio := deriveAspectRatioFromImageSize(value); aspectRatio != "" { + if _, ok := seedreamAspectRatioSizes[aspectRatio]; ok { + return aspectRatio + } + } } + return "" } func resolveGeminiImageSize(input map[string]any) string { - if resolution := strings.ToUpper(strings.TrimSpace(stringValue(input["resolution"]))); resolution != "" { - return resolution - } - size := strings.TrimSpace(stringValue(input["size"])) - switch size { - case "1024x1024": - return "1K" - case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "1920x1920": - return "2K" - default: - return "" - } -} - -func resolveSeedreamImageSize(modelID string, input map[string]any) string { - supported := supportedSeedreamSizes(modelID) for _, value := range []string{ - strings.ToUpper(strings.TrimSpace(stringValue(input["resolution"]))), - strings.ToUpper(strings.TrimSpace(stringValue(input["size"]))), + stringValue(input["resolution"]), + stringValue(input["size"]), } { - if _, ok := supported[value]; ok { - return value + if imageSize := geminiImageSizeFromValue(value); imageSize != "" { + return imageSize } } - return "2K" + return "" +} + +func resolveSeedreamImageSize(modelID string, input map[string]any) (string, error) { + for _, raw := range []string{ + stringValue(input["resolution"]), + stringValue(input["size"]), + } { + value := normalizeImageSizeToken(raw) + if value == "" { + continue + } + if supportedSeedreamSize(modelID, value) { + return value, nil + } + return "", fmt.Errorf("unsupported seedream size %q for model %s; use preset sizes only", value, modelID) + } + if aspectRatio := normalizeImageAspectRatio(stringValue(input["aspect_ratio"])); aspectRatio != "" { + return "", fmt.Errorf("seedream model %s requires preset size values (2K/3K/4K); aspect_ratio %q is not supported", modelID, aspectRatio) + } + return "2K", nil } func supportedSeedreamSizes(modelID string) map[string]struct{} { @@ -1181,10 +1288,10 @@ func supportedSeedreamSizes(modelID string) map[string]struct{} { } } -func encodeSeedreamReferenceInputs(refs []imageEditReference) (any, error) { +func encodeImageReferenceInputs(refs []imageEditReference) (any, error) { encoded := make([]string, 0, len(refs)) for _, ref := range refs { - value, err := encodeSeedreamReference(ref) + value, err := encodeImageReference(ref) if err != nil { return nil, err } @@ -1196,7 +1303,11 @@ func encodeSeedreamReferenceInputs(refs []imageEditReference) (any, error) { return encoded, nil } -func encodeSeedreamReference(ref imageEditReference) (string, error) { +func encodeSeedreamReferenceInputs(refs []imageEditReference) (any, error) { + return encodeImageReferenceInputs(refs) +} + +func encodeImageReference(ref imageEditReference) (string, error) { if rawURL := strings.TrimSpace(ref.URL); rawURL != "" { return rawURL, nil } @@ -1210,6 +1321,10 @@ func encodeSeedreamReference(ref imageEditReference) (string, error) { return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(ref.Content), nil } +func encodeSeedreamReference(ref imageEditReference) (string, error) { + return encodeImageReference(ref) +} + func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) { switch ref.Kind { case "local_path": @@ -1358,6 +1473,164 @@ func resolveVideoSize(modelID string, input map[string]any, ref imageEditReferen return size } +func resolveMiniMaxVideoResolution(input map[string]any, refs []imageEditReference) string { + if input != nil { + for _, raw := range []string{ + stringValue(input["resolution"]), + stringValue(input["size"]), + } { + value := strings.ToUpper(strings.TrimSpace(raw)) + switch value { + case "512P", "720P", "768P", "1080P": + return value + } + switch strings.TrimSpace(raw) { + case "512x512": + return "512P" + case "720x1280", "1280x720": + return "720P" + case "768x768": + return "768P" + case "1080x1920", "1920x1080": + return "1080P" + } + switch { + case strings.Contains(value, "1080"): + return "1080P" + case strings.Contains(value, "768"): + return "768P" + case strings.Contains(value, "720"): + return "720P" + case strings.Contains(value, "512"): + return "512P" + } + } + } + if len(refs) > 0 { + return "720P" + } + return "720P" +} + +func resolveMiniMaxVideoDuration(input map[string]any) int { + duration := 0 + for _, value := range []any{input["duration"], input["duration_s"], input["seconds"]} { + switch typed := value.(type) { + case int: + duration = typed + case int32: + duration = int(typed) + case int64: + duration = int(typed) + case float64: + duration = int(typed) + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil { + duration = parsed + } + } + if duration > 0 { + break + } + } + if duration <= 0 { + return 6 + } + return duration +} + +func (c *newAPIClient) submitMiniMaxVideoTask(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) (string, error) { + if !c.enabled() { + return "", errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return "", errors.New("PopiNewAPI token is not configured") + } + + prompt := strings.TrimSpace(stringValue(input["prompt"])) + if prompt == "" { + return "", errors.New("prompt is required") + } + + payload := map[string]any{ + "model": modelID, + "prompt": prompt, + "size": resolveMiniMaxVideoResolution(input, refs), + "duration": resolveMiniMaxVideoDuration(input), + } + + if len(refs) > 0 { + images := make([]string, 0, len(refs)) + for _, ref := range refs { + encoded, err := encodeImageReference(ref) + if err != nil { + return "", err + } + images = append(images, encoded) + } + payload["images"] = images + } + + metadata := map[string]any{} + for _, key := range []string{"prompt_optimizer", "fast_pretreatment", "callback_url", "aigc_watermark"} { + if value, ok := input[key]; ok { + metadata[key] = value + } + } + if len(metadata) > 0 { + payload["metadata"] = metadata + } + + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/video/generations", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", decodeTaskAPIError(respBody, resp.StatusCode) + } + + var decoded openAIVideoResponse + if err := json.Unmarshal(respBody, &decoded); err == nil { + taskID := strings.TrimSpace(decoded.ID) + if taskID == "" { + taskID = strings.TrimSpace(decoded.TaskID) + } + if taskID != "" { + return taskID, nil + } + } + + var envelope taskEnvelopeResponse + if err := json.Unmarshal(respBody, &envelope); err == nil && len(envelope.Data) > 0 { + var task taskDTOResponse + if err := json.Unmarshal(envelope.Data, &task); err == nil && strings.TrimSpace(task.TaskID) != "" { + return strings.TrimSpace(task.TaskID), nil + } + } + + return "", errors.New("PopiNewAPI returned no task id") +} + func resolveVideoAspectRatio(input map[string]any, ref imageEditReference) string { if input != nil { if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { diff --git a/internal/server/newapi_image_test.go b/internal/server/newapi_image_test.go index 2855f8b..f857b7b 100644 --- a/internal/server/newapi_image_test.go +++ b/internal/server/newapi_image_test.go @@ -129,7 +129,7 @@ func TestGenerateSeedreamImageRefsUsesImagesGenerationsEndpoint(t *testing.T) { refs, usage, err := client.generateImageRefs(context.Background(), "sk-test", "seedream-4-5-251128", map[string]any{ "prompt": "draw a golden retriever in the park", - "size": "1024x1024", + "size": "2K", }) if err != nil { t.Fatalf("generateImageRefs: %v", err) @@ -141,7 +141,7 @@ func TestGenerateSeedreamImageRefsUsesImagesGenerationsEndpoint(t *testing.T) { t.Fatalf("unexpected auth header: %q", gotAuth) } if gotBody["size"] != "2K" { - t.Fatalf("expected normalized 2K size, got %#v", gotBody["size"]) + t.Fatalf("expected preset 2K size, got %#v", gotBody["size"]) } if gotBody["response_format"] != "url" { t.Fatalf("expected default response_format=url, got %#v", gotBody["response_format"]) @@ -184,7 +184,7 @@ func TestGenerateEditedImageRefsUsesSeedreamImagesGenerationsEndpoint(t *testing refs, usage, err := client.generateEditedImageRefs(context.Background(), "sk-test", "seedream-4-5-251128", map[string]any{ "prompt": "edit this image into a dusk scene", - "size": "1024x1536", + "size": "2K", }, imageEditReference{ Filename: "source.jpg", ContentType: "image/jpeg", @@ -201,7 +201,7 @@ func TestGenerateEditedImageRefsUsesSeedreamImagesGenerationsEndpoint(t *testing t.Fatalf("unexpected auth header: %q", gotAuth) } if gotBody["size"] != "2K" { - t.Fatalf("expected normalized 2K size, got %#v", gotBody["size"]) + t.Fatalf("expected preset 2K size, got %#v", gotBody["size"]) } if gotBody["image"] != "https://example.com/reference.jpg" { t.Fatalf("expected image URL payload, got %#v", gotBody["image"]) @@ -218,9 +218,15 @@ func TestResolveGeminiAspectRatioFromSize(t *testing.T) { if got := resolveGeminiAspectRatio(map[string]any{"size": "1792x1024"}); got != "16:9" { t.Fatalf("expected 16:9, got %q", got) } + if got := resolveGeminiAspectRatio(map[string]any{"size": "2520x1080"}); got != "21:9" { + t.Fatalf("expected 21:9 from exact size, got %q", got) + } if got := resolveGeminiAspectRatio(map[string]any{"aspect_ratio": "4:5"}); got != "4:5" { t.Fatalf("expected explicit aspect ratio to win, got %q", got) } + if got := resolveGeminiAspectRatio(map[string]any{"aspect_ratio": "4x5"}); got != "4:5" { + t.Fatalf("expected normalized 4:5, got %q", got) + } } func TestResolveGeminiImageSizeFromResolutionOrSize(t *testing.T) { @@ -230,16 +236,127 @@ func TestResolveGeminiImageSizeFromResolutionOrSize(t *testing.T) { if got := resolveGeminiImageSize(map[string]any{"size": "1024x1024"}); got != "1K" { t.Fatalf("expected 1K from square preset, got %q", got) } + if got := resolveGeminiImageSize(map[string]any{"size": "2520x1080"}); got != "2K" { + t.Fatalf("expected 2K bucket for 2520x1080, got %q", got) + } } func TestResolveSeedreamImageSizeUsesSupportedPresetOrFallback(t *testing.T) { - if got := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"resolution": "4K"}); got != "4K" { + got, err := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"resolution": "4K"}) + if err != nil { + t.Fatalf("expected no error for 4K preset, got %v", err) + } + if got != "4K" { t.Fatalf("expected 4K for Seedream 4.5, got %q", got) } - if got := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"size": "1024x1536"}); got != "2K" { - t.Fatalf("expected 2K fallback for pixel size, got %q", got) + if _, err := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"size": "1024x1536"}); err == nil { + t.Fatal("expected exact pixel size to be rejected for Seedream") } - if got := resolveSeedreamImageSize("doubao-seedream-5-0-260128", map[string]any{"resolution": "3K"}); got != "3K" { + got, err = resolveSeedreamImageSize("doubao-seedream-5-0-260128", map[string]any{"resolution": "3K"}) + if err != nil { + t.Fatalf("expected no error for 3K preset, got %v", err) + } + if got != "3K" { t.Fatalf("expected 3K for Seedream 5.0, got %q", got) } + if _, err := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"size": "2048x1152"}); err == nil { + t.Fatal("expected exact size passthrough to be rejected for Seedream") + } + if _, err := resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{"aspect_ratio": "4:5"}); err == nil { + t.Fatal("expected aspect_ratio to be rejected for Seedream") + } + got, err = resolveSeedreamImageSize("doubao-seedream-4-5-251128", map[string]any{}) + if err != nil { + t.Fatalf("expected default 2K without explicit size, got %v", err) + } + if got != "2K" { + t.Fatalf("expected default 2K, got %q", got) + } +} + +func TestGenerateMiniMaxImageRefsUsesImagesGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "created": 1711234567, + "data": [{"url":"https://example.com/minimax.png"}] + }`)) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + refs, _, err := client.generateImageRefs(context.Background(), "sk-test", "image-01", map[string]any{ + "prompt": "draw a portrait", + "aspect_ratio": "4x5", + "response_format": "b64_json", + "prompt_optimizer": true, + "watermark": false, + }) + if err != nil { + t.Fatalf("generateImageRefs: %v", err) + } + if gotPath != "/v1/images/generations" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["aspect_ratio"] != "4:5" { + t.Fatalf("expected normalized aspect ratio 4:5, got %#v", gotBody["aspect_ratio"]) + } + if gotBody["response_format"] != "b64_json" { + t.Fatalf("expected b64_json response format, got %#v", gotBody["response_format"]) + } + if gotBody["prompt_optimizer"] != true { + t.Fatalf("expected prompt_optimizer=true, got %#v", gotBody["prompt_optimizer"]) + } + if len(refs) != 1 || refs[0].URL != "https://example.com/minimax.png" { + t.Fatalf("unexpected refs: %#v", refs) + } +} + +func TestGenerateMiniMaxEditedImageRefsSendsStandardImageField(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "created": 1711234567, + "data": [{"url":"https://example.com/minimax-edit.png"}] + }`)) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + _, _, err := client.generateEditedImageRefs(context.Background(), "sk-test", "image-01", map[string]any{ + "prompt": "turn it into watercolor", + "size": "832x1248", + "response_format": "url", + }, imageEditReference{ + URL: "https://example.com/reference.jpg", + }) + if err != nil { + t.Fatalf("generateEditedImageRefs: %v", err) + } + if gotBody["image"] != "https://example.com/reference.jpg" { + t.Fatalf("expected standard image field, got %#v", gotBody["image"]) + } + if gotBody["size"] != "832x1248" { + t.Fatalf("expected size passthrough for minimax mapping, got %#v", gotBody["size"]) + } } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index abe735b..7dfd875 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -23,11 +23,16 @@ func TestConfigFromEnvDefaultsVideoRouteToViduQ2(t *testing.T) { func TestInferRouteKeyForModelRecognizesViduAsVideo(t *testing.T) { cases := map[string]string{ - "viduq2": "video.image2video", - "vidu2.0": "video.image2video", - "sora-2": "video.image2video", - "runway-gen4": "video.image2video", - "gpt-image-1": "image.text2image", + "viduq2": "video.image2video", + "vidu2.0": "video.image2video", + "sora-2": "video.image2video", + "runway-gen4": "video.image2video", + "T2V-01": "video.image2video", + "I2V-01": "video.image2video", + "S2V-01": "video.image2video", + "MiniMax-Hailuo-2.3": "video.image2video", + "MiniMax-Hailuo-02": "video.image2video", + "gpt-image-1": "image.text2image", } for modelID, want := range cases { diff --git a/internal/server/server.go b/internal/server/server.go index bf3d420..deff402 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1170,7 +1170,15 @@ func (s *Server) executeImageToImageJob(record *job) { return } - refs, usage, err := s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, ref) + var ( + refs []resultRef + usage map[string]any + ) + if useMiniMaxImageGenerations(modelID) { + refs, usage, err = s.newapi.generateMiniMaxImageRefs(ctx, record.UpstreamKey, modelID, input, []imageEditReference{ref}) + } else { + refs, usage, err = s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, ref) + } if err != nil { if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{ "model_id": modelID, @@ -1224,18 +1232,41 @@ func (s *Server) executeImageToVideoJob(record *job) { submitCtx, cancelSubmit := context.WithTimeout(context.Background(), 2*time.Minute) defer cancelSubmit() - ref, err := s.resolveImageToImageReference(submitCtx, record, input) - if err != nil { - if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{ - "skill_id": record.SkillID, - "route_key": record.RouteKey, - }); repoErr != nil { - log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + var refsForMiniMax []imageEditReference + var ref imageEditReference + var err error + if useMiniMaxVideoGenerations(modelID) { + if hasVideoReferenceInput(input) { + refsForMiniMax, err = s.resolveVideoReferences(submitCtx, record, input) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{ + "skill_id": record.SkillID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + } + } else { + ref, err = s.resolveImageToImageReference(submitCtx, record, input) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{ + "skill_id": record.SkillID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return } - return } - upstreamTaskID, err := s.newapi.submitImageToVideoTask(submitCtx, record.UpstreamKey, modelID, input, ref) + var upstreamTaskID string + if useMiniMaxVideoGenerations(modelID) { + upstreamTaskID, err = s.newapi.submitMiniMaxVideoTask(submitCtx, record.UpstreamKey, modelID, input, refsForMiniMax) + } else { + upstreamTaskID, err = s.newapi.submitImageToVideoTask(submitCtx, record.UpstreamKey, modelID, input, ref) + } if err != nil { if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{ "model_id": modelID, @@ -1305,6 +1336,18 @@ func (s *Server) executeImageToVideoJob(record *job) { } } +func hasVideoReferenceInput(input map[string]any) bool { + if input == nil { + return false + } + return strings.TrimSpace(stringValue( + input["source_artifact_id"], + input["image"], + input["image_url"], + input["reference_image_url"], + )) != "" || len(extractStringValues(input["images"])) > 0 +} + func (s *Server) executeUnsupportedSkill(record *job) { if _, _, err := s.store.startJob(record.JobID); err != nil { log.Printf("popiartServer: start job %s failed: %v", record.JobID, err) @@ -1394,6 +1437,68 @@ func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, return s.downloadReferenceImage(ctx, refURL) } +func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) { + if record == nil { + return nil, fmt.Errorf("job record is required") + } + if artifactID := strings.TrimSpace(stringValue(input["source_artifact_id"])); artifactID != "" { + ref, err := s.resolveImageToImageReference(ctx, record, map[string]any{ + "source_artifact_id": artifactID, + }) + if err != nil { + return nil, err + } + return []imageEditReference{ref}, nil + } + + urls := extractStringValues(input["images"]) + if len(urls) == 0 { + if refURL := strings.TrimSpace(stringValue( + input["image"], + input["reference_image_url"], + input["image_url"], + )); refURL != "" { + urls = []string{refURL} + } + } + if len(urls) == 0 { + return nil, fmt.Errorf("reference image is required") + } + + refs := make([]imageEditReference, 0, len(urls)) + for _, rawURL := range urls { + ref, err := s.downloadReferenceImage(ctx, rawURL) + if err != nil { + return nil, err + } + refs = append(refs, ref) + } + return refs, nil +} + +func extractStringValues(value any) []string { + switch typed := value.(type) { + case []string: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if text := strings.TrimSpace(item); text != "" { + out = append(out, text) + } + } + return out + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if text := strings.TrimSpace(stringValue(item)); text != "" { + out = append(out, text) + } + } + return out + default: + return nil + } +} + func (s *Server) waitForVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() diff --git a/internal/server/video_test.go b/internal/server/video_test.go index 1ebae01..2d3262e 100644 --- a/internal/server/video_test.go +++ b/internal/server/video_test.go @@ -138,6 +138,110 @@ func TestSubmitImageToVideoTaskUsesURLImagesForViduModels(t *testing.T) { } } +func TestSubmitMiniMaxVideoTaskUsesVideoGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"task_minimax_video_123","status":"submitted"}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitMiniMaxVideoTask(context.Background(), "sk-test", "I2V-01", map[string]any{ + "prompt": "animate this portrait gently", + "size": "720x1280", + }, []imageEditReference{{ + URL: "https://example.com/reference.png", + }}) + if err != nil { + t.Fatalf("submitMiniMaxVideoTask: %v", err) + } + if taskID != "task_minimax_video_123" { + t.Fatalf("unexpected task id: %q", taskID) + } + if gotPath != "/v1/video/generations" { + t.Fatalf("expected /v1/video/generations, got %q", gotPath) + } + if gotBody["model"] != "I2V-01" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + images, ok := gotBody["images"].([]any) + if !ok || len(images) != 1 || images[0] != "https://example.com/reference.png" { + t.Fatalf("unexpected images payload: %#v", gotBody["images"]) + } + if gotBody["size"] != "720P" { + t.Fatalf("expected 720P resolution, got %#v", gotBody["size"]) + } +} + +func TestSubmitMiniMaxVideoTaskKeepsS2VImagesArray(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"task_s2v_123","status":"submitted"}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitMiniMaxVideoTask(context.Background(), "sk-test", "S2V-01", map[string]any{ + "prompt": "animate the subject gently", + }, []imageEditReference{{ + URL: "https://example.com/reference.png", + }}) + if err != nil { + t.Fatalf("submitMiniMaxVideoTask: %v", err) + } + if taskID != "task_s2v_123" { + t.Fatalf("unexpected task id: %q", taskID) + } + images, ok := gotBody["images"].([]any) + if !ok || len(images) != 1 || images[0] != "https://example.com/reference.png" { + t.Fatalf("unexpected images payload for S2V: %#v", gotBody["images"]) + } +} + +func TestResolveVideoReferencesSupportsImagesArray(t *testing.T) { + cfg := Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + } + server, err := NewWithConfig(cfg) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + imageBytes := tinyPNG(t) + refSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + })) + defer refSrv.Close() + + refs, err := server.resolveVideoReferences(context.Background(), &job{ + UserID: "user_test", + UpstreamKey: "sk-upstream", + }, map[string]any{ + "images": []any{refSrv.URL + "/a.png", refSrv.URL + "/b.png"}, + }) + if err != nil { + t.Fatalf("resolveVideoReferences: %v", err) + } + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %#v", refs) + } +} + func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path {