You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1837 lines
51 KiB
1837 lines
51 KiB
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"log"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
NewAPIBaseURL string
|
|
NewAPIToken string
|
|
DefaultImageModel string
|
|
DefaultVideoModel string
|
|
PublicBaseURL string
|
|
DataDir string
|
|
SQLitePath string
|
|
SessionSecret string
|
|
SkillhubDir string
|
|
}
|
|
|
|
func ConfigFromEnv() Config {
|
|
cfg := Config{
|
|
NewAPIBaseURL: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_BASE_URL")),
|
|
NewAPIToken: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_TOKEN")),
|
|
DefaultImageModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_IMAGE_MODEL")),
|
|
DefaultVideoModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_VIDEO_MODEL")),
|
|
PublicBaseURL: strings.TrimSpace(os.Getenv("POPIART_PUBLIC_BASE_URL")),
|
|
DataDir: strings.TrimSpace(os.Getenv("POPIART_DATA_DIR")),
|
|
SQLitePath: strings.TrimSpace(os.Getenv("POPIART_SQLITE_PATH")),
|
|
SessionSecret: strings.TrimSpace(os.Getenv("POPIART_SESSION_SECRET")),
|
|
SkillhubDir: strings.TrimSpace(os.Getenv("POPIART_SKILLHUB_DIR")),
|
|
}
|
|
return normalizeConfig(cfg)
|
|
}
|
|
|
|
func normalizeConfig(cfg Config) Config {
|
|
if cfg.NewAPIBaseURL == "" {
|
|
cfg.NewAPIBaseURL = "http://127.0.0.1:3000"
|
|
}
|
|
if cfg.DefaultImageModel == "" {
|
|
cfg.DefaultImageModel = "gemini-3.1-flash-image-preview"
|
|
}
|
|
if cfg.DefaultVideoModel == "" {
|
|
cfg.DefaultVideoModel = "viduq2"
|
|
}
|
|
if cfg.DataDir == "" {
|
|
if strings.TrimSpace(cfg.SQLitePath) != "" {
|
|
cfg.DataDir = filepath.Dir(cfg.SQLitePath)
|
|
} else {
|
|
cfg.DataDir = "./data"
|
|
}
|
|
}
|
|
if cfg.SQLitePath == "" {
|
|
cfg.SQLitePath = filepath.Join(cfg.DataDir, "popiart.db")
|
|
}
|
|
if cfg.SkillhubDir == "" {
|
|
if info, err := os.Stat("/tmp/Popiart_skillhub"); err == nil && info.IsDir() {
|
|
cfg.SkillhubDir = "/tmp/Popiart_skillhub"
|
|
}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
type newAPIClient struct {
|
|
baseURL string
|
|
token string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type imageGenerationResponse struct {
|
|
Created int64 `json:"created"`
|
|
Data []struct {
|
|
B64JSON string `json:"b64_json"`
|
|
URL string `json:"url"`
|
|
} `json:"data"`
|
|
Usage map[string]any `json:"usage"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
type geminiImageGenerationResponse struct {
|
|
Candidates []struct {
|
|
Content struct {
|
|
Parts []struct {
|
|
Text string `json:"text,omitempty"`
|
|
InlineData *struct {
|
|
MIMEType string `json:"mimeType"`
|
|
Data string `json:"data"`
|
|
} `json:"inlineData,omitempty"`
|
|
} `json:"parts"`
|
|
} `json:"content"`
|
|
} `json:"candidates"`
|
|
UsageMetadata map[string]any `json:"usageMetadata"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
Status string `json:"status"`
|
|
Code int `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
type modelListResponse struct {
|
|
Data []upstreamModel `json:"data"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
type upstreamModel struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object,omitempty"`
|
|
Created int64 `json:"created,omitempty"`
|
|
OwnedBy string `json:"owned_by,omitempty"`
|
|
SupportedEndpointTypes []string `json:"supported_endpoint_types,omitempty"`
|
|
}
|
|
|
|
type imageEditReference struct {
|
|
Role string
|
|
Filename string
|
|
ContentType string
|
|
Content []byte
|
|
URL string
|
|
}
|
|
|
|
type openAIVideoResponse struct {
|
|
ID string `json:"id"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
Status string `json:"status"`
|
|
Progress int `json:"progress,omitempty"`
|
|
Seconds string `json:"seconds,omitempty"`
|
|
Size string `json:"size,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
Code string `json:"code"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
type taskEnvelopeResponse struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
type taskDTOResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
Status string `json:"status"`
|
|
Progress string `json:"progress"`
|
|
ResultURL string `json:"result_url,omitempty"`
|
|
FailReason string `json:"fail_reason,omitempty"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
}
|
|
|
|
type videoTaskResult struct {
|
|
TaskID string
|
|
Status string
|
|
Progress string
|
|
URL string
|
|
Format string
|
|
ErrorCode string
|
|
ErrorReason string
|
|
}
|
|
|
|
func newNewAPIClient(cfg Config) *newAPIClient {
|
|
return &newAPIClient{
|
|
baseURL: strings.TrimRight(cfg.NewAPIBaseURL, "/"),
|
|
token: cfg.NewAPIToken,
|
|
httpClient: &http.Client{
|
|
Timeout: 3 * time.Minute,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *newAPIClient) enabled() bool {
|
|
return c != nil && c.baseURL != ""
|
|
}
|
|
|
|
func (c *newAPIClient) authorizedToken(token string) string {
|
|
token = strings.TrimSpace(token)
|
|
if token != "" {
|
|
return token
|
|
}
|
|
return strings.TrimSpace(c.token)
|
|
}
|
|
|
|
func (c *newAPIClient) authorizedImageEditToken(token string) string {
|
|
token = strings.TrimSpace(c.authorizedToken(token))
|
|
if token == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(token, "sk-") {
|
|
return token
|
|
}
|
|
return "sk-" + token
|
|
}
|
|
|
|
func (c *newAPIClient) fetchModelCatalog(ctx context.Context, token string) ([]upstreamModel, error) {
|
|
if !c.enabled() {
|
|
return nil, errors.New("PopiNewAPI base URL is not configured")
|
|
}
|
|
token = c.authorizedToken(token)
|
|
if token == "" {
|
|
return nil, errors.New("PopiNewAPI token is not configured")
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/models", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var decoded modelListResponse
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
return nil, fmt.Errorf("decode PopiNewAPI models response: %w", err)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
if decoded.Error != nil && decoded.Error.Message != "" {
|
|
return nil, errors.New(decoded.Error.Message)
|
|
}
|
|
return nil, fmt.Errorf("PopiNewAPI returned status %d", resp.StatusCode)
|
|
}
|
|
if decoded.Error != nil && decoded.Error.Message != "" {
|
|
return nil, errors.New(decoded.Error.Message)
|
|
}
|
|
|
|
return decoded.Data, nil
|
|
}
|
|
|
|
func (c *newAPIClient) listModels(ctx context.Context, token string) ([]model, error) {
|
|
items, err := c.fetchModelCatalog(ctx, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
models := make([]model, 0, len(items))
|
|
seen := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
normalized, ok := normalizeUpstreamModel(item)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, exists := seen[normalized.ID]; exists {
|
|
continue
|
|
}
|
|
seen[normalized.ID] = struct{}{}
|
|
models = append(models, normalized)
|
|
}
|
|
|
|
sort.Slice(models, func(i, j int) bool {
|
|
if models[i].Type != models[j].Type {
|
|
return models[i].Type < models[j].Type
|
|
}
|
|
if models[i].Provider != models[j].Provider {
|
|
return models[i].Provider < models[j].Provider
|
|
}
|
|
return models[i].ID < models[j].ID
|
|
})
|
|
return models, nil
|
|
}
|
|
|
|
func (c *newAPIClient) listModelIDs(ctx context.Context, token string) ([]string, error) {
|
|
items, err := c.fetchModelCatalog(ctx, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
modelIDs := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.ID) != "" {
|
|
modelIDs = append(modelIDs, item.ID)
|
|
}
|
|
}
|
|
return modelIDs, nil
|
|
}
|
|
|
|
func (c *newAPIClient) verifyKey(ctx context.Context, token string) error {
|
|
items, err := c.listModelIDs(ctx, token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(items) == 0 {
|
|
return errors.New("PopiNewAPI key is valid but has no available models")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeUpstreamModel(item upstreamModel) (model, bool) {
|
|
modelID := strings.TrimSpace(item.ID)
|
|
if modelID == "" {
|
|
return model{}, false
|
|
}
|
|
|
|
modelType, capabilities := classifyUpstreamModel(item)
|
|
if modelType == "" {
|
|
return model{}, false
|
|
}
|
|
|
|
provider := strings.TrimSpace(item.OwnedBy)
|
|
if provider == "" || provider == "custom" {
|
|
provider = inferProviderFallback(modelID)
|
|
}
|
|
return model{
|
|
ID: modelID,
|
|
Name: modelID,
|
|
Type: modelType,
|
|
Provider: provider,
|
|
Capabilities: capabilities,
|
|
Pricing: map[string]any{
|
|
"unit": modelType,
|
|
"rate": "upstream",
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func inferProviderFallback(modelID string) string {
|
|
lowerID := strings.ToLower(strings.TrimSpace(modelID))
|
|
switch {
|
|
case strings.Contains(lowerID, "vidu"):
|
|
return "vidu"
|
|
default:
|
|
return "custom"
|
|
}
|
|
}
|
|
|
|
func classifyUpstreamModel(item upstreamModel) (string, []string) {
|
|
if modelType, capabilities := classifySupportedEndpointTypes(item.SupportedEndpointTypes); modelType != "" {
|
|
return modelType, capabilities
|
|
}
|
|
return classifyModelIDFallback(item.ID)
|
|
}
|
|
|
|
func classifySupportedEndpointTypes(endpointTypes []string) (string, []string) {
|
|
hasVideo := false
|
|
hasImage := false
|
|
for _, endpointType := range endpointTypes {
|
|
switch strings.ToLower(strings.TrimSpace(endpointType)) {
|
|
case "openai-video":
|
|
hasVideo = true
|
|
case "image-generation":
|
|
hasImage = true
|
|
}
|
|
}
|
|
switch {
|
|
case hasVideo:
|
|
return "video", []string{"text2video", "image2video"}
|
|
case hasImage:
|
|
return "image", []string{"text2image", "img2img"}
|
|
default:
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
func classifyModelIDFallback(modelID string) (string, []string) {
|
|
lowerID := strings.ToLower(strings.TrimSpace(modelID))
|
|
switch {
|
|
case strings.Contains(lowerID, "sora"),
|
|
strings.Contains(lowerID, "veo"),
|
|
strings.Contains(lowerID, "vidu"),
|
|
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"}
|
|
case strings.Contains(lowerID, "image"),
|
|
strings.Contains(lowerID, "seedream"),
|
|
strings.Contains(lowerID, "flux"),
|
|
strings.Contains(lowerID, "recraft"),
|
|
strings.Contains(lowerID, "midjourney"),
|
|
strings.Contains(lowerID, "stable-diffusion"),
|
|
strings.Contains(lowerID, "sdxl"),
|
|
strings.Contains(lowerID, "imagen"):
|
|
return "image", []string{"text2image", "img2img"}
|
|
default:
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
func (c *newAPIClient) generateImageRefs(ctx context.Context, token, modelID string, input map[string]any) ([]resultRef, map[string]any, error) {
|
|
if !c.enabled() {
|
|
return nil, nil, errors.New("PopiNewAPI base URL is not configured")
|
|
}
|
|
if useGeminiImageGenerateContent(modelID) {
|
|
return c.generateGeminiImageRefs(ctx, token, modelID, input, nil)
|
|
}
|
|
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")
|
|
}
|
|
prompt, _ := input["prompt"].(string)
|
|
prompt = strings.TrimSpace(prompt)
|
|
if prompt == "" {
|
|
return nil, nil, errors.New("prompt is required")
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"model": modelID,
|
|
"prompt": prompt,
|
|
"n": 1,
|
|
}
|
|
if value, ok := input["size"]; ok {
|
|
payload["size"] = value
|
|
}
|
|
if value, ok := input["quality"]; ok {
|
|
payload["quality"] = value
|
|
}
|
|
if value, ok := input["background"]; ok {
|
|
payload["background"] = value
|
|
}
|
|
if value, ok := input["response_format"]; ok {
|
|
payload["response_format"] = 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) generateEditedImageRefs(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")
|
|
}
|
|
if len(refs) == 0 {
|
|
return nil, nil, errors.New("reference image content is required")
|
|
}
|
|
if useGeminiImageGenerateContent(modelID) {
|
|
return c.generateGeminiImageRefs(ctx, token, modelID, input, refs)
|
|
}
|
|
if useSeedreamImageGenerations(modelID) {
|
|
return c.generateSeedreamImageRefs(ctx, token, modelID, input, refs)
|
|
}
|
|
if useMiniMaxImageGenerations(modelID) {
|
|
return c.generateMiniMaxImageRefs(ctx, token, modelID, input, refs)
|
|
}
|
|
token = c.authorizedImageEditToken(token)
|
|
if token == "" {
|
|
return nil, nil, errors.New("PopiNewAPI token is not configured")
|
|
}
|
|
prompt, _ := input["prompt"].(string)
|
|
prompt = strings.TrimSpace(prompt)
|
|
if prompt == "" {
|
|
return nil, nil, errors.New("prompt is required")
|
|
}
|
|
ref := refs[0]
|
|
if len(ref.Content) == 0 {
|
|
return nil, nil, errors.New("reference image content is required")
|
|
}
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("model", modelID); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := writer.WriteField("prompt", prompt); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if value, ok := input["size"]; ok {
|
|
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" {
|
|
if err := writer.WriteField("size", text); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
filename := strings.TrimSpace(ref.Filename)
|
|
if filename == "" {
|
|
filename = "reference" + extensionFromContentType(ref.ContentType)
|
|
}
|
|
contentType := strings.TrimSpace(ref.ContentType)
|
|
if contentType == "" {
|
|
contentType = http.DetectContentType(ref.Content)
|
|
}
|
|
header := textproto.MIMEHeader{}
|
|
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image"; filename="%s"`, filename))
|
|
header.Set("Content-Type", contentType)
|
|
part, err := writer.CreatePart(header)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if _, err := part.Write(ref.Content); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/images/edits", &body)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
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) generateSeedreamImageRefs(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"),
|
|
}
|
|
size, err := resolveSeedreamImageSize(modelID, input)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if size != "" {
|
|
payload["size"] = size
|
|
}
|
|
if value, ok := input["seed"]; ok {
|
|
payload["seed"] = value
|
|
}
|
|
if value, ok := input["watermark"]; ok {
|
|
payload["watermark"] = value
|
|
}
|
|
if value, ok := input["sequential_image_generation"]; ok {
|
|
payload["sequential_image_generation"] = value
|
|
}
|
|
if value, ok := input["sequential_image_generation_options"]; ok {
|
|
payload["sequential_image_generation_options"] = value
|
|
}
|
|
if len(refs) > 0 {
|
|
imageInput, err := encodeSeedreamReferenceInputs(refs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
payload["image"] = imageInput
|
|
}
|
|
log.Printf(
|
|
"popiartServer: Seedream image request model=%s has_image=%t image_type=%s image_count=%d size=%v",
|
|
modelID,
|
|
payload["image"] != nil,
|
|
describeImagePayloadType(payload["image"]),
|
|
countImagePayloadItems(payload["image"]),
|
|
payload["size"],
|
|
)
|
|
|
|
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) 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")
|
|
}
|
|
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")
|
|
}
|
|
|
|
parts := make([]map[string]any, 0, 1+len(refs))
|
|
parts = append(parts, map[string]any{"text": prompt})
|
|
for index, ref := range refs {
|
|
if len(ref.Content) == 0 {
|
|
return nil, nil, errors.New("reference image content is required")
|
|
}
|
|
if label := geminiReferenceLabel(ref, index); label != "" {
|
|
parts = append(parts, map[string]any{"text": label})
|
|
}
|
|
contentType := strings.TrimSpace(ref.ContentType)
|
|
if contentType == "" {
|
|
contentType = http.DetectContentType(ref.Content)
|
|
}
|
|
parts = append(parts, map[string]any{
|
|
"inlineData": map[string]any{
|
|
"mimeType": contentType,
|
|
"data": base64.StdEncoding.EncodeToString(ref.Content),
|
|
},
|
|
})
|
|
}
|
|
|
|
generationConfig := map[string]any{
|
|
"responseModalities": []string{"IMAGE"},
|
|
}
|
|
imageConfig := map[string]any{}
|
|
if aspectRatio := resolveGeminiAspectRatio(input); aspectRatio != "" {
|
|
imageConfig["aspectRatio"] = aspectRatio
|
|
}
|
|
if imageSize := resolveGeminiImageSize(input); imageSize != "" {
|
|
imageConfig["imageSize"] = imageSize
|
|
}
|
|
payload := map[string]any{
|
|
"contents": []map[string]any{
|
|
{"parts": parts},
|
|
},
|
|
"generationConfig": generationConfig,
|
|
}
|
|
if len(imageConfig) > 0 {
|
|
generationConfig["imageConfig"] = imageConfig
|
|
}
|
|
log.Printf(
|
|
"popiartServer: Gemini image request model=%s parts=%d refs=%d image_config=%v",
|
|
modelID,
|
|
len(parts),
|
|
len(refs),
|
|
imageConfig,
|
|
)
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
endpoint := c.baseURL + "/v1beta/models/" + url.PathEscape(modelID) + ":generateContent"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, 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 decodeGeminiImageGenerationResponse(resp.StatusCode, respBody, modelID)
|
|
}
|
|
|
|
func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelID string, input map[string]any, ref 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 == "" {
|
|
prompt = "Generate a short polished image-to-video clip from the provided reference image."
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "vidu") && strings.TrimSpace(ref.URL) != "" {
|
|
return c.submitImageToVideoTaskByURL(ctx, token, modelID, input, ref, prompt)
|
|
}
|
|
if len(ref.Content) == 0 {
|
|
return "", errors.New("reference image content is required")
|
|
}
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("model", modelID); err != nil {
|
|
return "", err
|
|
}
|
|
if err := writer.WriteField("prompt", prompt); err != nil {
|
|
return "", err
|
|
}
|
|
seconds := strings.TrimSpace(resolveVideoDurationSeconds(input))
|
|
if seconds == "" {
|
|
seconds = "4"
|
|
}
|
|
if err := writer.WriteField("seconds", seconds); err != nil {
|
|
return "", err
|
|
}
|
|
if size := resolveVideoSize(modelID, input, ref); size != "" {
|
|
if err := writer.WriteField("size", size); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
filename := strings.TrimSpace(ref.Filename)
|
|
if filename == "" {
|
|
filename = "input_reference" + extensionFromContentType(ref.ContentType)
|
|
}
|
|
contentType := strings.TrimSpace(ref.ContentType)
|
|
if contentType == "" {
|
|
contentType = http.DetectContentType(ref.Content)
|
|
}
|
|
header := textproto.MIMEHeader{}
|
|
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="input_reference"; filename="%s"`, filename))
|
|
header.Set("Content-Type", contentType)
|
|
part, err := writer.CreatePart(header)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := part.Write(ref.Content); err != nil {
|
|
return "", err
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/videos", &body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
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
|
|
}
|
|
|
|
var decoded openAIVideoResponse
|
|
if err := json.Unmarshal(respBody, &decoded); err != nil {
|
|
return "", fmt.Errorf("decode PopiNewAPI video submit response: %w", err)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return "", decodeTaskAPIError(respBody, resp.StatusCode)
|
|
}
|
|
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
|
return "", errors.New(decoded.Error.Message)
|
|
}
|
|
|
|
taskID := strings.TrimSpace(decoded.ID)
|
|
if taskID == "" {
|
|
taskID = strings.TrimSpace(decoded.TaskID)
|
|
}
|
|
if taskID == "" {
|
|
return "", errors.New("PopiNewAPI returned no task id")
|
|
}
|
|
return taskID, nil
|
|
}
|
|
|
|
func geminiReferenceLabel(ref imageEditReference, index int) string {
|
|
switch strings.ToLower(strings.TrimSpace(ref.Role)) {
|
|
case "source":
|
|
return fmt.Sprintf("Image %d is the source scene. Keep the scene layout, camera framing, main action, and spatial relationships from this image.", index+1)
|
|
case "identity":
|
|
return fmt.Sprintf("Image %d is the identity reference. Keep the character face, hair, accessories, and recognizability from this image.", index+1)
|
|
case "style":
|
|
return fmt.Sprintf("Image %d is the style reference. Apply only the visual style, palette, texture, and illustration treatment from this image. Do not change the character identity because of this image.", index+1)
|
|
case "reference":
|
|
return fmt.Sprintf("Image %d is an additional reference. Use it only for supporting details explicitly requested in the prompt.", index+1)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *newAPIClient) submitImageToVideoTaskByURL(ctx context.Context, token, modelID string, input map[string]any, ref imageEditReference, prompt string) (string, error) {
|
|
payload := map[string]any{
|
|
"model": modelID,
|
|
"prompt": prompt,
|
|
"images": []string{strings.TrimSpace(ref.URL)},
|
|
"duration": 5,
|
|
}
|
|
if duration := strings.TrimSpace(resolveVideoDurationSeconds(input)); duration != "" {
|
|
if parsed, err := strconv.Atoi(duration); err == nil && parsed > 0 {
|
|
payload["duration"] = parsed
|
|
}
|
|
}
|
|
if size := resolveVideoSize(modelID, input, ref); size != "" {
|
|
payload["size"] = size
|
|
}
|
|
if aspectRatio := resolveVideoAspectRatio(input, ref); aspectRatio != "" {
|
|
payload["metadata"] = map[string]any{
|
|
"aspect_ratio": aspectRatio,
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/videos", 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
|
|
}
|
|
|
|
var decoded openAIVideoResponse
|
|
if err := json.Unmarshal(respBody, &decoded); err != nil {
|
|
return "", fmt.Errorf("decode PopiNewAPI video submit response: %w", err)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return "", decodeTaskAPIError(respBody, resp.StatusCode)
|
|
}
|
|
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
|
return "", errors.New(decoded.Error.Message)
|
|
}
|
|
|
|
taskID := strings.TrimSpace(decoded.ID)
|
|
if taskID == "" {
|
|
taskID = strings.TrimSpace(decoded.TaskID)
|
|
}
|
|
if taskID == "" {
|
|
return "", errors.New("PopiNewAPI returned no task id")
|
|
}
|
|
return taskID, nil
|
|
}
|
|
|
|
func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) {
|
|
if !c.enabled() {
|
|
return nil, errors.New("PopiNewAPI base URL is not configured")
|
|
}
|
|
token = c.authorizedToken(token)
|
|
if token == "" {
|
|
return nil, errors.New("PopiNewAPI token is not configured")
|
|
}
|
|
|
|
result, err := c.fetchOpenAIVideoTask(ctx, token, taskID)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
fallback, fallbackErr := c.fetchGenericVideoTask(ctx, token, taskID)
|
|
if fallbackErr == nil {
|
|
return fallback, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
func (c *newAPIClient) fetchOpenAIVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/videos/"+taskID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, decodeTaskAPIError(body, resp.StatusCode)
|
|
}
|
|
|
|
var decoded openAIVideoResponse
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
return nil, fmt.Errorf("decode PopiNewAPI openai video fetch response: %w", err)
|
|
}
|
|
|
|
result := &videoTaskResult{
|
|
TaskID: defaultString(strings.TrimSpace(decoded.ID), strings.TrimSpace(decoded.TaskID)),
|
|
Status: normalizeVideoTaskStatus(decoded.Status),
|
|
Progress: normalizeVideoProgress(decoded.Progress, ""),
|
|
}
|
|
if decoded.Metadata != nil {
|
|
if text := strings.TrimSpace(stringValue(decoded.Metadata["url"])); text != "" {
|
|
result.URL = text
|
|
}
|
|
if text := strings.TrimSpace(stringValue(decoded.Metadata["format"])); text != "" {
|
|
result.Format = text
|
|
}
|
|
}
|
|
if decoded.Error != nil {
|
|
result.ErrorCode = strings.TrimSpace(decoded.Error.Code)
|
|
result.ErrorReason = strings.TrimSpace(decoded.Error.Message)
|
|
}
|
|
if result.TaskID == "" {
|
|
return nil, errors.New("PopiNewAPI returned no task id")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c *newAPIClient) fetchGenericVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/video/generations/"+taskID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, decodeTaskAPIError(body, resp.StatusCode)
|
|
}
|
|
|
|
var env taskEnvelopeResponse
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
return nil, fmt.Errorf("decode PopiNewAPI task envelope: %w", err)
|
|
}
|
|
if strings.TrimSpace(env.Code) != "" && !strings.EqualFold(env.Code, "success") {
|
|
return nil, errors.New(defaultString(strings.TrimSpace(env.Message), "PopiNewAPI video task fetch failed"))
|
|
}
|
|
|
|
var task taskDTOResponse
|
|
if err := json.Unmarshal(env.Data, &task); err == nil && strings.TrimSpace(task.TaskID) != "" {
|
|
return &videoTaskResult{
|
|
TaskID: strings.TrimSpace(task.TaskID),
|
|
Status: normalizeVideoTaskStatus(task.Status),
|
|
Progress: normalizeVideoProgress(0, task.Progress),
|
|
URL: strings.TrimSpace(task.ResultURL),
|
|
ErrorReason: strings.TrimSpace(task.FailReason),
|
|
}, nil
|
|
}
|
|
|
|
var simple struct {
|
|
TaskID string `json:"task_id"`
|
|
Status string `json:"status"`
|
|
URL string `json:"url"`
|
|
Format string `json:"format"`
|
|
Progress string `json:"progress"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(env.Data, &simple); err != nil {
|
|
return nil, fmt.Errorf("decode PopiNewAPI generic task payload: %w", err)
|
|
}
|
|
result := &videoTaskResult{
|
|
TaskID: strings.TrimSpace(simple.TaskID),
|
|
Status: normalizeVideoTaskStatus(simple.Status),
|
|
URL: strings.TrimSpace(simple.URL),
|
|
Format: strings.TrimSpace(simple.Format),
|
|
Progress: normalizeVideoProgress(0, simple.Progress),
|
|
}
|
|
if simple.Error != nil {
|
|
result.ErrorCode = strings.TrimSpace(simple.Error.Code)
|
|
result.ErrorReason = strings.TrimSpace(simple.Error.Message)
|
|
}
|
|
if result.TaskID == "" {
|
|
result.TaskID = taskID
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func decodeImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) {
|
|
|
|
var decoded imageGenerationResponse
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
return nil, nil, fmt.Errorf("decode PopiNewAPI response: %w", err)
|
|
}
|
|
if statusCode >= 400 {
|
|
if decoded.Error != nil && decoded.Error.Message != "" {
|
|
return nil, decoded.Usage, errors.New(decoded.Error.Message)
|
|
}
|
|
return nil, decoded.Usage, fmt.Errorf("PopiNewAPI returned status %d", statusCode)
|
|
}
|
|
if decoded.Error != nil && decoded.Error.Message != "" {
|
|
return nil, decoded.Usage, errors.New(decoded.Error.Message)
|
|
}
|
|
if len(decoded.Data) == 0 {
|
|
return nil, decoded.Usage, errors.New("PopiNewAPI returned no image data")
|
|
}
|
|
|
|
first := decoded.Data[0]
|
|
if first.B64JSON != "" {
|
|
content, err := base64.StdEncoding.DecodeString(first.B64JSON)
|
|
if err != nil {
|
|
return nil, decoded.Usage, fmt.Errorf("decode b64 image: %w", err)
|
|
}
|
|
contentType := http.DetectContentType(content)
|
|
return []resultRef{
|
|
{
|
|
Kind: "data_url",
|
|
DataURL: "data:" + contentType + ";base64," + first.B64JSON,
|
|
Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)),
|
|
ContentType: contentType,
|
|
SizeBytes: int64(len(content)),
|
|
},
|
|
}, decoded.Usage, nil
|
|
}
|
|
|
|
if first.URL != "" {
|
|
ref := resultRef{
|
|
Kind: "url",
|
|
URL: first.URL,
|
|
Filename: filenameFromURL(first.URL, fmt.Sprintf("%s-%d", sanitizeFilename(nameHint), time.Now().UTC().Unix())),
|
|
}
|
|
if strings.HasPrefix(first.URL, "data:") {
|
|
ref.Kind = "data_url"
|
|
ref.DataURL = first.URL
|
|
ref.URL = ""
|
|
}
|
|
return []resultRef{ref}, decoded.Usage, nil
|
|
}
|
|
|
|
return nil, decoded.Usage, errors.New("PopiNewAPI returned neither b64_json nor url")
|
|
}
|
|
|
|
func decodeGeminiImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) {
|
|
var decoded geminiImageGenerationResponse
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
return nil, nil, fmt.Errorf("decode Gemini image response: %w", err)
|
|
}
|
|
if statusCode >= 400 {
|
|
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
|
return nil, decoded.UsageMetadata, errors.New(strings.TrimSpace(decoded.Error.Message))
|
|
}
|
|
return nil, decoded.UsageMetadata, fmt.Errorf("Gemini image generation returned status %d", statusCode)
|
|
}
|
|
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
|
return nil, decoded.UsageMetadata, errors.New(strings.TrimSpace(decoded.Error.Message))
|
|
}
|
|
if len(decoded.Candidates) == 0 {
|
|
return nil, decoded.UsageMetadata, errors.New("Gemini image generation returned no candidates")
|
|
}
|
|
|
|
for _, part := range decoded.Candidates[0].Content.Parts {
|
|
if part.InlineData == nil || strings.TrimSpace(part.InlineData.Data) == "" {
|
|
continue
|
|
}
|
|
content, err := base64.StdEncoding.DecodeString(part.InlineData.Data)
|
|
if err != nil {
|
|
return nil, decoded.UsageMetadata, fmt.Errorf("decode Gemini inline image: %w", err)
|
|
}
|
|
contentType := strings.TrimSpace(part.InlineData.MIMEType)
|
|
if contentType == "" {
|
|
contentType = http.DetectContentType(content)
|
|
}
|
|
return []resultRef{
|
|
{
|
|
Kind: "data_url",
|
|
DataURL: "data:" + contentType + ";base64," + part.InlineData.Data,
|
|
Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)),
|
|
ContentType: contentType,
|
|
SizeBytes: int64(len(content)),
|
|
},
|
|
}, decoded.UsageMetadata, nil
|
|
}
|
|
|
|
return nil, decoded.UsageMetadata, errors.New("Gemini image generation returned no inline image data")
|
|
}
|
|
|
|
func useGeminiImageGenerateContent(modelID string) bool {
|
|
lowerID := strings.ToLower(strings.TrimSpace(modelID))
|
|
return strings.Contains(lowerID, "gemini") || strings.Contains(lowerID, "banana")
|
|
}
|
|
|
|
func useSeedreamImageGenerations(modelID string) bool {
|
|
lowerID := strings.ToLower(strings.TrimSpace(modelID))
|
|
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 := normalizeImageAspectRatio(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
|
if _, ok := seedreamAspectRatioSizes[aspectRatio]; ok {
|
|
return aspectRatio
|
|
}
|
|
}
|
|
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 {
|
|
for _, value := range []string{
|
|
stringValue(input["resolution"]),
|
|
stringValue(input["size"]),
|
|
} {
|
|
if imageSize := geminiImageSizeFromValue(value); imageSize != "" {
|
|
return imageSize
|
|
}
|
|
}
|
|
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{} {
|
|
lowerID := strings.ToLower(strings.TrimSpace(modelID))
|
|
if strings.Contains(lowerID, "5-0") {
|
|
return map[string]struct{}{
|
|
"2K": {},
|
|
"3K": {},
|
|
}
|
|
}
|
|
return map[string]struct{}{
|
|
"2K": {},
|
|
"4K": {},
|
|
}
|
|
}
|
|
|
|
func encodeImageReferenceInputs(refs []imageEditReference) (any, error) {
|
|
encoded := make([]string, 0, len(refs))
|
|
for _, ref := range refs {
|
|
value, err := encodeImageReference(ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
encoded = append(encoded, value)
|
|
}
|
|
if len(encoded) == 1 {
|
|
return encoded[0], nil
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
func encodeSeedreamReferenceInputs(refs []imageEditReference) (any, error) {
|
|
return encodeImageReferenceInputs(refs)
|
|
}
|
|
|
|
func describeImagePayloadType(value any) string {
|
|
switch value.(type) {
|
|
case nil:
|
|
return "missing"
|
|
case string:
|
|
return "string"
|
|
case []string:
|
|
return "string_array"
|
|
case []any:
|
|
return "array"
|
|
default:
|
|
return fmt.Sprintf("%T", value)
|
|
}
|
|
}
|
|
|
|
func countImagePayloadItems(value any) int {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return 0
|
|
case string:
|
|
if strings.TrimSpace(typed) == "" {
|
|
return 0
|
|
}
|
|
return 1
|
|
case []string:
|
|
return len(typed)
|
|
case []any:
|
|
return len(typed)
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func encodeImageReference(ref imageEditReference) (string, error) {
|
|
if rawURL := strings.TrimSpace(ref.URL); rawURL != "" {
|
|
return rawURL, nil
|
|
}
|
|
if len(ref.Content) == 0 {
|
|
return "", errors.New("reference image content is required")
|
|
}
|
|
contentType := strings.TrimSpace(ref.ContentType)
|
|
if contentType == "" {
|
|
contentType = http.DetectContentType(ref.Content)
|
|
}
|
|
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":
|
|
file, err := os.Open(ref.LocalPath)
|
|
if err != nil {
|
|
return "", 0, nil, err
|
|
}
|
|
return defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream"), ref.SizeBytes, file, nil
|
|
case "data_url":
|
|
contentType, content, err := decodeDataURL(ref.DataURL)
|
|
if err != nil {
|
|
return "", 0, nil, err
|
|
}
|
|
return contentType, int64(len(content)), io.NopCloser(bytes.NewReader(content)), nil
|
|
case "url":
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ref.URL, nil)
|
|
if err != nil {
|
|
return "", 0, nil, err
|
|
}
|
|
if authToken := c.authorizedToken(token); authToken != "" && shouldAttachAuthHeader(c.baseURL, ref.URL) {
|
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", 0, nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", 0, nil, fmt.Errorf("download upstream artifact failed: %s", strings.TrimSpace(string(body)))
|
|
}
|
|
contentType := defaultString(strings.TrimSpace(resp.Header.Get("Content-Type")), defaultString(ref.ContentType, "application/octet-stream"))
|
|
sizeBytes := resp.ContentLength
|
|
if sizeBytes <= 0 {
|
|
sizeBytes = ref.SizeBytes
|
|
}
|
|
return contentType, sizeBytes, resp.Body, nil
|
|
default:
|
|
return "", 0, nil, fmt.Errorf("unsupported result ref kind: %s", ref.Kind)
|
|
}
|
|
}
|
|
|
|
func decodeDataURL(value string) (string, []byte, error) {
|
|
if !strings.HasPrefix(value, "data:") {
|
|
return "", nil, errors.New("invalid data url")
|
|
}
|
|
comma := strings.Index(value, ",")
|
|
if comma <= 5 {
|
|
return "", nil, errors.New("invalid data url payload")
|
|
}
|
|
header := value[5:comma]
|
|
payload := value[comma+1:]
|
|
contentType := "text/plain"
|
|
if semi := strings.Index(header, ";"); semi >= 0 {
|
|
if strings.TrimSpace(header[:semi]) != "" {
|
|
contentType = strings.TrimSpace(header[:semi])
|
|
}
|
|
if !strings.Contains(header[semi+1:], "base64") {
|
|
return "", nil, errors.New("unsupported non-base64 data url")
|
|
}
|
|
} else if strings.TrimSpace(header) != "" {
|
|
contentType = strings.TrimSpace(header)
|
|
}
|
|
content, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("decode data url payload: %w", err)
|
|
}
|
|
return contentType, content, nil
|
|
}
|
|
|
|
func sanitizeFilename(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "artifact"
|
|
}
|
|
replacer := strings.NewReplacer("/", "-", "\\", "-", " ", "-", ":", "-")
|
|
return replacer.Replace(value)
|
|
}
|
|
|
|
func extensionFromContentType(contentType string) string {
|
|
switch contentType {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "video/mp4":
|
|
return ".mp4"
|
|
case "video/quicktime":
|
|
return ".mov"
|
|
case "video/webm":
|
|
return ".webm"
|
|
default:
|
|
return ".jpg"
|
|
}
|
|
}
|
|
|
|
func filenameFromURL(rawURL, fallback string) string {
|
|
if parsed, err := http.NewRequest(http.MethodGet, rawURL, nil); err == nil {
|
|
base := path.Base(parsed.URL.Path)
|
|
if base != "" && base != "." && base != "/" {
|
|
return base
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func resolveVideoDurationSeconds(input map[string]any) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
for _, value := range []any{input["seconds"], input["duration_s"], input["duration"]} {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
typed = strings.TrimSpace(typed)
|
|
if typed != "" {
|
|
return typed
|
|
}
|
|
case int:
|
|
if typed > 0 {
|
|
return strconv.Itoa(typed)
|
|
}
|
|
case int32:
|
|
if typed > 0 {
|
|
return strconv.FormatInt(int64(typed), 10)
|
|
}
|
|
case int64:
|
|
if typed > 0 {
|
|
return strconv.FormatInt(typed, 10)
|
|
}
|
|
case float64:
|
|
if typed > 0 {
|
|
return strconv.Itoa(int(typed))
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveVideoSize(modelID string, input map[string]any, ref imageEditReference) string {
|
|
size := resolveBaseVideoSize(input, ref)
|
|
if inferProviderFallback(modelID) == "vidu" {
|
|
return normalizeViduResolution(size)
|
|
}
|
|
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 != "" {
|
|
return aspectRatio
|
|
}
|
|
}
|
|
if len(ref.Content) > 0 {
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(ref.Content))
|
|
if err == nil && cfg.Width > 0 && cfg.Height > 0 {
|
|
if cfg.Width >= cfg.Height {
|
|
return "16:9"
|
|
}
|
|
return "9:16"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveBaseVideoSize(input map[string]any, ref imageEditReference) string {
|
|
if input != nil {
|
|
if size := strings.TrimSpace(stringValue(input["size"])); size != "" {
|
|
return size
|
|
}
|
|
switch strings.TrimSpace(stringValue(input["aspect_ratio"])) {
|
|
case "16:9":
|
|
return "1280x720"
|
|
case "9:16":
|
|
return "720x1280"
|
|
}
|
|
}
|
|
|
|
if len(ref.Content) > 0 {
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(ref.Content))
|
|
if err == nil && cfg.Width > 0 && cfg.Height > 0 {
|
|
if cfg.Width >= cfg.Height {
|
|
return "1280x720"
|
|
}
|
|
return "720x1280"
|
|
}
|
|
}
|
|
|
|
return "720x1280"
|
|
}
|
|
|
|
func normalizeViduResolution(size string) string {
|
|
switch strings.ToLower(strings.TrimSpace(size)) {
|
|
case "360p", "540p", "720p", "1080p":
|
|
return strings.ToLower(strings.TrimSpace(size))
|
|
case "1920x1080", "1080x1920":
|
|
return "1080p"
|
|
default:
|
|
return "720p"
|
|
}
|
|
}
|
|
|
|
func normalizeVideoTaskStatus(status string) string {
|
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
|
case "queued", "pending", "submitted":
|
|
return "queued"
|
|
case "processing", "in_progress", "running":
|
|
return "in_progress"
|
|
case "completed", "done", "success", "succeeded":
|
|
return "completed"
|
|
case "failed", "failure", "cancelled", "canceled":
|
|
return "failed"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeVideoProgress(progress int, fallback string) string {
|
|
if progress > 0 {
|
|
return fmt.Sprintf("%d%%", progress)
|
|
}
|
|
fallback = strings.TrimSpace(fallback)
|
|
if fallback != "" {
|
|
return fallback
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func decodeTaskAPIError(body []byte, statusCode int) error {
|
|
var taskErr struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &taskErr); err == nil {
|
|
if taskErr.Error != nil && strings.TrimSpace(taskErr.Error.Message) != "" {
|
|
return errors.New(taskErr.Error.Message)
|
|
}
|
|
if strings.TrimSpace(taskErr.Message) != "" {
|
|
return errors.New(taskErr.Message)
|
|
}
|
|
}
|
|
return fmt.Errorf("PopiNewAPI returned status %d", statusCode)
|
|
}
|
|
|
|
func shouldAttachAuthHeader(baseURL, rawURL string) bool {
|
|
base, err := url.Parse(strings.TrimSpace(baseURL))
|
|
if err != nil {
|
|
return strings.HasPrefix(rawURL, baseURL+"/")
|
|
}
|
|
target, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil {
|
|
return strings.HasPrefix(rawURL, baseURL+"/")
|
|
}
|
|
if target.Scheme == "" || target.Host == "" {
|
|
return true
|
|
}
|
|
if strings.EqualFold(base.Host, target.Host) {
|
|
return true
|
|
}
|
|
if equivalentLocalHosts(base.Hostname(), target.Hostname()) && base.Port() == target.Port() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func equivalentLocalHosts(a, b string) bool {
|
|
a = strings.ToLower(strings.TrimSpace(a))
|
|
b = strings.ToLower(strings.TrimSpace(b))
|
|
if a == b {
|
|
return true
|
|
}
|
|
return (a == "localhost" && b == "127.0.0.1") || (a == "127.0.0.1" && b == "localhost")
|
|
}
|
|
|