From 97f29ed156549f7a7f7acb61911a89fd303d9a72 Mon Sep 17 00:00:00 2001 From: wangbo Date: Mon, 8 Jun 2026 09:29:45 +0800 Subject: [PATCH] Restore standard MiniMax resolutions and client mapping --- apps/api/internal/clients/media_clients.go | 139 ++++++++++++++++++ apps/api/internal/clients/provider_task.go | 46 +++++- .../internal/clients/provider_task_test.go | 102 +++++++++++++ .../internal/runner/candidate_filter_test.go | 32 ++++ apps/api/internal/runner/pricing.go | 13 ++ apps/api/internal/runner/pricing_test.go | 13 +- .../0048_minimax_hailuo23_capabilities.sql | 123 ++++++++++++++++ ..._minimax_hailuo23_standard_resolutions.sql | 123 ++++++++++++++++ 8 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 apps/api/migrations/0048_minimax_hailuo23_capabilities.sql create mode 100644 apps/api/migrations/0049_minimax_hailuo23_standard_resolutions.sql diff --git a/apps/api/internal/clients/media_clients.go b/apps/api/internal/clients/media_clients.go index 2018d4a..4286822 100644 --- a/apps/api/internal/clients/media_clients.go +++ b/apps/api/internal/clients/media_clients.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/hex" "net/http" + "net/url" "strings" "time" ) @@ -157,9 +158,147 @@ func minimaxSpec() providerTaskSpec { StatusPaths: []string{"status"}, SuccessStatuses: []string{"success"}, FailureStatuses: []string{"failed", "expired"}, + DefaultSubmitBody: func(request Request, body map[string]any) map[string]any { + return minimaxVideoPayload(request, body) + }, + ResolveSuccess: minimaxResolveVideoFile, } } +func minimaxVideoPayload(request Request, body map[string]any) map[string]any { + model := upstreamModelName(request.Candidate) + body["model"] = model + if strings.TrimSpace(stringFromAny(body["prompt"])) == "" { + body["prompt"] = mediaPromptText(body) + } + firstFrame, lastFrame := minimaxFrameImages(body) + if firstFrame != "" && strings.TrimSpace(stringFromAny(body["first_frame_image"])) == "" { + body["first_frame_image"] = firstFrame + } + if lastFrame != "" && strings.TrimSpace(stringFromAny(body["last_frame_image"])) == "" { + body["last_frame_image"] = lastFrame + } + if resolution := minimaxVideoResolution(model, stringFromAny(body["resolution"])); resolution != "" { + body["resolution"] = resolution + } + for _, key := range []string{ + "content", "input", "_paramWarnings", + "first_frame", "firstFrame", "last_frame", "lastFrame", + "image", "images", "image_url", "imageUrl", "image_urls", "imageUrls", + "reference_image", "referenceImage", "duration_seconds", "durationSeconds", + } { + delete(body, key) + } + return body +} + +func minimaxFrameImages(body map[string]any) (string, string) { + firstFrame := firstNonEmptyStringValue(body, "first_frame_image", "firstFrameImage", "first_frame", "firstFrame") + lastFrame := firstNonEmptyStringValue(body, "last_frame_image", "lastFrameImage", "last_frame", "lastFrame") + imageURLs := firstNonEmptyStringListFromAny(body["image"], body["images"], body["image_url"], body["imageUrl"], body["image_urls"], body["imageUrls"]) + if firstFrame == "" && len(imageURLs) > 0 { + firstFrame = imageURLs[0] + } + for _, item := range contentItems(body["content"]) { + if strings.TrimSpace(stringFromAny(item["type"])) != "image_url" { + continue + } + url := minimaxNestedURL(item, "image_url") + if url == "" { + continue + } + switch strings.TrimSpace(stringFromAny(item["role"])) { + case "first_frame": + if firstFrame == "" { + firstFrame = url + } + case "last_frame": + if lastFrame == "" { + lastFrame = url + } + default: + if firstFrame == "" { + firstFrame = url + } + } + } + return firstFrame, lastFrame +} + +func minimaxNestedURL(item map[string]any, key string) string { + if url := strings.TrimSpace(stringFromAny(item[key])); url != "" { + return url + } + nested := mapFromAny(item[key]) + return strings.TrimSpace(stringFromAny(nested["url"])) +} + +func minimaxVideoResolution(model string, resolution string) string { + resolution = strings.TrimSpace(resolution) + if resolution == "" { + return "" + } + normalized := strings.ToLower(strings.ReplaceAll(resolution, " ", "")) + isHailuo23 := strings.Contains(strings.ToLower(model), "hailuo-2.3") + if isHailuo23 { + switch normalized { + case "720", "720p", "768", "768p": + return "768P" + case "1080", "1080p": + return "1080P" + } + } + switch normalized { + case "512", "512p": + return "512P" + case "720", "720p": + return "720P" + case "768", "768p": + return "768P" + case "1080", "1080p": + return "1080P" + default: + if strings.HasSuffix(normalized, "p") { + return strings.TrimSuffix(normalized, "p") + "P" + } + return resolution + } +} + +func minimaxResolveVideoFile(ctx context.Context, client *http.Client, request Request, result map[string]any) (map[string]any, string, error) { + if len(providerTaskData(request, result)) > 0 { + return result, "", nil + } + fileID := strings.TrimSpace(stringFromPathValue(valueAtPath(result, "file_id"))) + if fileID == "" { + return result, "", nil + } + fetched, requestID, err := providerGetJSON(ctx, client, providerURL(request.Candidate.BaseURL, "/files/retrieve?file_id="+url.QueryEscape(fileID)), request.Candidate.Credentials, "bearer") + if err != nil { + return nil, requestID, err + } + if isProviderTaskFailure(minimaxSpec(), fetched) { + return nil, requestID, providerTaskFailure(minimaxSpec(), fetched, firstNonEmptyString(requestID, requestIDFromResult(fetched)), time.Now()) + } + downloadURL := firstNonEmptyString( + valueAtPath(fetched, "file.download_url"), + valueAtPath(fetched, "file.downloadUrl"), + valueAtPath(fetched, "download_url"), + valueAtPath(fetched, "downloadUrl"), + valueAtPath(fetched, "url"), + ) + if downloadURL == "" { + return nil, requestID, &ClientError{Code: "invalid_response", Message: "minimax video download url is missing", RequestID: requestID, Retryable: false} + } + out := cloneMapAny(result) + out["video_url"] = downloadURL + if file, ok := fetched["file"].(map[string]any); ok { + out["file"] = cloneMapAny(file) + } + out["file_retrieve"] = cloneMapAny(fetched) + return out, firstNonEmptyString(requestID, requestIDFromResult(fetched)), nil +} + func (c MinimaxClient) runSpeech(ctx context.Context, request Request) (Response, error) { startedAt := time.Now() payload := minimaxSpeechPayload(request) diff --git a/apps/api/internal/clients/provider_task.go b/apps/api/internal/clients/provider_task.go index 24a0a4f..68602d9 100644 --- a/apps/api/internal/clients/provider_task.go +++ b/apps/api/internal/clients/provider_task.go @@ -21,6 +21,7 @@ type providerTaskSpec struct { FailureStatuses []string ProcessStatuses []string DefaultSubmitBody func(Request, map[string]any) map[string]any + ResolveSuccess func(context.Context, *http.Client, Request, map[string]any) (map[string]any, string, error) } type providerTaskClient struct { @@ -54,6 +55,15 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response, return Response{}, providerTaskFailure(c.Spec, result, requestID, startedAt) } if isProviderTaskSuccess(c.Spec, result) && hasProviderTaskResult(result) { + resolved, resolvedRequestID, err := c.resolveSuccess(ctx, request, result) + if err != nil { + return Response{}, annotateResponseError(err, firstNonEmptyString(resolvedRequestID, requestID), startedAt, time.Now()) + } + result = resolved + requestID = firstNonEmptyString(resolvedRequestID, requestID) + if isProviderTaskFailure(c.Spec, result) { + return Response{}, providerTaskFailure(c.Spec, result, requestID, startedAt) + } return Response{ Result: normalizeProviderTaskResult(request, c.Spec, result, ""), RequestID: requestID, @@ -96,6 +106,15 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response, lastResult = result requestID = firstNonEmptyString(pollRequestID, requestID, requestIDFromResult(result), upstreamTaskID) if isProviderTaskSuccess(c.Spec, result) { + resolved, resolvedRequestID, err := c.resolveSuccess(ctx, request, result) + if err != nil { + return Response{}, annotateResponseError(err, firstNonEmptyString(resolvedRequestID, requestID, upstreamTaskID), pollStarted, time.Now()) + } + result = resolved + requestID = firstNonEmptyString(resolvedRequestID, requestID) + if isProviderTaskFailure(c.Spec, result) { + return Response{}, providerTaskFailure(c.Spec, result, requestID, startedAt) + } finishedAt := time.Now() return Response{ Result: normalizeProviderTaskResult(request, c.Spec, result, upstreamTaskID), @@ -119,6 +138,13 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response, } } +func (c providerTaskClient) resolveSuccess(ctx context.Context, request Request, result map[string]any) (map[string]any, string, error) { + if c.Spec.ResolveSuccess == nil { + return result, "", nil + } + return c.Spec.ResolveSuccess(ctx, httpClient(request.HTTPClient, c.HTTPClient), request, result) +} + func providerTaskKindSupported(kind string) bool { switch kind { case "images.generations", "images.edits", "videos.generations", "song.generations", "music.generations", "speech.generations": @@ -282,9 +308,16 @@ func isProviderTaskSuccess(spec providerTaskSpec, result map[string]any) bool { } func isProviderTaskFailure(spec providerTaskSpec, result map[string]any) bool { + if statusCode := providerTaskBusinessStatusCode(result); statusCode != "" && statusCode != "0" { + return true + } return containsStatus(append([]string{"failed", "failure", "error", "cancelled", "canceled", "fail", "expired", "task not found"}, spec.FailureStatuses...), providerTaskStatus(spec, result)) } +func providerTaskBusinessStatusCode(result map[string]any) string { + return strings.TrimSpace(stringFromPathValue(valueAtPath(result, "base_resp.status_code"))) +} + func containsStatus(values []string, status string) bool { status = strings.ToLower(strings.TrimSpace(status)) for _, value := range values { @@ -397,9 +430,9 @@ func appendURLValues(out *[]any, value any) { } func providerTaskFailure(spec providerTaskSpec, result map[string]any, requestID string, startedAt time.Time) error { - message := firstNonEmptyString(valueAtPath(result, "message"), valueAtPath(result, "error.message"), valueAtPath(result, "error"), valueAtPath(result, "Response.ErrorMessage"), valueAtPath(result, "comment"), spec.Name+" task failed") + message := firstNonEmptyString(valueAtPath(result, "message"), valueAtPath(result, "error.message"), valueAtPath(result, "error"), valueAtPath(result, "base_resp.status_msg"), valueAtPath(result, "Response.ErrorMessage"), valueAtPath(result, "comment"), spec.Name+" task failed") return &ClientError{ - Code: firstNonEmptyString(valueAtPath(result, "code"), valueAtPath(result, "error_code"), valueAtPath(result, "Response.ErrorCode"), "provider_failed"), + Code: firstNonEmptyPathString(valueAtPath(result, "code"), valueAtPath(result, "error_code"), valueAtPath(result, "base_resp.status_code"), valueAtPath(result, "Response.ErrorCode"), "provider_failed"), Message: message, RequestID: requestID, ResponseStartedAt: startedAt, @@ -409,6 +442,15 @@ func providerTaskFailure(spec providerTaskSpec, result map[string]any, requestID } } +func firstNonEmptyPathString(values ...any) string { + for _, value := range values { + if text := stringFromPathValue(value); text != "" { + return text + } + } + return "" +} + func providerPollInterval(request Request) time.Duration { return durationFromConfig(request.Candidate.PlatformConfig, 2*time.Second, "pollIntervalMs", "poll_interval_ms") } diff --git a/apps/api/internal/clients/provider_task_test.go b/apps/api/internal/clients/provider_task_test.go index 7c6883e..dd8217f 100644 --- a/apps/api/internal/clients/provider_task_test.go +++ b/apps/api/internal/clients/provider_task_test.go @@ -228,6 +228,75 @@ func TestProviderTaskClientsSubmitAndPoll(t *testing.T) { } } +func TestMinimaxClientNormalizesHailuo23PayloadAndRetrievesFile(t *testing.T) { + var submitted map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Fatalf("unexpected auth header: %q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-request-id", "req-minimax") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/video_generation": + if err := json.NewDecoder(r.Body).Decode(&submitted); err != nil { + t.Fatalf("decode minimax submit request: %v", err) + } + _, _ = w.Write([]byte(`{"task_id":"mm-task","base_resp":{"status_code":0,"status_msg":"success"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/query/video_generation" && r.URL.Query().Get("task_id") == "mm-task": + _, _ = w.Write([]byte(`{"task_id":"mm-task","status":"Success","file_id":"file-1","base_resp":{"status_code":0,"status_msg":"success"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/files/retrieve" && r.URL.Query().Get("file_id") == "file-1": + _, _ = w.Write([]byte(`{"file":{"download_url":"https://cdn.example/minimax-file.mp4"},"base_resp":{"status_code":0,"status_msg":"success"}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + response, err := (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "videos.generations", + ModelType: "image_to_video", + Model: "海螺2.3", + Body: map[string]any{ + "resolution": "720p", + "duration": 6, + "content": []any{ + map[string]any{"type": "text", "text": "camera moves in"}, + map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}}, + }, + }, + Candidate: store.RuntimeModelCandidate{ + Provider: "minimax", + SpecType: "minimax", + BaseURL: server.URL, + Credentials: map[string]any{"apiKey": "test-key"}, + PlatformConfig: map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 1000}, + ModelName: "海螺2.3", + ProviderModelName: "MiniMax-Hailuo-2.3", + ModelType: "image_to_video", + }, + }) + if err != nil { + t.Fatalf("run minimax client: %v", err) + } + if submitted["model"] != "MiniMax-Hailuo-2.3" || submitted["prompt"] != "camera moves in" || submitted["first_frame_image"] != "https://example.com/first.png" { + t.Fatalf("unexpected minimax submit payload: %+v", submitted) + } + if submitted["resolution"] != "768P" { + t.Fatalf("hailuo 2.3 720p should be submitted as 768P, got %+v", submitted) + } + if _, ok := submitted["content"]; ok { + t.Fatalf("minimax native request should not include generic content: %+v", submitted) + } + data, _ := response.Result["data"].([]any) + if len(data) != 1 { + t.Fatalf("unexpected minimax response data: %+v", response.Result) + } + first, _ := data[0].(map[string]any) + if first["url"] != "https://cdn.example/minimax-file.mp4" { + t.Fatalf("unexpected minimax video url: %+v", response.Result) + } +} + func TestSunoClientSubmitsAndPollsAudioGeneration(t *testing.T) { var submitted map[string]any var submittedRemoteTaskID string @@ -303,6 +372,39 @@ func TestSunoClientSubmitsAndPollsAudioGeneration(t *testing.T) { } func TestProviderTaskClientFailureAndRetryableErrors(t *testing.T) { + t.Run("submit business failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-request-id", "req-minimax-invalid") + if r.Method != http.MethodPost || r.URL.Path != "/video_generation" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + _, _ = w.Write([]byte(`{"base_resp":{"status_code":1008,"status_msg":"invalid resolution"}}`)) + })) + defer server.Close() + + _, err := (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "videos.generations", + ModelType: "video_generate", + Model: "海螺2.3", + Body: map[string]any{"model": "海螺2.3", "prompt": "hello"}, + Candidate: store.RuntimeModelCandidate{ + Provider: "minimax", + SpecType: "minimax", + BaseURL: server.URL, + Credentials: map[string]any{"apiKey": "test-key"}, + PlatformConfig: map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 1000}, + ModelName: "海螺2.3", + ProviderModelName: "MiniMax-Hailuo-2.3", + ModelType: "video_generate", + }, + }) + var clientErr *ClientError + if !errors.As(err, &clientErr) || clientErr.Code != "1008" || clientErr.Message != "invalid resolution" || clientErr.RequestID != "req-minimax-invalid" { + t.Fatalf("expected minimax business failure, got %#v", err) + } + }) + t.Run("poll failure", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/apps/api/internal/runner/candidate_filter_test.go b/apps/api/internal/runner/candidate_filter_test.go index 313a594..c213eed 100644 --- a/apps/api/internal/runner/candidate_filter_test.go +++ b/apps/api/internal/runner/candidate_filter_test.go @@ -28,6 +28,38 @@ func TestFilterRuntimeCandidatesByRequestResolutionKeepsSupportedCandidate(t *te } } +func TestFilterRuntimeCandidatesUsesStandardVideoResolutionValues(t *testing.T) { + candidates := []store.RuntimeModelCandidate{ + { + PlatformID: "minimax", + PlatformKey: "minimax", + PlatformName: "MiniMax", + PlatformModelID: "hailuo23", + ModelName: "MiniMax-Hailuo-2.3", + ModelAlias: "海螺2.3", + ModelType: "video_generate", + Capabilities: map[string]any{ + "video_generate": map[string]any{ + "output_resolutions": []any{"720p", "1080p"}, + }, + }, + }, + } + + filtered, summary, err := filterRuntimeCandidatesByRequest("videos.generations", "海螺2.3", "video_generate", map[string]any{ + "resolution": "720p", + }, candidates) + if err != nil { + t.Fatalf("standard 720p request should match MiniMax catalog capability before client-side upstream mapping: %v", err) + } + if len(filtered) != 1 || filtered[0].PlatformModelID != "hailuo23" { + t.Fatalf("expected MiniMax candidate to remain, got %+v", filtered) + } + if summary["supportedCandidateCount"] != 1 || summary["filteredCandidateCount"] != 0 { + t.Fatalf("unexpected filter summary: %+v", summary) + } +} + func TestFilterRuntimeCandidatesByScopedVideoResolution(t *testing.T) { candidates := []store.RuntimeModelCandidate{ { diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index f80fe70..4c97263 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -323,6 +323,19 @@ func weightValueAliases(key string, name string) []string { return nil } switch key { + case "resolutionWeights": + aliases := []string{name} + lower := strings.ToLower(name) + if lower != name { + aliases = append(aliases, lower) + } + if strings.HasSuffix(lower, "p") { + aliases = append(aliases, strings.TrimSuffix(lower, "p")+"P") + } + if lower == "768p" { + aliases = append(aliases, "720p") + } + return aliases case "audioWeights": return []string{name, "audio-" + name} case "referenceVideoWeights": diff --git a/apps/api/internal/runner/pricing_test.go b/apps/api/internal/runner/pricing_test.go index 090372f..bb1b528 100644 --- a/apps/api/internal/runner/pricing_test.go +++ b/apps/api/internal/runner/pricing_test.go @@ -46,7 +46,7 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T) "video": map[string]any{ "basePrice": 100, "dynamicWeight": map[string]any{ - "resolutionWeights": map[string]any{"1080p": 1.5}, + "resolutionWeights": map[string]any{"720p": 1.25, "1080p": 1.5}, "audioWeights": map[string]any{"true": 2}, "referenceVideoWeights": map[string]any{"true": 1.5}, "voiceSpecifiedWeights": map[string]any{"true": 1.2}, @@ -59,7 +59,7 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T) items := service.billings(context.Background(), nil, "videos.generations", map[string]any{ "audio": true, "duration": 12, - "resolution": "1080p", + "resolution": "1080P", "voice_id": "voice-a", "content": []any{ map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/reference.mp4"}}, @@ -82,6 +82,15 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T) if got, want := line["audioSource"], "preprocessed_request"; got != want { t.Fatalf("video audio source = %v, want %v", got, want) } + + items = service.billings(context.Background(), nil, "videos.generations", map[string]any{ + "duration": 5, + "resolution": "768P", + }, candidate, clients.Response{}, true) + line = firstBillingLine(t, items) + if got, want := floatFromAny(line["amount"]), 125.0; got != want { + t.Fatalf("768P should use 720p billing weight, got %v, want %v", got, want) + } } func TestMusicBillingUsesSongResourceAndOutputCount(t *testing.T) { diff --git a/apps/api/migrations/0048_minimax_hailuo23_capabilities.sql b/apps/api/migrations/0048_minimax_hailuo23_capabilities.sql new file mode 100644 index 0000000..97866d3 --- /dev/null +++ b/apps/api/migrations/0048_minimax_hailuo23_capabilities.sql @@ -0,0 +1,123 @@ +WITH minimax_hailuo_defs(provider_model_name, capabilities) AS ( + VALUES + ( + 'MiniMax-Hailuo-2.3', + '{ + "video_generate": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "aspect_ratio_allowed": [] + }, + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["video_generate", "image_to_video"] + }'::jsonb + ), + ( + 'MiniMax-Hailuo-2.3-Fast', + '{ + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["image_to_video"] + }'::jsonb + ) +) +UPDATE base_model_catalog model +SET capabilities = defs.capabilities, + metadata = CASE + WHEN jsonb_typeof(model.metadata->'rawModel') = 'object' THEN jsonb_set(model.metadata, '{rawModel,capabilities}', defs.capabilities, true) + ELSE model.metadata + END, + default_snapshot = CASE + WHEN model.default_snapshot IS NULL THEN NULL + ELSE jsonb_set( + CASE + WHEN jsonb_typeof(model.default_snapshot->'metadata'->'rawModel') = 'object' THEN jsonb_set(model.default_snapshot, '{metadata,rawModel,capabilities}', defs.capabilities, true) + ELSE model.default_snapshot + END, + '{capabilities}', + defs.capabilities, + true + ) + END, + updated_at = now() +FROM minimax_hailuo_defs defs +WHERE model.provider_key = 'minimax' + AND model.provider_model_name = defs.provider_model_name + AND COALESCE(model.metadata->>'source', '') = 'server-main.integration-platform'; + +WITH minimax_hailuo_defs(provider_model_name, capabilities) AS ( + VALUES + ( + 'MiniMax-Hailuo-2.3', + '{ + "video_generate": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "aspect_ratio_allowed": [] + }, + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["video_generate", "image_to_video"] + }'::jsonb + ), + ( + 'MiniMax-Hailuo-2.3-Fast', + '{ + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["image_to_video"] + }'::jsonb + ) +) +UPDATE platform_models model +SET capabilities = defs.capabilities, + updated_at = now() +FROM minimax_hailuo_defs defs, integration_platforms platform, base_model_catalog base_model +WHERE platform.provider = 'minimax' + AND platform.id = model.platform_id + AND base_model.id = model.base_model_id + AND COALESCE(model.provider_model_name, model.model_name) = defs.provider_model_name + AND COALESCE(base_model.metadata->>'source', '') = 'server-main.integration-platform'; diff --git a/apps/api/migrations/0049_minimax_hailuo23_standard_resolutions.sql b/apps/api/migrations/0049_minimax_hailuo23_standard_resolutions.sql new file mode 100644 index 0000000..97866d3 --- /dev/null +++ b/apps/api/migrations/0049_minimax_hailuo23_standard_resolutions.sql @@ -0,0 +1,123 @@ +WITH minimax_hailuo_defs(provider_model_name, capabilities) AS ( + VALUES + ( + 'MiniMax-Hailuo-2.3', + '{ + "video_generate": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "aspect_ratio_allowed": [] + }, + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["video_generate", "image_to_video"] + }'::jsonb + ), + ( + 'MiniMax-Hailuo-2.3-Fast', + '{ + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["image_to_video"] + }'::jsonb + ) +) +UPDATE base_model_catalog model +SET capabilities = defs.capabilities, + metadata = CASE + WHEN jsonb_typeof(model.metadata->'rawModel') = 'object' THEN jsonb_set(model.metadata, '{rawModel,capabilities}', defs.capabilities, true) + ELSE model.metadata + END, + default_snapshot = CASE + WHEN model.default_snapshot IS NULL THEN NULL + ELSE jsonb_set( + CASE + WHEN jsonb_typeof(model.default_snapshot->'metadata'->'rawModel') = 'object' THEN jsonb_set(model.default_snapshot, '{metadata,rawModel,capabilities}', defs.capabilities, true) + ELSE model.default_snapshot + END, + '{capabilities}', + defs.capabilities, + true + ) + END, + updated_at = now() +FROM minimax_hailuo_defs defs +WHERE model.provider_key = 'minimax' + AND model.provider_model_name = defs.provider_model_name + AND COALESCE(model.metadata->>'source', '') = 'server-main.integration-platform'; + +WITH minimax_hailuo_defs(provider_model_name, capabilities) AS ( + VALUES + ( + 'MiniMax-Hailuo-2.3', + '{ + "video_generate": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "aspect_ratio_allowed": [] + }, + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["video_generate", "image_to_video"] + }'::jsonb + ), + ( + 'MiniMax-Hailuo-2.3-Fast', + '{ + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": {"720p": [6, 10], "1080p": [6, 6]}, + "duration_options": {"720p": [6, 10], "1080p": [6]}, + "input_first_frame": true, + "input_first_last_frame": false, + "input_last_frame": false, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "aspect_ratio_allowed": [], + "support_video_effect_template": false + }, + "originalTypes": ["image_to_video"] + }'::jsonb + ) +) +UPDATE platform_models model +SET capabilities = defs.capabilities, + updated_at = now() +FROM minimax_hailuo_defs defs, integration_platforms platform, base_model_catalog base_model +WHERE platform.provider = 'minimax' + AND platform.id = model.platform_id + AND base_model.id = model.base_model_id + AND COALESCE(model.provider_model_name, model.model_name) = defs.provider_model_name + AND COALESCE(base_model.metadata->>'source', '') = 'server-main.integration-platform';