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:
+317
-44
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user