307 lines
9.7 KiB
Go
307 lines
9.7 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/volcengine/volc-sdk-golang/base"
|
||
)
|
||
|
||
const (
|
||
jiMengSubmitAction = "CVSync2AsyncSubmitTask"
|
||
jiMengResultAction = "CVSync2AsyncGetResult"
|
||
jiMengAPIVersion = "2022-08-31"
|
||
)
|
||
|
||
type jiMengResponse struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
RequestID string `json:"request_id"`
|
||
Data struct {
|
||
TaskID string `json:"task_id"`
|
||
Status string `json:"status"`
|
||
BinaryDataBase64 []string `json:"binary_data_base64"`
|
||
ImageURLs []string `json:"image_urls"`
|
||
VideoURL string `json:"video_url"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
func isVolcengineJiMengProtocol(protocol string) bool {
|
||
return protocol == "volcengine-jimeng-image" || protocol == "volcengine-jimeng-video"
|
||
}
|
||
|
||
func runVolcengineJiMengImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||
if input.Mask != nil {
|
||
return nil, errors.New("即梦图片协议不支持蒙版编辑,请移除蒙版后重试")
|
||
}
|
||
body := map[string]interface{}{
|
||
"req_key": input.Config.Model,
|
||
"prompt": withSystemPrompt(input.Config, input.Prompt),
|
||
"force_single": true,
|
||
}
|
||
if width, height := jiMengImageDimensions(input.Config.Size); width > 0 && height > 0 {
|
||
body["width"] = width
|
||
body["height"] = height
|
||
}
|
||
if len(input.ReferenceImages) > 14 {
|
||
return nil, errors.New("即梦图片协议最多支持 14 张参考图")
|
||
}
|
||
if len(input.ReferenceImages) > 0 {
|
||
images := make([]string, 0, len(input.ReferenceImages))
|
||
for _, image := range input.ReferenceImages {
|
||
raw, _, err := mediaBytes(image)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取即梦参考图失败:%w", err)
|
||
}
|
||
images = append(images, base64.StdEncoding.EncodeToString(raw))
|
||
}
|
||
body["binary_data_base64"] = images
|
||
}
|
||
|
||
taskID, err := submitJiMengTask(ctx, input.Config, body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||
result, err := pollJiMengTask(ctx, input.Config, taskID, `{"return_url":true}`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(result.Data.Status)) {
|
||
case "done":
|
||
images, err := jiMengImageDataURLs(ctx, result)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("即梦图片任务 %s 结果读取失败:%w", taskID, err)
|
||
}
|
||
return map[string]interface{}{"mode": "image", "images": images}, nil
|
||
case "not_found", "expired":
|
||
return nil, fmt.Errorf("即梦图片任务 %s 已失效,请重新生成", taskID)
|
||
}
|
||
if err := sleepContext(ctx, 3*time.Second); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("即梦图片生成超时(任务 %s)", taskID)
|
||
}
|
||
|
||
func runVolcengineJiMengVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||
if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
|
||
return nil, errors.New("即梦视频协议只支持一张首帧图,不支持参考视频或音频")
|
||
}
|
||
frames, err := jiMengVideoFrames(input.Config.VideoSeconds)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
body := map[string]interface{}{
|
||
"req_key": input.Config.Model,
|
||
"prompt": strings.TrimSpace(input.Prompt),
|
||
"frames": frames,
|
||
}
|
||
if len(input.ReferenceImages) == 0 {
|
||
body["aspect_ratio"] = jiMengVideoAspectRatio(input.Config.Size)
|
||
} else {
|
||
raw, _, err := mediaBytes(input.ReferenceImages[0])
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取即梦视频首帧图失败:%w", err)
|
||
}
|
||
body["binary_data_base64"] = []string{base64.StdEncoding.EncodeToString(raw)}
|
||
}
|
||
|
||
taskID, err := submitJiMengTask(ctx, input.Config, body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||
result, err := pollJiMengTask(ctx, input.Config, taskID, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(result.Data.Status)) {
|
||
case "done":
|
||
videoURL := strings.TrimSpace(result.Data.VideoURL)
|
||
if videoURL == "" {
|
||
return nil, fmt.Errorf("即梦视频任务 %s 已完成但没有返回视频地址", taskID)
|
||
}
|
||
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), videoURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("即梦视频任务 %s 下载失败:%w", taskID, err)
|
||
}
|
||
mimeType = normalizedMediaMimeType(mimeType, data)
|
||
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
|
||
case "not_found", "expired":
|
||
return nil, fmt.Errorf("即梦视频任务 %s 已失效,请重新生成", taskID)
|
||
}
|
||
if err := sleepContext(ctx, 5*time.Second); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("即梦视频生成超时(任务 %s)", taskID)
|
||
}
|
||
|
||
func submitJiMengTask(ctx context.Context, config providerConfig, body map[string]interface{}) (string, error) {
|
||
if resumed := resumedProviderRequestID(ctx); resumed != "" {
|
||
return resumed, nil
|
||
}
|
||
var payload jiMengResponse
|
||
if err := postJiMengJSON(withProviderRequestKind(ctx, "create"), config, jiMengSubmitAction, body, &payload); err != nil {
|
||
return "", err
|
||
}
|
||
if err := validateJiMengResponse(payload); err != nil {
|
||
return "", err
|
||
}
|
||
taskID := strings.TrimSpace(payload.Data.TaskID)
|
||
if taskID == "" {
|
||
return "", errors.New("即梦接口没有返回任务 ID")
|
||
}
|
||
return taskID, nil
|
||
}
|
||
|
||
func pollJiMengTask(ctx context.Context, config providerConfig, taskID string, reqJSON string) (jiMengResponse, error) {
|
||
body := map[string]interface{}{"req_key": config.Model, "task_id": taskID}
|
||
if reqJSON != "" {
|
||
body["req_json"] = reqJSON
|
||
}
|
||
var payload jiMengResponse
|
||
if err := postJiMengJSON(withProviderRequestKind(ctx, "poll"), config, jiMengResultAction, body, &payload); err != nil {
|
||
return payload, err
|
||
}
|
||
return payload, validateJiMengResponse(payload)
|
||
}
|
||
|
||
func postJiMengJSON(ctx context.Context, config providerConfig, action string, body interface{}, target interface{}) error {
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
endpoint, err := url.Parse(strings.TrimSpace(config.BaseURL))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
endpoint.Path = "/"
|
||
query := endpoint.Query()
|
||
query.Set("Action", action)
|
||
query.Set("Version", jiMengAPIVersion)
|
||
endpoint.RawQuery = query.Encode()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(data))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
ApplyDefaultOutboundHeaders(req)
|
||
credentials := base.Credentials{
|
||
AccessKeyID: strings.TrimSpace(config.APIKey),
|
||
SecretAccessKey: strings.TrimSpace(config.SecretKey),
|
||
Region: "cn-north-1",
|
||
Service: "cv",
|
||
}
|
||
return doJSON(credentials.Sign(req), target)
|
||
}
|
||
|
||
func validateJiMengResponse(payload jiMengResponse) error {
|
||
if payload.Code == 10000 {
|
||
return nil
|
||
}
|
||
message := strings.TrimSpace(payload.Message)
|
||
if message == "" {
|
||
message = "未知错误"
|
||
}
|
||
if payload.RequestID != "" {
|
||
return fmt.Errorf("即梦接口返回错误 %d:%s(request_id: %s)", payload.Code, message, payload.RequestID)
|
||
}
|
||
return fmt.Errorf("即梦接口返回错误 %d:%s", payload.Code, message)
|
||
}
|
||
|
||
func jiMengImageDataURLs(ctx context.Context, payload jiMengResponse) ([]string, error) {
|
||
images := make([]string, 0, len(payload.Data.ImageURLs)+len(payload.Data.BinaryDataBase64))
|
||
for _, rawURL := range payload.Data.ImageURLs {
|
||
data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
images = append(images, dataURL(normalizedMediaMimeType(mimeType, data), data))
|
||
}
|
||
for _, encoded := range payload.Data.BinaryDataBase64 {
|
||
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
images = append(images, dataURL(normalizedMediaMimeType("image/png", data), data))
|
||
}
|
||
if len(images) == 0 {
|
||
return nil, errors.New("任务已完成但没有返回图片")
|
||
}
|
||
return images, nil
|
||
}
|
||
|
||
func jiMengImageDimensions(value string) (int, int) {
|
||
parts := strings.Split(strings.ToLower(normalizePixelSize(value)), "x")
|
||
if len(parts) != 2 {
|
||
return 0, 0
|
||
}
|
||
width, widthErr := strconv.Atoi(parts[0])
|
||
height, heightErr := strconv.Atoi(parts[1])
|
||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
|
||
return 0, 0
|
||
}
|
||
area := int64(width) * int64(height)
|
||
if area < 1024*1024 || area > 4096*4096 {
|
||
return 0, 0
|
||
}
|
||
return width, height
|
||
}
|
||
|
||
func jiMengVideoFrames(value string) (int, error) {
|
||
seconds, _ := strconv.Atoi(strings.TrimSpace(value))
|
||
switch seconds {
|
||
case 5:
|
||
return 121, nil
|
||
case 10:
|
||
return 241, nil
|
||
default:
|
||
return 0, errors.New("即梦视频仅支持 5 秒或 10 秒,请调整视频时长")
|
||
}
|
||
}
|
||
|
||
func jiMengVideoAspectRatio(value string) string {
|
||
allowed := []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9"}
|
||
value = strings.TrimSpace(value)
|
||
for _, candidate := range allowed {
|
||
if value == candidate {
|
||
return candidate
|
||
}
|
||
}
|
||
parts := strings.Split(strings.ToLower(value), "x")
|
||
if len(parts) != 2 {
|
||
return "16:9"
|
||
}
|
||
width, widthErr := strconv.ParseFloat(parts[0], 64)
|
||
height, heightErr := strconv.ParseFloat(parts[1], 64)
|
||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
|
||
return "16:9"
|
||
}
|
||
ratio := width / height
|
||
best := allowed[0]
|
||
bestDistance := math.MaxFloat64
|
||
for _, candidate := range allowed {
|
||
parts := strings.Split(candidate, ":")
|
||
left, _ := strconv.ParseFloat(parts[0], 64)
|
||
right, _ := strconv.ParseFloat(parts[1], 64)
|
||
if distance := math.Abs(ratio - left/right); distance < bestDistance {
|
||
best = candidate
|
||
bestDistance = distance
|
||
}
|
||
}
|
||
return best
|
||
}
|