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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user