- 替换中转站从 xbcl.link 到 weda.cc - prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖) - 4 路并发调用 + 原图展示 = 5 张 variant - 前端提示词中译英支持 - 全局 Vue errorHandler - WebSocket 鉴权失败跳登录 - 删除已弃用的 laserCompositor 微服务 Co-Authored-By: Claude <noreply@anthropic.com>
322 lines
10 KiB
Go
322 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/topfans/backend/pkg/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// OpenAIClient OpenAI Images API 客户端
|
|
//
|
|
// 当前仅实现 /v1/images/edits 端点(图生图,镭射卡场景):
|
|
// - 输入:远程图片 URL(由本服务先下载) + 风格 prompt
|
|
// - 输出:PNG 字节流(透明背景,直接可上传 OSS)
|
|
//
|
|
// 不接 /v1/images/generations(文生图),不接 variations(已弃用)。
|
|
// 失败时直接 error,无任何 fallback,由 controller 决定如何处理。
|
|
type OpenAIClient struct {
|
|
BaseURL string // 默认 https://api.openai.com/v1
|
|
APIKey string
|
|
Model string // 默认 gpt-image-1.5
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewOpenAIClient 创建 OpenAI 客户端
|
|
func NewOpenAIClient(baseURL, apiKey, model string) *OpenAIClient {
|
|
if baseURL == "" {
|
|
baseURL = "https://api.openai.com/v1"
|
|
}
|
|
if model == "" {
|
|
model = "gpt-image-1.5"
|
|
}
|
|
return &OpenAIClient{
|
|
BaseURL: baseURL,
|
|
APIKey: apiKey,
|
|
Model: model,
|
|
HTTPClient: &http.Client{
|
|
Timeout: 360 * time.Second, // gpt-image-2 通过中转站时可能达 5min+, 设 6min
|
|
},
|
|
}
|
|
}
|
|
|
|
// OpenAIEditResponse /v1/images/edits 响应结构
|
|
type OpenAIEditResponse struct {
|
|
Created int64 `json:"created"`
|
|
Data []struct {
|
|
B64JSON string `json:"b64_json,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
RevisedPrompt string `json:"revised_prompt,omitempty"`
|
|
} `json:"data"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
// EditImage 调用 OpenAI /v1/images/edits 端点
|
|
//
|
|
// 参数:
|
|
// - imageURL: 远程图片 URL(必须是 GET 可访问的图片,OpenAI 服务端会拉取)
|
|
// - prompt: 风格描述(<= 32000 字符)
|
|
//
|
|
// 返回:解码后的 PNG 字节流;失败返回 error
|
|
func (c *OpenAIClient) EditImage(ctx context.Context, imageURL, prompt string) ([]byte, error) {
|
|
if c.APIKey == "" {
|
|
return nil, fmt.Errorf("OpenAI API key not configured (set OPENAI_API_KEY)")
|
|
}
|
|
if imageURL == "" {
|
|
return nil, fmt.Errorf("imageURL is required for edit endpoint")
|
|
}
|
|
if prompt == "" {
|
|
return nil, fmt.Errorf("prompt is required")
|
|
}
|
|
|
|
// 基底镭射卡模板 + 拼接用户提示词
|
|
// 用户提示词建议传入英文,中文 GPT-image 理解效果略差
|
|
prompt = `Transform the input image into a premium holographic visual artwork.
|
|
|
|
Preserve the original subject identity, pose, and composition.
|
|
|
|
This is a high-end artistic re-imagining of the photograph, not a card, not a poster.
|
|
|
|
Randomly apply combinations of:
|
|
holographic light refraction, prismatic color grading, iridescent reflections,
|
|
laser light dispersion, metallic sheen overlays, glass-like distortion,
|
|
aurora color bleeding, spectral glow enhancement, cinematic post-processing,
|
|
abstract particle integration.
|
|
|
|
Random holographic styles:
|
|
oil-slick sheen, prism refraction, aurora diffusion, laser scattering,
|
|
liquid metal reflection, fractured spectrum, holographic bloom,
|
|
rainbow diffraction, crystal refraction, spectral distortion.
|
|
|
|
Visual direction may vary between:
|
|
luxury editorial photography, experimental fine art, cinematic portrait enhancement,
|
|
museum-grade print, surreal holographic art, avant-garde visual experiment.
|
|
|
|
Background may subtly transform into abstract light fields, cosmic gradients,
|
|
or studio-like luxury atmospheres.
|
|
|
|
Constraints:
|
|
Do not add frames, borders, cards, labels, or typography.
|
|
Do not convert into trading card or poster layout.
|
|
Do not alter subject identity.
|
|
|
|
Result should feel like a luxury holographic fine art photograph.
|
|
Ultra detailed, high-end commercial imaging quality.
|
|
|
|
User direction: ` + prompt
|
|
|
|
// 1. 下载远程图片到 []byte
|
|
imgData, err := downloadRemoteImage(ctx, imageURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download image: %w", err)
|
|
}
|
|
|
|
// 2. 构造 multipart/form-data body
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
|
|
// image[]: PNG 文件
|
|
// ⚠️ 不能用 CreateFormFile —— 它不设置 Content-Type, multipart 默认会给二进制 part
|
|
// 加 application/octet-stream, OpenAI 后端只接受 image/{jpeg,png,webp},会直接 400
|
|
// 改用 CreatePart 手动探测真实 MIME 并显式设置
|
|
contentType := detectImageContentType(imgData, "input.png")
|
|
h := make(textproto.MIMEHeader)
|
|
h.Set("Content-Disposition", `form-data; name="image[]"; filename="input.png"`)
|
|
h.Set("Content-Type", contentType)
|
|
part, err := writer.CreatePart(h)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(imgData); err != nil {
|
|
return nil, fmt.Errorf("write image data: %w", err)
|
|
}
|
|
|
|
// 表单字段
|
|
fields := buildEditFields(c.Model, prompt)
|
|
for k, v := range fields {
|
|
if err := writer.WriteField(k, v); err != nil {
|
|
return nil, fmt.Errorf("write field %s: %w", k, err)
|
|
}
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return nil, fmt.Errorf("close multipart writer: %w", err)
|
|
}
|
|
|
|
// 3. 发送请求
|
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
|
c.BaseURL+"/images/edits", body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openai request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, safePrefixBytes(respBody, 500))
|
|
}
|
|
|
|
// 4. 解析响应
|
|
var edResp OpenAIEditResponse
|
|
if err := json.Unmarshal(respBody, &edResp); err != nil {
|
|
return nil, fmt.Errorf("parse response: %w, body=%s", err, safePrefixBytes(respBody, 500))
|
|
}
|
|
if edResp.Error != nil {
|
|
return nil, fmt.Errorf("openai error: %s - %s", edResp.Error.Code, edResp.Error.Message)
|
|
}
|
|
if len(edResp.Data) == 0 {
|
|
return nil, fmt.Errorf("openai returned no data array")
|
|
}
|
|
|
|
// 5. 解析图片:优先 b64_json,fallback 到 url
|
|
pngBytes, source, err := decodeImageFromResponse(ctx, &edResp.Data[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logger.Logger.Info("OpenAI edit image succeeded",
|
|
zap.String("model", c.Model),
|
|
zap.String("source", source),
|
|
zap.Int("bytes", len(pngBytes)),
|
|
zap.String("revised_prompt", safePrefixBytes([]byte(edResp.Data[0].RevisedPrompt), 80)),
|
|
)
|
|
|
|
return pngBytes, nil
|
|
}
|
|
|
|
// downloadRemoteImage 下载远程图片到 []byte
|
|
// 失败时返回 error,无 fallback
|
|
func downloadRemoteImage(ctx context.Context, imageURL string) ([]byte, error) {
|
|
cli := &http.Client{Timeout: 30 * time.Second}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := cli.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("download HTTP %d (url prefix: %s)", resp.StatusCode, safePrefixStr(imageURL, 60))
|
|
}
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// safePrefixBytes 截断字节用于日志(避免巨大 body 撑爆日志)
|
|
func safePrefixBytes(b []byte, n int) string {
|
|
if len(b) <= n {
|
|
return string(b)
|
|
}
|
|
return string(b[:n]) + "...(truncated)"
|
|
}
|
|
|
|
// safePrefixStr 截断字符串
|
|
func safePrefixStr(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
// buildEditFields 根据 model 名前缀构造 /v1/images/edits 的 multipart 字段集。
|
|
//
|
|
// gpt-image-*: OpenAI 新版,支持 transparent 背景 + 1024x1536 竖版 + 高级 quality
|
|
// 其他(包括 dall-e-* / image-*): 旧版 / 中转站,只接受基础字段,size 限定 square
|
|
func buildEditFields(model, prompt string) map[string]string {
|
|
fields := map[string]string{
|
|
"model": model,
|
|
"prompt": prompt,
|
|
"n": "1",
|
|
"size": "1024x1536",
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// decodeImageFromResponse 从 OpenAI 响应里解出 PNG 字节。
|
|
//
|
|
// 优先 b64_json(OpenAI 官方 / 中转站都常用),fallback 到 url(要再发一次 GET)。
|
|
// 返回 (bytes, source, err) — source 写日志用,标识数据来自 b64_json 还是 url 下载。
|
|
func decodeImageFromResponse(ctx context.Context, item *struct {
|
|
B64JSON string `json:"b64_json,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
RevisedPrompt string `json:"revised_prompt,omitempty"`
|
|
}) ([]byte, string, error) {
|
|
if item.B64JSON != "" {
|
|
pngBytes, err := base64.StdEncoding.DecodeString(item.B64JSON)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("base64 decode: %w", err)
|
|
}
|
|
return pngBytes, "b64_json", nil
|
|
}
|
|
|
|
if item.URL != "" {
|
|
// 走 downloadRemoteImage 拉 URL 字节(dall-e-2 旧版经常返这种)
|
|
// 但 image URL 通常 1h 过期,务必立即下载
|
|
imgData, err := downloadRemoteImage(ctx, item.URL)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("download openai output url: %w", err)
|
|
}
|
|
return imgData, "url", nil
|
|
}
|
|
|
|
return nil, "", fmt.Errorf("openai response item has neither b64_json nor url")
|
|
}
|
|
|
|
// detectImageContentType 探测图片真实 MIME,供 multipart Content-Type 使用。
|
|
//
|
|
// 策略:
|
|
// 1. 先按 magic bytes(http.DetectContentType) —— 不依赖文件名/扩展名,准
|
|
// 2. 检测失败(空文件/未知格式)时, fallback 到文件名扩展名(应该是 input.png → image/png)
|
|
// 3. 兜底 application/octet-stream
|
|
//
|
|
// 返回值限定 OpenAI 接受的 image/{jpeg,png,webp} 集合,其他格式也走 octet-stream 让 OpenAI 直接拒绝
|
|
// (而不是上传个错的格式,后端报个更离谱的错)
|
|
func detectImageContentType(data []byte, filename string) string {
|
|
detected := http.DetectContentType(data) // 返回 "image/png" 等
|
|
switch detected {
|
|
case "image/jpeg", "image/png", "image/webp":
|
|
return detected
|
|
}
|
|
|
|
// 探测不到或不在 OpenAI 白名单 → 用文件名扩展名兜底
|
|
if ext := strings.ToLower(filepath.Ext(filename)); ext != "" {
|
|
if mt := mime.TypeByExtension(ext); mt != "" {
|
|
// mime.TypeByExtension 返回值如 "image/png; charset=utf-8",需要去掉 charset
|
|
if i := strings.Index(mt, ";"); i > 0 {
|
|
mt = strings.TrimSpace(mt[:i])
|
|
}
|
|
switch mt {
|
|
case "image/jpeg", "image/png", "image/webp":
|
|
return mt
|
|
}
|
|
}
|
|
}
|
|
|
|
// 兜底 —— OpenAI 会以 "unsupported mimetype" 拒绝,但请求能正常发出去便于诊断
|
|
return "application/octet-stream"
|
|
}
|