feat(seedance): 接入真人资产管理与引用
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
var portraitAssetPlaceholderPattern = regexp.MustCompile(`(?i)<<<[[:space:]]*portrait[_-]?asset_([0-9]+)[[:space:]]*>>>|@portrait_asset([0-9]+)|@人像资产([0-9]+)`)
|
||||
|
||||
type PortraitAssetCapability struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CanUse bool `json:"canUse"`
|
||||
CanCreate bool `json:"canCreate"`
|
||||
CanUseAsPortraitAsset bool `json:"canUseAsPortraitAsset"`
|
||||
CanUseAsPlainMaterial bool `json:"canUseAsPlainMaterial"`
|
||||
AvailablePlatformIDs []string `json:"availablePlatformIds"`
|
||||
CreationPlatformIDs []string `json:"creationPlatformIds"`
|
||||
CanReferenceTencentAsset bool `json:"canReferenceTencentAssetUri"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type PortraitAssetCreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
SourceType string
|
||||
URL string
|
||||
Preview string
|
||||
MimeType string
|
||||
ByteSize int64
|
||||
SourceSHA256 string
|
||||
PrivateAvatarEligible bool
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type PortraitAssetSyncResponse struct {
|
||||
Requested int `json:"requested"`
|
||||
Accepted int `json:"accepted"`
|
||||
SyncedIDs []string `json:"syncedIds"`
|
||||
Skipped []PortraitAssetIssue `json:"skipped"`
|
||||
Failed []PortraitAssetIssue `json:"failed"`
|
||||
Assets []store.PortraitAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type PortraitAssetIssue struct {
|
||||
ID string `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type portraitAssetPlatformSettings struct {
|
||||
ProjectName string
|
||||
AssetGroupID string
|
||||
Credentials clients.VolcesAssetCredentials
|
||||
}
|
||||
|
||||
func (s *Service) PortraitAssetCapability(ctx context.Context) (PortraitAssetCapability, error) {
|
||||
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
|
||||
if err != nil {
|
||||
return PortraitAssetCapability{}, err
|
||||
}
|
||||
ids := make([]string, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
if _, ok := portraitAssetSettings(platform); ok {
|
||||
ids = append(ids, platform.PlatformID)
|
||||
}
|
||||
}
|
||||
capability := PortraitAssetCapability{
|
||||
Enabled: len(ids) > 0,
|
||||
CanUse: len(ids) > 0,
|
||||
CanCreate: len(ids) > 0,
|
||||
CanUseAsPortraitAsset: len(ids) > 0,
|
||||
CanUseAsPlainMaterial: true,
|
||||
AvailablePlatformIDs: ids,
|
||||
CreationPlatformIDs: ids,
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
capability.Reason = "未配置可用的火山 Seedance 人像资产平台;请在 Volces 平台 config.seedancePrivateAsset 中配置 enabled、accessKey、secretKey、projectName、assetGroupId。"
|
||||
}
|
||||
return capability, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePortraitAsset(ctx context.Context, user *auth.User, input PortraitAssetCreateInput) (store.PortraitAsset, bool, error) {
|
||||
if s.store == nil {
|
||||
return store.PortraitAsset{}, false, fmt.Errorf("portrait asset store is unavailable")
|
||||
}
|
||||
if !validPortraitAssetSourceType(input.SourceType) {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_unsupported_type", Message: "source type must be image, video, or audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if strings.TrimSpace(input.URL) == "" {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_source_url_required", Message: "portrait asset source URL is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !input.PrivateAvatarEligible {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "private_avatar_eligible must be true after the user confirms authorization", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if existing, found, err := s.store.FindPortraitAssetBySourceHash(ctx, user, input.SourceSHA256); err != nil {
|
||||
return store.PortraitAsset{}, false, err
|
||||
} else if found {
|
||||
return existing, true, nil
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
if user == nil || userID == "" {
|
||||
return store.PortraitAsset{}, false, store.ErrLocalUserRequired
|
||||
}
|
||||
asset, err := s.store.CreatePortraitAsset(ctx, store.PortraitAssetInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
UserID: userID,
|
||||
GatewayTenantID: strings.TrimSpace(user.GatewayTenantID),
|
||||
TenantID: strings.TrimSpace(user.TenantID),
|
||||
TenantKey: strings.TrimSpace(user.TenantKey),
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
SourceType: strings.ToLower(strings.TrimSpace(input.SourceType)),
|
||||
URL: strings.TrimSpace(input.URL),
|
||||
Preview: firstNonEmptyString(strings.TrimSpace(input.Preview), strings.TrimSpace(input.URL)),
|
||||
MimeType: strings.TrimSpace(input.MimeType),
|
||||
ByteSize: input.ByteSize,
|
||||
SourceSHA256: strings.TrimSpace(input.SourceSHA256),
|
||||
PrivateAvatarEligible: input.PrivateAvatarEligible,
|
||||
Metadata: input.Metadata,
|
||||
})
|
||||
return asset, false, err
|
||||
}
|
||||
|
||||
func (s *Service) SyncPortraitAssets(ctx context.Context, user *auth.User, ids []string) (PortraitAssetSyncResponse, error) {
|
||||
response := PortraitAssetSyncResponse{
|
||||
Requested: len(ids), SyncedIDs: make([]string, 0), Skipped: make([]PortraitAssetIssue, 0), Failed: make([]PortraitAssetIssue, 0), Assets: make([]store.PortraitAsset, 0),
|
||||
}
|
||||
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
configured := make([]store.PortraitAssetPlatform, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
if _, ok := portraitAssetSettings(platform); ok {
|
||||
configured = append(configured, platform)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, value := range ids {
|
||||
assetID := strings.TrimSpace(value)
|
||||
if assetID == "" || seen[assetID] {
|
||||
continue
|
||||
}
|
||||
seen[assetID] = true
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if !found {
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: assetID, Reason: "portrait asset not found"})
|
||||
continue
|
||||
}
|
||||
if !asset.PrivateAvatarEligible {
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: "portrait asset authorization is required"})
|
||||
continue
|
||||
}
|
||||
if len(configured) == 0 {
|
||||
_ = s.store.UpdatePortraitAssetStatus(ctx, asset.ID, "not_configured", "no configured Volces portrait asset platform")
|
||||
asset.Status = "not_configured"
|
||||
asset.LastError = "no configured Volces portrait asset platform"
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: asset.LastError})
|
||||
response.Assets = append(response.Assets, asset)
|
||||
continue
|
||||
}
|
||||
|
||||
response.Accepted++
|
||||
assetFailed := false
|
||||
for _, platform := range configured {
|
||||
if err := s.syncPortraitAssetToPlatform(ctx, asset, platform); err != nil {
|
||||
assetFailed = true
|
||||
response.Failed = append(response.Failed, PortraitAssetIssue{ID: asset.ID, Reason: platform.PlatformID + ": " + err.Error()})
|
||||
}
|
||||
}
|
||||
updated, _, err := s.refreshPortraitAssetStatus(ctx, user, asset.ID)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Assets = append(response.Assets, updated)
|
||||
if !assetFailed {
|
||||
response.SyncedIDs = append(response.SyncedIDs, updated.ID)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncPortraitAssetToPlatform(ctx context.Context, asset store.PortraitAsset, platform store.PortraitAssetPlatform) error {
|
||||
settings, ok := portraitAssetSettings(platform)
|
||||
if !ok {
|
||||
return &clients.ClientError{Code: "portrait_asset_not_configured", Message: "platform portrait asset configuration is incomplete", Retryable: false}
|
||||
}
|
||||
binding, found, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, platform.PlatformID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
binding = store.PortraitAssetBinding{AssetID: asset.ID, PlatformID: platform.PlatformID, ProjectName: settings.ProjectName, AssetGroupID: settings.AssetGroupID, Status: "pending"}
|
||||
}
|
||||
if !portraitAssetHasPublicURL(asset.URL) {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{
|
||||
Code: "portrait_asset_public_url_required",
|
||||
Message: "portrait asset URL must be an absolute http(s) URL reachable by Volces",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
})
|
||||
}
|
||||
client := clients.VolcesAssetClient{HTTPClient: s.portraitAssetHTTPClient()}
|
||||
remoteID := strings.TrimSpace(binding.RemoteAssetID)
|
||||
if remoteID == "" {
|
||||
created, _, createErr := client.CreateAsset(ctx, settings.Credentials, map[string]any{
|
||||
"GroupId": settings.AssetGroupID, "URL": asset.URL, "Name": asset.Name,
|
||||
"AssetType": volcesPortraitAssetType(asset.SourceType), "ProjectName": settings.ProjectName,
|
||||
})
|
||||
if createErr != nil {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, createErr)
|
||||
}
|
||||
remoteID = strings.TrimSpace(created.ID)
|
||||
if remoteID == "" {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{Code: "invalid_response", Message: "volces CreateAsset returned no asset id", Retryable: false})
|
||||
}
|
||||
binding.RemoteAssetID = remoteID
|
||||
}
|
||||
remote, _, getErr := client.GetAsset(ctx, settings.Credentials, map[string]any{"Id": remoteID, "ProjectName": settings.ProjectName})
|
||||
if getErr != nil {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, getErr)
|
||||
}
|
||||
binding.ProjectName = settings.ProjectName
|
||||
binding.AssetGroupID = settings.AssetGroupID
|
||||
binding.RemoteAssetID = firstNonEmptyString(remote.ID, remoteID)
|
||||
binding.RemoteAssetURI = "asset://" + binding.RemoteAssetID
|
||||
binding.Status = portraitAssetBindingStatus(remote.Status)
|
||||
binding.LastErrorCode = strings.TrimSpace(stringFromMap(remote.Error, "Code"))
|
||||
binding.LastErrorMessage = strings.TrimSpace(stringFromMap(remote.Error, "Message"))
|
||||
if binding.Status == "failed" && binding.LastErrorMessage == "" {
|
||||
binding.LastErrorMessage = "volces portrait asset processing failed"
|
||||
}
|
||||
_, err = s.store.UpsertPortraitAssetBinding(ctx, binding)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) recordPortraitAssetBindingFailure(ctx context.Context, binding store.PortraitAssetBinding, settings portraitAssetPlatformSettings, cause error) error {
|
||||
binding.ProjectName = settings.ProjectName
|
||||
binding.AssetGroupID = settings.AssetGroupID
|
||||
binding.Status = "failed"
|
||||
binding.LastErrorCode = clients.ErrorCode(cause)
|
||||
binding.LastErrorMessage = cause.Error()
|
||||
_, err := s.store.UpsertPortraitAssetBinding(ctx, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (s *Service) refreshPortraitAssetStatus(ctx context.Context, user *auth.User, assetID string) (store.PortraitAsset, bool, error) {
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil || !found {
|
||||
return asset, found, err
|
||||
}
|
||||
active, total, lastError, _, err := s.store.PortraitAssetBindingSummary(ctx, asset.ID)
|
||||
if err != nil {
|
||||
return asset, true, err
|
||||
}
|
||||
status := "not_synced"
|
||||
if total == 0 {
|
||||
status = "not_synced"
|
||||
} else if active > 0 {
|
||||
status = "active"
|
||||
if active < total {
|
||||
status = "partial"
|
||||
}
|
||||
} else if lastError != "" {
|
||||
status = "failed"
|
||||
} else {
|
||||
status = "pending"
|
||||
}
|
||||
if err := s.store.UpdatePortraitAssetStatus(ctx, asset.ID, status, lastError); err != nil {
|
||||
return asset, true, err
|
||||
}
|
||||
asset.Status = status
|
||||
asset.LastError = lastError
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) compilePortraitAssetReferences(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) (map[string]any, error) {
|
||||
entries := portraitAssetList(body["portrait_asset_list"])
|
||||
if len(entries) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
if kind != "videos.generations" || !isVolcesPortraitAssetCandidate(candidate) {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "portrait assets require a configured Volces Seedance omni video model", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !candidateSupportsPortraitAssets(candidate) {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "selected model does not enable supports_portrait_asset_reference", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
|
||||
out := cloneMap(body)
|
||||
content := contentItems(out["content"])
|
||||
labels := make([]string, len(entries))
|
||||
nonAudioAssets := 0
|
||||
for index, entry := range entries {
|
||||
assetID := firstNonEmptyString(stringFromMap(entry, "id"), stringFromMap(entry, "easyai_portrait_asset_id"))
|
||||
if assetID == "" {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_id_required", Message: fmt.Sprintf("portrait_asset_list[%d].id is required", index), StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_not_found", Message: "portrait asset not found", StatusCode: http.StatusNotFound, Retryable: false}
|
||||
}
|
||||
if !asset.PrivateAvatarEligible {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "portrait asset authorization is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
binding, bound, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, candidate.PlatformID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bound || binding.Status != "active" || strings.TrimSpace(binding.RemoteAssetURI) == "" {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_processing", Message: "portrait asset is not active for the selected Volces platform; sync it and retry", StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
labels[index] = firstNonEmptyString(strings.TrimSpace(stringFromMap(entry, "name")), asset.Name, "portrait asset "+fmt.Sprint(index+1))
|
||||
if asset.SourceType != "audio" {
|
||||
nonAudioAssets++
|
||||
}
|
||||
content = append(content, portraitAssetContent(asset.SourceType, binding.RemoteAssetURI))
|
||||
}
|
||||
if nonAudioAssets == 0 {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_audio_only", Message: "portrait_asset_list cannot contain audio-only assets", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
for index := range content {
|
||||
if strings.ToLower(strings.TrimSpace(stringFromAny(content[index]["type"]))) != "text" {
|
||||
continue
|
||||
}
|
||||
content[index]["text"] = replacePortraitAssetPlaceholders(stringFromAny(content[index]["text"]), labels)
|
||||
}
|
||||
out["content"] = mapsToAnySlice(content)
|
||||
delete(out, "portrait_asset_list")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) portraitAssetHTTPClient() *http.Client {
|
||||
if s.httpClients != nil && s.httpClients.none != nil {
|
||||
return s.httpClients.none
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func portraitAssetSettings(platform store.PortraitAssetPlatform) (portraitAssetPlatformSettings, bool) {
|
||||
config := portraitAssetNestedConfig(platform.Config)
|
||||
accessKey := firstNonEmptyString(portraitAssetValue(config, "accessKey", "access_key"), portraitAssetValue(platform.Credentials, "accessKey", "access_key"))
|
||||
secretKey := firstNonEmptyString(portraitAssetValue(config, "secretKey", "secret_key"), portraitAssetValue(platform.Credentials, "secretKey", "secret_key"))
|
||||
projectName := firstNonEmptyString(portraitAssetValue(config, "projectName", "project_name"), "default")
|
||||
assetGroupID := portraitAssetValue(config, "assetGroupId", "asset_group_id")
|
||||
endpoint := firstNonEmptyString(portraitAssetValue(config, "assetEndpoint", "asset_endpoint", "volcesAssetEndpoint", "volces_asset_endpoint"), clientsVolcesAssetDefaultEndpoint())
|
||||
if accessKey == "" || secretKey == "" || projectName == "" || assetGroupID == "" {
|
||||
return portraitAssetPlatformSettings{}, false
|
||||
}
|
||||
if enabled, present := portraitAssetBool(config, "enabled"); present && !enabled {
|
||||
return portraitAssetPlatformSettings{}, false
|
||||
}
|
||||
return portraitAssetPlatformSettings{ProjectName: projectName, AssetGroupID: assetGroupID, Credentials: clients.VolcesAssetCredentials{AccessKey: accessKey, SecretKey: secretKey, Endpoint: endpoint}}, true
|
||||
}
|
||||
|
||||
func portraitAssetNestedConfig(config map[string]any) map[string]any {
|
||||
for _, key := range []string{"seedancePrivateAsset", "seedance_private_asset", "portraitAsset", "portrait_asset"} {
|
||||
if nested, ok := config[key].(map[string]any); ok {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func portraitAssetValue(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(stringFromAny(values[key])); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func portraitAssetBool(values map[string]any, key string) (bool, bool) {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(typed), "true"), true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func validPortraitAssetSourceType(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "image", "video", "audio":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetHasPublicURL(value string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")
|
||||
}
|
||||
|
||||
func volcesPortraitAssetType(sourceType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "video":
|
||||
return "Video"
|
||||
case "audio":
|
||||
return "Audio"
|
||||
default:
|
||||
return "Image"
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetBindingStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "active", "succeeded", "success":
|
||||
return "active"
|
||||
case "failed", "error":
|
||||
return "failed"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetList(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if object, ok := item.(map[string]any); ok {
|
||||
out = append(out, object)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
return typed
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetContent(sourceType string, assetURI string) map[string]any {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "video":
|
||||
return map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": assetURI}}
|
||||
case "audio":
|
||||
return map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": assetURI}}
|
||||
default:
|
||||
return map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": assetURI}}
|
||||
}
|
||||
}
|
||||
|
||||
func replacePortraitAssetPlaceholders(value string, labels []string) string {
|
||||
return portraitAssetPlaceholderPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := portraitAssetPlaceholderPattern.FindStringSubmatch(match)
|
||||
for index := 1; index < len(parts); index++ {
|
||||
if parts[index] == "" {
|
||||
continue
|
||||
}
|
||||
position := int(parts[index][0] - '0')
|
||||
if len(parts[index]) > 1 {
|
||||
position = 0
|
||||
for _, r := range parts[index] {
|
||||
position = position*10 + int(r-'0')
|
||||
}
|
||||
}
|
||||
if position > 0 && position <= len(labels) && strings.TrimSpace(labels[position-1]) != "" {
|
||||
return labels[position-1]
|
||||
}
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func isVolcesPortraitAssetCandidate(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
|
||||
return provider == "volces" || provider == "volces-openai"
|
||||
}
|
||||
|
||||
func candidateSupportsPortraitAssets(candidate store.RuntimeModelCandidate) bool {
|
||||
capabilities := effectiveModelCapability(candidate)
|
||||
for _, key := range []string{candidate.ModelType, "omni_video", "omni", "video_generate"} {
|
||||
if capability, ok := capabilities[key].(map[string]any); ok {
|
||||
if enabled, present := portraitAssetBool(capability, "supports_portrait_asset_reference"); present {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func portraitAssetUserKeys(user *auth.User) (string, string) {
|
||||
if user == nil {
|
||||
return "", ""
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(user.GatewayUserID)
|
||||
if gatewayUserID == "" && user.Source == "gateway" {
|
||||
gatewayUserID = strings.TrimSpace(user.ID)
|
||||
}
|
||||
return gatewayUserID, strings.TrimSpace(user.ID)
|
||||
}
|
||||
|
||||
func portraitAssetSHA256(payload []byte) string {
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func clientsVolcesAssetDefaultEndpoint() string { return "https://ark.cn-beijing.volcengineapi.com" }
|
||||
Reference in New Issue
Block a user