529 lines
20 KiB
Go
529 lines
20 KiB
Go
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" }
|