Files
ttstd-infinite-canvas/backend/internal/service/provider_jimeng.go

307 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}