fix(inference): 回退为 OpenAI 兼容同步 Images API,恢复图片编辑功能
- 使用标准 POST /images/generations 端点(suchuang.vip 兼容) - 恢复 EditImages 的 multipart/form-data 实现 - 移除 yuntts 异步 submit/poll 流程 - 配置精简: base_url / api_key / model / width / height / quality
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@@ -11,9 +12,9 @@ import (
|
|||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gen2d/internal/config"
|
"gen2d/internal/config"
|
||||||
)
|
)
|
||||||
@@ -26,47 +27,28 @@ func InitImageGenConfig(cfg config.ImageGenConfig) {
|
|||||||
imgCfg = cfg
|
imgCfg = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================== GPT Image 2 API 类型 ========================
|
// ======================== OpenAI 兼容 Images API 类型 ========================
|
||||||
|
|
||||||
// genSubmitReq 提交生图任务请求体。
|
// imageGenRequest OpenAI 兼容文生图请求体。
|
||||||
type genSubmitReq struct {
|
type imageGenRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
N int `json:"n,omitempty"`
|
||||||
ReferenceImages []string `json:"reference_images,omitempty"`
|
Size string `json:"size,omitempty"`
|
||||||
XChannel string `json:"x_channel,omitempty"`
|
Quality string `json:"quality,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// genSubmitResp 提交生图任务响应体。
|
// imageGenResponse OpenAI 兼容文生图响应体。
|
||||||
type genSubmitResp struct {
|
type imageGenResponse struct {
|
||||||
Code int `json:"code"`
|
Data []struct {
|
||||||
Message string `json:"message"`
|
URL string `json:"url"`
|
||||||
Data struct {
|
B64JSON string `json:"b64_json"`
|
||||||
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"`
|
|
||||||
} `json:"data"`
|
} `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) {
|
func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]GeneratedImage, error) {
|
||||||
count := 1
|
count := 1
|
||||||
if params.Frames.Directions > 0 && params.Frames.FramesPerDirection > 0 {
|
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 != "" {
|
if imgCfg.APIKey != "" {
|
||||||
log.Println("[inference] using image gen async API")
|
width, height := imgCfg.Width, imgCfg.Height
|
||||||
return generateAsync(ctx, prompt, count, nil)
|
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
|
size := params.Resolution
|
||||||
@@ -86,170 +73,22 @@ func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]G
|
|||||||
return generateMockImages(size, count)
|
return generateMockImages(size, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================== 图片编辑 ========================
|
// callImageAPI 调用 OpenAI 兼容 Images API。
|
||||||
|
func callImageAPI(ctx context.Context, prompt string, count, width, height int) ([]GeneratedImage, error) {
|
||||||
// EditImages 图片编辑接口,以参考图模式提交 GPT Image 2 编辑任务。
|
reqBody := imageGenRequest{
|
||||||
func EditImages(ctx context.Context, imageData []byte, prompt string, count int) ([]GeneratedImage, error) {
|
Model: imgCfg.Model,
|
||||||
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,
|
Prompt: prompt,
|
||||||
AspectRatio: imgCfg.AspectRatio,
|
N: count,
|
||||||
ReferenceImages: refImages,
|
Size: fmt.Sprintf("%dx%d", width, height),
|
||||||
XChannel: imgCfg.XChannel,
|
Quality: imgCfg.Quality,
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
body, err := json.Marshal(reqBody)
|
||||||
if err != nil {
|
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"
|
url := strings.TrimRight(imgCfg.BaseURL, "/") + "/images/generations"
|
||||||
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"
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
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)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("send: %w", err)
|
return nil, fmt.Errorf("send request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var sr genStatusResp
|
if resp.StatusCode != http.StatusOK {
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return nil, fmt.Errorf("decode: %w", err)
|
return nil, fmt.Errorf("image api error %d: %s", resp.StatusCode, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
if sr.Code != 200 {
|
return parseImageResponse(ctx, resp.Body, width, height)
|
||||||
return nil, fmt.Errorf("status query failed: %s", sr.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &sr, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadResult 下载生成的图片。
|
// parseImageResponse 解析 OpenAI 兼容图片响应(b64_json 或 url)。
|
||||||
func downloadResult(ctx context.Context, imageURL string) ([]byte, error) {
|
func parseImageResponse(ctx context.Context, r io.Reader, width, height int) ([]GeneratedImage, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create download request: %w", err)
|
return nil, fmt.Errorf("create download request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download: %w", err)
|
return nil, fmt.Errorf("download: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("download status %d", resp.StatusCode)
|
return nil, fmt.Errorf("download status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return io.ReadAll(resp.Body)
|
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
|
var QualityChecker = defaultCheckQuality
|
||||||
|
|
||||||
// CheckQuality 调用当前 QualityChecker。
|
|
||||||
func CheckQuality(ctx context.Context, images []GeneratedImage, style map[string]string) (bool, string, error) {
|
func CheckQuality(ctx context.Context, images []GeneratedImage, style map[string]string) (bool, string, error) {
|
||||||
return QualityChecker(ctx, images, style)
|
return QualityChecker(ctx, images, style)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user