Files

182 lines
5.8 KiB
Go

// Package layercompositions owns durable Seedream layer-editor documents.
package layercompositions
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
)
var (
ErrConflict = errors.New("layer composition version conflict")
ErrInvalid = errors.New("invalid layer composition")
ErrAssetNotFound = errors.New("layer composition asset not found")
)
type Box [4]float64
type Layer struct {
ID string `json:"id"`
AssetID string `json:"assetId"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Visible bool `json:"visible"`
Box Box `json:"box"`
Rotation float64 `json:"rotation"`
Source string `json:"source"`
EditJobID string `json:"editJobId,omitempty"`
OriginalAssetID string `json:"originalAssetId,omitempty"`
}
type Composition struct {
JobID string `json:"jobId"`
OwnerID string `json:"-"`
BaseAssetID string `json:"baseAssetId"`
BaseVisible bool `json:"baseVisible"`
Layers []Layer `json:"layers"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Repository interface {
FindLayerComposition(context.Context, string, string) (Composition, bool, error)
SaveLayerComposition(context.Context, Composition, int64) (Composition, error)
}
type AssetCatalog interface {
GetOwner(context.Context, string, string) (assets.Asset, bool, error)
}
type Service struct {
repository Repository
assets AssetCatalog
}
func NewService(repository Repository, catalog AssetCatalog) *Service {
return &Service{repository: repository, assets: catalog}
}
func (service *Service) Get(ctx context.Context, ownerID, jobID string) (Composition, bool, error) {
if strings.TrimSpace(ownerID) == "" || strings.TrimSpace(jobID) == "" {
return Composition{}, false, fmt.Errorf("%w: ownerId and jobId are required", ErrInvalid)
}
return service.repository.FindLayerComposition(ctx, ownerID, jobID)
}
func (service *Service) Save(ctx context.Context, ownerID, jobID string, expectedVersion int64, document Composition) (Composition, error) {
document.OwnerID = strings.TrimSpace(ownerID)
document.JobID = strings.TrimSpace(jobID)
document.Version = 0
if expectedVersion < 0 {
return Composition{}, fmt.Errorf("%w: version must not be negative", ErrInvalid)
}
if err := normalizeAndValidate(&document); err != nil {
return Composition{}, err
}
assetIDs := []string{document.BaseAssetID}
for _, layer := range document.Layers {
assetIDs = append(assetIDs, layer.AssetID)
if layer.OriginalAssetID != "" {
assetIDs = append(assetIDs, layer.OriginalAssetID)
}
}
seen := map[string]bool{}
for _, assetID := range assetIDs {
if seen[assetID] {
continue
}
seen[assetID] = true
asset, found, err := service.assets.GetOwner(ctx, document.OwnerID, assetID)
if err != nil {
return Composition{}, fmt.Errorf("load layer asset: %w", err)
}
if !found || (asset.Kind != assets.KindImage && asset.Kind != assets.KindReference && asset.Kind != assets.KindMask) {
return Composition{}, fmt.Errorf("%w: %s", ErrAssetNotFound, assetID)
}
}
return service.repository.SaveLayerComposition(ctx, document, expectedVersion)
}
func normalizeAndValidate(document *Composition) error {
if document.OwnerID == "" || document.JobID == "" || strings.TrimSpace(document.BaseAssetID) == "" {
return fmt.Errorf("%w: ownerId, jobId, and baseAssetId are required", ErrInvalid)
}
document.BaseAssetID = strings.TrimSpace(document.BaseAssetID)
if len(document.Layers) > 100 {
return fmt.Errorf("%w: at most 100 layers are supported", ErrInvalid)
}
if document.Layers == nil {
document.Layers = []Layer{}
}
ids := map[string]bool{}
for index := range document.Layers {
layer := &document.Layers[index]
layer.ID = strings.TrimSpace(layer.ID)
layer.AssetID = strings.TrimSpace(layer.AssetID)
layer.Name = strings.TrimSpace(layer.Name)
layer.Description = strings.TrimSpace(layer.Description)
layer.Source = strings.TrimSpace(layer.Source)
layer.EditJobID = strings.TrimSpace(layer.EditJobID)
layer.OriginalAssetID = strings.TrimSpace(layer.OriginalAssetID)
if layer.ID == "" || layer.AssetID == "" || layer.Name == "" {
return fmt.Errorf("%w: every layer requires id, assetId, and name", ErrInvalid)
}
if ids[layer.ID] {
return fmt.Errorf("%w: duplicate layer id %s", ErrInvalid, layer.ID)
}
ids[layer.ID] = true
if len([]rune(layer.Name)) > 120 || len([]rune(layer.Description)) > 500 {
return fmt.Errorf("%w: layer text is too long", ErrInvalid)
}
if !validLayerSource(layer.Source) {
return fmt.Errorf("%w: unsupported layer source %s", ErrInvalid, layer.Source)
}
if err := validateBox(layer.Box); err != nil {
return fmt.Errorf("%w: layer %s: %v", ErrInvalid, layer.ID, err)
}
if math.IsNaN(layer.Rotation) || math.IsInf(layer.Rotation, 0) {
return fmt.Errorf("%w: layer rotation must be finite", ErrInvalid)
}
layer.Rotation = normalizeRotation(layer.Rotation)
}
return nil
}
func validLayerSource(value string) bool {
switch value {
case "provider", "upload", "library", "seedream-edit":
return true
default:
return false
}
}
func validateBox(box Box) error {
for _, value := range box {
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 1000 {
return errors.New("box coordinates must be finite values between 0 and 1000")
}
}
if box[2]-box[0] < 1 || box[3]-box[1] < 1 {
return errors.New("box must have a positive width and height")
}
return nil
}
func normalizeRotation(value float64) float64 {
value = math.Mod(value, 360)
if value > 180 {
value -= 360
}
if value <= -180 {
value += 360
}
return math.Round(value*1000) / 1000
}