2026-05-24 13:21:11 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-24 20:45:18 +08:00
|
|
|
|
"bytes"
|
2026-05-24 13:21:11 +08:00
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/rand"
|
2026-05-25 13:01:13 +08:00
|
|
|
|
"encoding/base64"
|
2026-05-24 22:47:43 +08:00
|
|
|
|
"encoding/json"
|
2026-05-24 13:21:11 +08:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"image"
|
|
|
|
|
|
"image/color"
|
|
|
|
|
|
"image/png"
|
2026-05-24 22:47:43 +08:00
|
|
|
|
"io"
|
|
|
|
|
|
"log"
|
2026-05-25 13:01:13 +08:00
|
|
|
|
"mime/multipart"
|
2026-05-24 22:47:43 +08:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"strings"
|
2026-05-24 20:45:18 +08:00
|
|
|
|
|
|
|
|
|
|
"gen2d/internal/config"
|
2026-05-24 13:21:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-24 20:45:18 +08:00
|
|
|
|
// imgCfg 保存文生图配置,由 main 通过 InitImageGenConfig 注入。
|
|
|
|
|
|
var imgCfg config.ImageGenConfig
|
|
|
|
|
|
|
|
|
|
|
|
// InitImageGenConfig 注入文生图配置。
|
|
|
|
|
|
func InitImageGenConfig(cfg config.ImageGenConfig) {
|
|
|
|
|
|
imgCfg = cfg
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// ======================== OpenAI 兼容 Images API 类型 ========================
|
2026-05-24 22:47:43 +08:00
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// 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"`
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// imageGenResponse OpenAI 兼容文生图响应体。
|
|
|
|
|
|
type imageGenResponse struct {
|
|
|
|
|
|
Data []struct {
|
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
|
B64JSON string `json:"b64_json"`
|
2026-05-25 12:44:39 +08:00
|
|
|
|
} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ======================== 文生图 ========================
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// GenerateImages 调用 OpenAI 兼容 Images API 生成图片,未配置 key 时回退 mock。
|
2026-05-24 13:21:11 +08:00
|
|
|
|
func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]GeneratedImage, error) {
|
2026-05-24 22:47:43 +08:00
|
|
|
|
count := 1
|
|
|
|
|
|
if params.Frames.Directions > 0 && params.Frames.FramesPerDirection > 0 {
|
|
|
|
|
|
count = params.Frames.Directions * params.Frames.FramesPerDirection
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if imgCfg.APIKey != "" {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
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)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 13:21:11 +08:00
|
|
|
|
size := params.Resolution
|
|
|
|
|
|
if size <= 0 {
|
|
|
|
|
|
size = 64
|
|
|
|
|
|
}
|
2026-05-25 12:44:39 +08:00
|
|
|
|
log.Println("[inference] image API key not configured, using mock")
|
2026-05-24 22:47:43 +08:00
|
|
|
|
return generateMockImages(size, count)
|
|
|
|
|
|
}
|
2026-05-24 13:21:11 +08:00
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// 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,
|
2026-05-24 13:21:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 22:47:43 +08:00
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
url := strings.TrimRight(imgCfg.BaseURL, "/") + "/images/generations"
|
2026-05-24 22:47:43 +08:00
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("create request: %w", err)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("send request: %w", err)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
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))
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return parseImageResponse(ctx, resp.Body, width, height)
|
2026-05-25 12:44:39 +08:00
|
|
|
|
}
|
2026-05-24 22:47:43 +08:00
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// 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)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
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)
|
2026-05-25 12:44:39 +08:00
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("decode base64 image %d: %w", i, err)
|
2026-05-25 12:44:39 +08:00
|
|
|
|
}
|
2026-05-25 13:01:13 +08:00
|
|
|
|
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)
|
2026-05-25 12:44:39 +08:00
|
|
|
|
}
|
2026-05-25 13:01:13 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return nil, fmt.Errorf("image %d: no data or url in response", i)
|
2026-05-25 12:44:39 +08:00
|
|
|
|
}
|
2026-05-25 13:01:13 +08:00
|
|
|
|
images = append(images, GeneratedImage{
|
|
|
|
|
|
Data: data,
|
|
|
|
|
|
Width: width,
|
|
|
|
|
|
Height: height,
|
|
|
|
|
|
Format: "png",
|
|
|
|
|
|
})
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return images, nil
|
2026-05-25 12:44:39 +08:00
|
|
|
|
}
|
2026-05-24 22:47:43 +08:00
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// downloadImage 从 URL 下载图片数据。
|
|
|
|
|
|
func downloadImage(ctx context.Context, url string) ([]byte, error) {
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
2026-05-25 12:44:39 +08:00
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("create download request: %w", err)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("download: %w", err)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
2026-05-25 13:01:13 +08:00
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
return nil, fmt.Errorf("download status %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
return io.ReadAll(resp.Body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ======================== 图片编辑 ========================
|
2026-05-24 22:47:43 +08:00
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
// 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")
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
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)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-05-24 13:21:11 +08:00
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
url := strings.TrimRight(imgCfg.BaseURL, "/") + "/images/edits"
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("create request: %w", err)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
2026-05-25 13:01:13 +08:00
|
|
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+imgCfg.APIKey)
|
2026-05-25 12:44:39 +08:00
|
|
|
|
|
2026-05-24 22:47:43 +08:00
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
if err != nil {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return nil, fmt.Errorf("send request: %w", err)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
2026-05-25 12:44:39 +08:00
|
|
|
|
|
2026-05-24 22:47:43 +08:00
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
2026-05-25 13:01:13 +08:00
|
|
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
|
|
|
|
return nil, fmt.Errorf("image edit api error %d: %s", resp.StatusCode, string(b))
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
2026-05-25 12:44:39 +08:00
|
|
|
|
|
2026-05-25 13:01:13 +08:00
|
|
|
|
return parseImageResponse(ctx, resp.Body, imgCfg.Width, imgCfg.Height)
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ======================== 质检 ========================
|
|
|
|
|
|
|
2026-05-24 13:21:11 +08:00
|
|
|
|
var QualityChecker = defaultCheckQuality
|
|
|
|
|
|
|
|
|
|
|
|
func CheckQuality(ctx context.Context, images []GeneratedImage, style map[string]string) (bool, string, error) {
|
|
|
|
|
|
return QualityChecker(ctx, images, style)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func defaultCheckQuality(ctx context.Context, images []GeneratedImage, style map[string]string) (bool, string, error) {
|
|
|
|
|
|
return true, "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewCountedQualityChecker(passOnRetry int) func(context.Context, []GeneratedImage, map[string]string) (bool, string, error) {
|
|
|
|
|
|
var callCount int
|
|
|
|
|
|
return func(_ context.Context, _ []GeneratedImage, _ map[string]string) (bool, string, error) {
|
|
|
|
|
|
callCount++
|
|
|
|
|
|
if callCount >= passOnRetry {
|
|
|
|
|
|
return true, "", nil
|
|
|
|
|
|
}
|
2026-05-25 12:44:39 +08:00
|
|
|
|
return false, fmt.Sprintf("style inconsistent (attempt %d)", callCount), nil
|
2026-05-24 13:21:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func AlwaysFailQualityChecker() func(context.Context, []GeneratedImage, map[string]string) (bool, string, error) {
|
|
|
|
|
|
return func(_ context.Context, _ []GeneratedImage, _ map[string]string) (bool, string, error) {
|
2026-05-25 12:44:39 +08:00
|
|
|
|
return false, "style inconsistent", nil
|
2026-05-24 13:21:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 22:47:43 +08:00
|
|
|
|
// ======================== Mock 回退 ========================
|
|
|
|
|
|
|
|
|
|
|
|
func generateMockImages(size, count int) ([]GeneratedImage, error) {
|
|
|
|
|
|
images := make([]GeneratedImage, count)
|
|
|
|
|
|
for i := 0; i < count; i++ {
|
|
|
|
|
|
data, err := generateMockImage(size, i)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("generate mock image %d: %w", i, err)
|
|
|
|
|
|
}
|
2026-05-25 12:44:39 +08:00
|
|
|
|
images[i] = GeneratedImage{Data: data, Width: size, Height: size, Format: "png"}
|
2026-05-24 22:47:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
return images, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 13:21:11 +08:00
|
|
|
|
func generateMockImage(size int, seed int) ([]byte, error) {
|
|
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
|
|
|
|
|
r := uint8((seed*47 + 13) % 256)
|
|
|
|
|
|
g := uint8((seed*83 + 37) % 256)
|
|
|
|
|
|
b := uint8((seed*61 + 71) % 256)
|
|
|
|
|
|
for y := 0; y < size; y++ {
|
|
|
|
|
|
for x := 0; x < size; x++ {
|
|
|
|
|
|
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return buf.Bytes(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func generateRandomBytes(n int) ([]byte, error) {
|
|
|
|
|
|
b := make([]byte, n)
|
|
|
|
|
|
_, err := rand.Read(b)
|
|
|
|
|
|
return b, err
|
|
|
|
|
|
}
|