// 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 }