From 8c027d9b52829487b7876ed66e2331a66663afec Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 13:01:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(inference):=20=E5=9B=9E=E9=80=80=E4=B8=BA?= =?UTF-8?q?=20OpenAI=20=E5=85=BC=E5=AE=B9=E5=90=8C=E6=AD=A5=20Images=20API?= =?UTF-8?q?=EF=BC=8C=E6=81=A2=E5=A4=8D=E5=9B=BE=E7=89=87=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用标准 POST /images/generations 端点(suchuang.vip 兼容) - 恢复 EditImages 的 multipart/form-data 实现 - 移除 yuntts 异步 submit/poll 流程 - 配置精简: base_url / api_key / model / width / height / quality --- backend/internal/service/inference.go | 338 ++++++++++---------------- 1 file changed, 127 insertions(+), 211 deletions(-) diff --git a/backend/internal/service/inference.go b/backend/internal/service/inference.go index c52a24e..f334a34 100755 --- a/backend/internal/service/inference.go +++ b/backend/internal/service/inference.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "image" @@ -11,9 +12,9 @@ import ( "image/png" "io" "log" + "mime/multipart" "net/http" "strings" - "time" "gen2d/internal/config" ) @@ -26,47 +27,28 @@ func InitImageGenConfig(cfg config.ImageGenConfig) { imgCfg = cfg } -// ======================== GPT Image 2 API 类型 ======================== +// ======================== OpenAI 兼容 Images API 类型 ======================== -// genSubmitReq 提交生图任务请求体。 -type genSubmitReq struct { - Prompt string `json:"prompt"` - AspectRatio string `json:"aspect_ratio,omitempty"` - ReferenceImages []string `json:"reference_images,omitempty"` - XChannel string `json:"x_channel,omitempty"` +// imageGenRequest OpenAI 兼容文生图请求体。 +type imageGenRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + N int `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` } -// genSubmitResp 提交生图任务响应体。 -type genSubmitResp struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - } `json:"data"` -} - -// genStatusReq 查询任务状态请求体。 -type genStatusReq struct { - TaskID string `json:"task_id"` -} - -// genStatusResp 查询任务状态响应体。 -type genStatusResp struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - Progress int `json:"progress"` - ResultImageURL string `json:"result_image_url"` - ErrorMessage string `json:"error_message"` +// imageGenResponse OpenAI 兼容文生图响应体。 +type imageGenResponse struct { + Data []struct { + URL string `json:"url"` + B64JSON string `json:"b64_json"` } `json:"data"` } // ======================== 文生图 ======================== -// GenerateImages 通过 GPT Image 2 异步 API 生成图片,未配置 key 时回退到 mock。 +// GenerateImages 调用 OpenAI 兼容 Images API 生成图片,未配置 key 时回退 mock。 func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]GeneratedImage, error) { count := 1 if params.Frames.Directions > 0 && params.Frames.FramesPerDirection > 0 { @@ -74,8 +56,13 @@ func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]G } if imgCfg.APIKey != "" { - log.Println("[inference] using image gen async API") - return generateAsync(ctx, prompt, count, nil) + width, height := imgCfg.Width, imgCfg.Height + if params.Resolution > 0 { + width = params.Resolution + height = params.Resolution + } + log.Println("[inference] calling image gen API") + return callImageAPI(ctx, prompt, count, width, height) } size := params.Resolution @@ -86,170 +73,22 @@ func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]G return generateMockImages(size, count) } -// ======================== 图片编辑 ======================== - -// EditImages 图片编辑接口,以参考图模式提交 GPT Image 2 编辑任务。 -func EditImages(ctx context.Context, imageData []byte, prompt string, count int) ([]GeneratedImage, error) { - if imgCfg.APIKey == "" { - return nil, fmt.Errorf("image API key not configured") - } - log.Println("[inference] using image gen async API for edit") - // 暂不支持参考图 URL 模式,提示用户 - return nil, fmt.Errorf("image edit with reference image: not yet implemented for async API") -} - -// ======================== 异步核心 ======================== - -// generateAsync 并行提交 count 个任务,轮询完成后下载图片。 -func generateAsync(ctx context.Context, prompt string, count int, refImages []string) ([]GeneratedImage, error) { - type submitResult struct { - index int - taskID string - err error - } - results := make(chan submitResult, count) - for i := 0; i < count; i++ { - go func(idx int) { - taskID, err := submitTask(ctx, prompt, refImages) - results <- submitResult{index: idx, taskID: taskID, err: err} - }(i) - } - - taskIDs := make([]string, count) - for i := 0; i < count; i++ { - r := <-results - if r.err != nil { - return nil, fmt.Errorf("submit task %d: %w", r.index, r.err) - } - taskIDs[r.index] = r.taskID - } - - type imageResult struct { - index int - image GeneratedImage - err error - } - imgResults := make(chan imageResult, count) - for i, tid := range taskIDs { - go func(idx int, taskID string) { - img, err := pollAndDownload(ctx, taskID) - imgResults <- imageResult{index: idx, image: img, err: err} - }(i, tid) - } - - images := make([]GeneratedImage, count) - for i := 0; i < count; i++ { - r := <-imgResults - if r.err != nil { - return nil, fmt.Errorf("task %d: %w", r.index, r.err) - } - images[r.index] = r.image - } - - return images, nil -} - -// submitTask 提交生图任务,返回 taskID。 -func submitTask(ctx context.Context, prompt string, refImages []string) (string, error) { - reqBody := genSubmitReq{ - Prompt: prompt, - AspectRatio: imgCfg.AspectRatio, - ReferenceImages: refImages, - XChannel: imgCfg.XChannel, +// callImageAPI 调用 OpenAI 兼容 Images API。 +func callImageAPI(ctx context.Context, prompt string, count, width, height int) ([]GeneratedImage, error) { + reqBody := imageGenRequest{ + Model: imgCfg.Model, + Prompt: prompt, + N: count, + Size: fmt.Sprintf("%dx%d", width, height), + Quality: imgCfg.Quality, } body, err := json.Marshal(reqBody) if err != nil { - return "", fmt.Errorf("marshal: %w", err) + return nil, fmt.Errorf("marshal request: %w", err) } - url := strings.TrimRight(imgCfg.BaseURL, "/") + "/gpt-image2/generate" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("send: %w", err) - } - defer resp.Body.Close() - - var sr genSubmitResp - if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { - return "", fmt.Errorf("decode: %w", err) - } - - if sr.Code != 200 { - return "", fmt.Errorf("submit failed: %s", sr.Message) - } - - log.Printf("[inference] task submitted: %s", sr.Data.TaskID) - return sr.Data.TaskID, nil -} - -// pollAndDownload 轮询任务直到完成,下载图片。 -func pollAndDownload(ctx context.Context, taskID string) (GeneratedImage, error) { - pollInterval := imgCfg.PollInterval - if pollInterval <= 0 { - pollInterval = 3 - } - maxWait := imgCfg.PollMaxWait - if maxWait <= 0 { - maxWait = 120 - } - - deadline := time.Now().Add(time.Duration(maxWait) * time.Second) - ticker := time.NewTicker(time.Duration(pollInterval) * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return GeneratedImage{}, ctx.Err() - case <-ticker.C: - st, err := queryStatus(ctx, taskID) - if err != nil { - return GeneratedImage{}, fmt.Errorf("query status: %w", err) - } - - switch st.Data.Status { - case "completed": - log.Printf("[inference] task %s completed, downloading", taskID) - data, err := downloadResult(ctx, st.Data.ResultImageURL) - if err != nil { - return GeneratedImage{}, fmt.Errorf("download: %w", err) - } - return GeneratedImage{Data: data, Format: "png"}, nil - - case "failed": - errMsg := st.Data.ErrorMessage - if errMsg == "" { - errMsg = "unknown error" - } - return GeneratedImage{}, fmt.Errorf("generation failed: %s", errMsg) - - default: - log.Printf("[inference] task %s status=%s progress=%d", taskID, st.Data.Status, st.Data.Progress) - } - - if time.Now().After(deadline) { - return GeneratedImage{}, fmt.Errorf("poll timeout after %ds", maxWait) - } - } - } -} - -// queryStatus 查询任务状态。 -func queryStatus(ctx context.Context, taskID string) (*genStatusResp, error) { - body, err := json.Marshal(genStatusReq{TaskID: taskID}) - if err != nil { - return nil, fmt.Errorf("marshal: %w", err) - } - - url := strings.TrimRight(imgCfg.BaseURL, "/") + "/gpt-image2/status" + url := strings.TrimRight(imgCfg.BaseURL, "/") + "/images/generations" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) @@ -259,48 +98,125 @@ func queryStatus(ctx context.Context, taskID string) (*genStatusResp, error) { resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, fmt.Errorf("send: %w", err) + return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() - var sr genStatusResp - if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { - return nil, fmt.Errorf("decode: %w", err) + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("image api error %d: %s", resp.StatusCode, string(b)) } - if sr.Code != 200 { - return nil, fmt.Errorf("status query failed: %s", sr.Message) - } - - return &sr, nil + return parseImageResponse(ctx, resp.Body, width, height) } -// downloadResult 下载生成的图片。 -func downloadResult(ctx context.Context, imageURL string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) +// parseImageResponse 解析 OpenAI 兼容图片响应(b64_json 或 url)。 +func parseImageResponse(ctx context.Context, r io.Reader, width, height int) ([]GeneratedImage, error) { + var genResp imageGenResponse + if err := json.NewDecoder(r).Decode(&genResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + images := make([]GeneratedImage, 0, len(genResp.Data)) + for i, d := range genResp.Data { + var data []byte + switch { + case d.B64JSON != "": + var err error + data, err = base64.StdEncoding.DecodeString(d.B64JSON) + if err != nil { + return nil, fmt.Errorf("decode base64 image %d: %w", i, err) + } + case d.URL != "": + var err error + data, err = downloadImage(ctx, d.URL) + if err != nil { + return nil, fmt.Errorf("download image %d: %w", i, err) + } + default: + return nil, fmt.Errorf("image %d: no data or url in response", i) + } + images = append(images, GeneratedImage{ + Data: data, + Width: width, + Height: height, + Format: "png", + }) + } + return images, nil +} + +// downloadImage 从 URL 下载图片数据。 +func downloadImage(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create download request: %w", err) } - resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("download: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("download status %d", resp.StatusCode) } - return io.ReadAll(resp.Body) } +// ======================== 图片编辑 ======================== + +// EditImages 图片编辑接口,调用 OpenAI 兼容 Images Edits API(multipart/form-data)。 +func EditImages(ctx context.Context, imageData []byte, prompt string, count int) ([]GeneratedImage, error) { + if imgCfg.APIKey == "" { + return nil, fmt.Errorf("image API key not configured") + } + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, err := writer.CreateFormFile("image", "image.png") + if err != nil { + return nil, fmt.Errorf("create form file: %w", err) + } + if _, err := part.Write(imageData); err != nil { + return nil, fmt.Errorf("write image data: %w", err) + } + + writer.WriteField("prompt", prompt) + writer.WriteField("model", imgCfg.Model) + writer.WriteField("n", fmt.Sprintf("%d", count)) + writer.WriteField("size", fmt.Sprintf("%dx%d", imgCfg.Width, imgCfg.Height)) + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("close multipart writer: %w", err) + } + + url := strings.TrimRight(imgCfg.BaseURL, "/") + "/images/edits" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("image edit api error %d: %s", resp.StatusCode, string(b)) + } + + return parseImageResponse(ctx, resp.Body, imgCfg.Width, imgCfg.Height) +} + // ======================== 质检 ======================== -// QualityChecker 质检函数,可替换用于测试。 var QualityChecker = defaultCheckQuality -// CheckQuality 调用当前 QualityChecker。 func CheckQuality(ctx context.Context, images []GeneratedImage, style map[string]string) (bool, string, error) { return QualityChecker(ctx, images, style) }