Restore MiniMax media routing parity across image and video flows

The server now keeps portable image ratio normalization, enforces documented Seedream size presets, routes MiniMax img2img through generations-style payloads, and submits Hailuo video variants through the video generations API with proper reference-image handling.

Constraint: The deployed test chain relies on popiartServer as the lowest-risk place to adapt provider-specific routing without broad newapi changes
Rejected: Expand newapi relay modes for every MiniMax image/video branch | larger blast radius than needed for the verified server-managed flows
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep MiniMax-specific media routing in popiartServer unless upstream newapi adapters are upgraded to the same contract
Tested: go test ./internal/server -run TestInferRouteKeyForModelRecognizesViduAsVideo|TestGenerate|TestResolve|TestSubmitMiniMaxVideoTask|TestExecuteImageToImageJobUsesGenerationsPathForMiniMax|TestExecuteImageToVideoJob|TestResolveVideoReferencesSupportsImagesArray
Not-tested: Full PopiNewAPI unit suite (blocked earlier by external module checksum drift in local checkout)
This commit is contained in:
wtgoku
2026-04-15 11:57:05 +08:00
parent a73b85c45f
commit 6ff9ad6997
7 changed files with 982 additions and 67 deletions
+197
View File
@@ -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
}
+114
View File
@@ -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)
}
}
+312 -39
View File
@@ -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 != "" {
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
for _, value := range []string{
stringValue(input["resolution"]),
stringValue(input["size"]),
} {
if imageSize := geminiImageSizeFromValue(value); imageSize != "" {
return imageSize
}
}
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"]))),
func resolveSeedreamImageSize(modelID string, input map[string]any) (string, error) {
for _, raw := range []string{
stringValue(input["resolution"]),
stringValue(input["size"]),
} {
if _, ok := supported[value]; ok {
return value
value := normalizeImageSizeToken(raw)
if value == "" {
continue
}
if supportedSeedreamSize(modelID, value) {
return value, nil
}
return "2K"
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 != "" {
+125 -8
View File
@@ -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"])
}
}
+5
View File
@@ -27,6 +27,11 @@ func TestInferRouteKeyForModelRecognizesViduAsVideo(t *testing.T) {
"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",
}
+108 -3
View File
@@ -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,7 +1232,12 @@ func (s *Server) executeImageToVideoJob(record *job) {
submitCtx, cancelSubmit := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancelSubmit()
ref, err := s.resolveImageToImageReference(submitCtx, record, input)
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,
@@ -1234,8 +1247,26 @@ func (s *Server) executeImageToVideoJob(record *job) {
}
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
}
}
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()
+104
View File
@@ -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 {