Files
ttstd-infinite-canvas/backend/internal/app/provider_audio.go
VicoHu 60bad8cad4 fix(protocol): Seedream 声明式结果 - 裸 b64_json 补全 data URL
声明式解析把 Ark/Seedream 的裸 b64_json 写成 DataURL 时未加 data: 前缀,导致图片任务在上游成功后仍报媒体结果下载失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 18:15:32 +08:00

260 lines
9.4 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 app
// 音频生成。
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"infinite-canvas/backend/internal/model"
)
func runAudioTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if _, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
return runDeclarativeProtocolTask(ctx, input)
}
if resolved, ok := input.Metadata["resolvedCharacterVersions"].([]interface{}); ok && len(resolved) > 0 {
voiceKey := metadataString(input.Metadata, "resolvedCharacterVoiceKey")
if voiceKey == "" || strings.TrimSpace(input.Config.AudioVoice) != voiceKey {
return nil, errors.New("角色配音缺少已解析的声音绑定")
}
}
format := defaultString(input.Config.AudioFormat, "mp3")
body := map[string]interface{}{
"model": input.Config.Model,
"input": input.Prompt,
"voice": defaultString(input.Config.AudioVoice, "alloy"),
"response_format": format,
"speed": 1,
}
if input.Config.AudioSpeed != "" {
body["speed"] = parseFloat(input.Config.AudioSpeed, 1)
}
if input.Config.AudioInstructions != "" {
body["instructions"] = input.Config.AudioInstructions
}
if input.Config.InterfaceType == string(model.ChannelInterfaceAsyncAudio) {
return runAsyncAudioTask(ctx, input, body, format)
}
data, mimeType, err := postBinary(ctx, input.Config, "/audio/speech", body)
if err != nil {
return nil, err
}
mimeType, err = validateGeneratedAudio(mimeType, data, format)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "audio", "audio": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "format": format}}, nil
}
func runAsyncAudioTask(ctx context.Context, input canvasGenerationInput, body map[string]interface{}, format string) (map[string]interface{}, error) {
id := resumedProviderRequestID(ctx)
var state map[string]interface{}
if id == "" {
if err := postJSON(ctx, input.Config, "/audio/tasks", body, &state); err != nil {
return nil, err
}
state = asyncAudioPayload(state)
extracted, err := firstJSONString(state, "id", "task_id", "request_id")
if err != nil {
return nil, fmt.Errorf("异步音频接口任务 ID 无效:%w", err)
}
id = extracted
if id == "" {
return nil, errors.New("异步音频接口没有返回任务 ID")
}
if asyncAudioSucceeded(state) {
return asyncAudioResult(ctx, input.Config, id, state, format)
}
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
state = map[string]interface{}{}
pollCtx := withProviderRequestKind(ctx, "poll")
if err := getJSON(pollCtx, input.Config, "/audio/tasks/"+url.PathEscape(id), &state); err != nil {
return nil, err
}
state = asyncAudioPayload(state)
if asyncAudioSucceeded(state) {
return asyncAudioResult(ctx, input.Config, id, state, format)
}
status := strings.ToLower(strings.TrimSpace(stringField(state, "status")))
if status == "failed" || status == "cancelled" || status == "canceled" || status == "expired" || status == "error" {
return nil, fmt.Errorf("异步音频生成失败(任务 %s):%s", id, asyncAudioErrorMessage(state))
}
if err := sleepContext(ctx, 2500*time.Millisecond); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("异步音频生成超时(任务 %s)", id)
}
func asyncAudioPayload(payload map[string]interface{}) map[string]interface{} {
for _, key := range []string{"data", "result", "output"} {
if nested, ok := payload[key].(map[string]interface{}); ok {
for parentKey, parentValue := range payload {
if parentKey == "data" || parentKey == "result" || parentKey == "output" {
continue
}
if _, exists := nested[parentKey]; !exists {
nested[parentKey] = parentValue
}
}
return nested
}
}
return payload
}
func asyncAudioSucceeded(state map[string]interface{}) bool {
status := strings.ToLower(strings.TrimSpace(stringField(state, "status")))
done, _ := state["done"].(bool)
return done || status == "completed" || status == "succeeded" || status == "success" || status == "done" || (status == "" && asyncAudioResultURL(state) != "")
}
func asyncAudioResult(ctx context.Context, config providerConfig, id string, state map[string]interface{}, format string) (map[string]interface{}, error) {
resultURL := asyncAudioResultURL(state)
var data []byte
var mimeType string
var err error
if strings.HasPrefix(resultURL, "data:") {
mimeType, data, err = decodeProviderDataURL(resultURL)
if err == nil {
limit, limitErr := providerGeneratedFileLimit(ctx)
if limitErr != nil {
err = limitErr
} else if int64(len(data)) > limit {
err = fmt.Errorf("异步音频结果超过 %s 限制", formatStorageLimit(limit))
}
}
} else if isPublicMediaURL(resultURL) {
data, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "download"), resultURL)
} else {
data, mimeType, err = getBinary(withProviderRequestKind(ctx, "download"), config, "/audio/tasks/"+url.PathEscape(id)+"/content")
}
if err != nil {
return nil, fmt.Errorf("异步音频结果下载失败(任务 %s):%w", id, err)
}
mimeType, err = validateGeneratedAudio(mimeType, data, format)
if err != nil {
return nil, fmt.Errorf("异步音频结果无效(任务 %s):%w", id, err)
}
return map[string]interface{}{"mode": "audio", "audio": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "format": format}}, nil
}
func asyncAudioResultURL(state map[string]interface{}) string {
for _, key := range []string{"audio_url", "audioUrl", "result_url", "resultUrl", "output_url", "outputUrl", "url", "data"} {
if value := strings.TrimSpace(stringField(state, key)); strings.HasPrefix(value, "data:") || isPublicMediaURL(value) {
return value
}
}
for _, key := range []string{"audio", "data", "result", "output"} {
if nested, ok := state[key].(map[string]interface{}); ok {
if value := asyncAudioResultURL(nested); value != "" {
return value
}
}
}
return ""
}
func asyncAudioErrorMessage(state map[string]interface{}) string {
_, message := providerFailureDetails(state)
return defaultString(message, firstNonEmptyString(stringField(state, "message"), "上游返回失败状态"))
}
func decodeProviderDataURL(value string) (string, []byte, error) {
header, encoded, ok := strings.Cut(value, ",")
if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(strings.ToLower(header), ";base64") {
return "", nil, errors.New("data URL 格式无效")
}
mimeType := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64")
data, err := base64.StdEncoding.DecodeString(encoded)
return mimeType, data, err
}
func providerGeneratedFileLimit(ctx context.Context) (int64, error) {
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil {
return maxProviderResponseBytes, nil
}
policy, err := metadata.Service.RuntimePolicy()
if err != nil {
return 0, fmt.Errorf("读取生成资源限制失败:%w", err)
}
return megabytes(policy.Resource.GeneratedFileMB), nil
}
func validateGeneratedAudio(declared string, data []byte, format string) (string, error) {
if len(data) == 0 {
return "", errors.New("音频内容为空")
}
detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
if strings.Contains(detected, "json") || strings.HasPrefix(detected, "text/") || strings.HasPrefix(detected, "image/") || strings.HasPrefix(detected, "video/") {
return "", fmt.Errorf("上游返回了非音频内容:%s", detected)
}
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(declared, ";")[0]))
resolved := ""
if strings.HasPrefix(mimeType, "audio/") {
resolved = mimeType
} else if strings.HasPrefix(detected, "audio/") {
resolved = detected
} else if fallback := audioFormatMimeType(format); fallback != "" && (mimeType == "" || mimeType == "application/octet-stream") {
resolved = fallback
}
if resolved == "" {
return "", fmt.Errorf("上游响应类型不是音频:%s", defaultString(mimeType, detected))
}
if !audioSignatureMatches(resolved, data) {
return "", fmt.Errorf("音频内容与格式不匹配:%s", resolved)
}
return resolved, nil
}
func audioSignatureMatches(mimeType string, data []byte) bool {
if strings.Contains(mimeType, "pcm") || mimeType == "audio/l16" {
return len(data) > 0
}
if strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3") {
return bytes.HasPrefix(data, []byte("ID3")) || (len(data) >= 2 && data[0] == 0xff && data[1]&0xe0 == 0xe0)
}
if strings.Contains(mimeType, "wav") || strings.Contains(mimeType, "wave") {
return len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WAVE"))
}
if strings.Contains(mimeType, "opus") || strings.Contains(mimeType, "ogg") {
return bytes.HasPrefix(data, []byte("OggS"))
}
if strings.Contains(mimeType, "flac") {
return bytes.HasPrefix(data, []byte("fLaC"))
}
if strings.Contains(mimeType, "aac") {
return bytes.HasPrefix(data, []byte("ADIF")) || (len(data) >= 2 && data[0] == 0xff && data[1]&0xf0 == 0xf0)
}
return false
}
func audioFormatMimeType(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "wav":
return "audio/wav"
case "opus":
return "audio/opus"
case "aac":
return "audio/aac"
case "flac":
return "audio/flac"
case "pcm":
return "audio/pcm"
case "mp3":
return "audio/mpeg"
default:
return ""
}
}