修复了一点bug,然后增加了图层拆分更多的功能
This commit is contained in:
1 parent
1305a9cdd8
commit
ce8372e878
31 files changed
+2567
-391
No files matched your search
@@ -0,0 +1,181 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package layercompositions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
|
||||
)
|
||||
|
||||
type compositionRepositoryStub struct {
|
||||
value Composition
|
||||
found bool
|
||||
}
|
||||
|
||||
func (repository *compositionRepositoryStub) FindLayerComposition(_ context.Context, ownerID, jobID string) (Composition, bool, error) {
|
||||
if !repository.found || repository.value.OwnerID != ownerID || repository.value.JobID != jobID {
|
||||
return Composition{}, false, nil
|
||||
}
|
||||
return repository.value, true, nil
|
||||
}
|
||||
|
||||
func (repository *compositionRepositoryStub) SaveLayerComposition(_ context.Context, value Composition, expectedVersion int64) (Composition, error) {
|
||||
if repository.found && repository.value.Version != expectedVersion {
|
||||
return Composition{}, ErrConflict
|
||||
}
|
||||
if !repository.found && expectedVersion != 0 {
|
||||
return Composition{}, ErrConflict
|
||||
}
|
||||
now := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
|
||||
value.Version = expectedVersion + 1
|
||||
value.CreatedAt = now
|
||||
value.UpdatedAt = now
|
||||
repository.value = value
|
||||
repository.found = true
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type assetCatalogStub map[string]assets.Asset
|
||||
|
||||
func (catalog assetCatalogStub) GetOwner(_ context.Context, ownerID, assetID string) (assets.Asset, bool, error) {
|
||||
asset, found := catalog[assetID]
|
||||
return asset, found && asset.OwnerID == ownerID, nil
|
||||
}
|
||||
|
||||
func TestServiceSavesOwnedCompositionAndNormalizesRotation(t *testing.T) {
|
||||
repository := &compositionRepositoryStub{}
|
||||
catalog := assetCatalogStub{
|
||||
"base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage},
|
||||
"layer": {ID: "layer", OwnerID: "owner", Kind: assets.KindImage},
|
||||
}
|
||||
service := NewService(repository, catalog)
|
||||
|
||||
saved, err := service.Save(context.Background(), "owner", "job-1", 0, Composition{
|
||||
BaseAssetID: "base", BaseVisible: true,
|
||||
Layers: []Layer{{ID: "layer-1", AssetID: "layer", Name: "主体", Visible: true, Box: Box{100, 100, 900, 900}, Rotation: 450, Source: "upload"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Version != 1 || saved.OwnerID != "owner" || saved.JobID != "job-1" || saved.Layers[0].Rotation != 90 {
|
||||
t.Fatalf("saved=%#v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsForeignOrMalformedLayerAssets(t *testing.T) {
|
||||
service := NewService(&compositionRepositoryStub{}, assetCatalogStub{
|
||||
"base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage},
|
||||
"foreign": {ID: "foreign", OwnerID: "another-owner", Kind: assets.KindImage},
|
||||
})
|
||||
_, err := service.Save(context.Background(), "owner", "job-1", 0, Composition{
|
||||
BaseAssetID: "base", BaseVisible: true,
|
||||
Layers: []Layer{{ID: "layer-1", AssetID: "foreign", Name: "主体", Visible: true, Box: Box{100, 100, 900, 900}, Source: "library"}},
|
||||
})
|
||||
if !errors.Is(err, ErrAssetNotFound) {
|
||||
t.Fatalf("foreign asset err=%v", err)
|
||||
}
|
||||
|
||||
_, err = service.Save(context.Background(), "owner", "job-1", 0, Composition{
|
||||
BaseAssetID: "base", BaseVisible: true,
|
||||
Layers: []Layer{{ID: "layer-1", AssetID: "base", Name: "主体", Visible: true, Box: Box{900, 100, 100, 900}, Source: "provider"}},
|
||||
})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid box err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUsesOptimisticVersion(t *testing.T) {
|
||||
repository := &compositionRepositoryStub{found: true, value: Composition{JobID: "job-1", OwnerID: "owner", Version: 3}}
|
||||
service := NewService(repository, assetCatalogStub{"base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage}})
|
||||
_, err := service.Save(context.Background(), "owner", "job-1", 2, Composition{BaseAssetID: "base", BaseVisible: true})
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user