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
+317 -44
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 != "" {
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 != "" {