popiart-server
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

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")
}