package server import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "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 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")), 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")), } 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 == "" { 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 { Filename string ContentType string Content []byte } 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, "hunyuan-video"), strings.Contains(lowerID, "video"), strings.Contains(lowerID, "t2v"), strings.Contains(lowerID, "i2v"): return "video", []string{"text2video", "image2video"} case strings.Contains(lowerID, "image"), 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) } 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, ref imageEditReference) ([]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, []imageEditReference{ref}) } 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") } 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) 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 _, ref := range refs { if len(ref.Content) == 0 { return nil, nil, errors.New("reference image content is required") } 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 { payload["imageConfig"] = 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 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 } if seconds := resolveVideoDurationSeconds(input); seconds != "" { 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 (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 resolveGeminiAspectRatio(input map[string]any) string { if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { return aspectRatio } size := strings.TrimSpace(stringValue(input["size"])) if size == "" { return "" } switch size { case "1024x1024", "1920x1920": return "1:1" case "1536x1024": return "3:2" case "1024x1536": return "2:3" case "1792x1024": return "16:9" case "1024x1792": return "9:16" default: return "" } } func resolveGeminiImageSize(input map[string]any) string { if resolution := strings.ToUpper(strings.TrimSpace(stringValue(input["resolution"]))); resolution != "" { return resolution } size := strings.TrimSpace(stringValue(input["size"])) switch size { case "1024x1024": return "1K" case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "1920x1920": return "2K" default: return "" } } func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) { switch ref.Kind { 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 "4" } 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 "4" } 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 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") }