From ce8372e878e750a984c40a7744ea6dd39324ba12 Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 2 Sep 2026 16:06:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E7=82=B9bu?= =?UTF-8?q?g=EF=BC=8C=E7=84=B6=E5=90=8E=E5=A2=9E=E5=8A=A0=E4=BA=86?= =?UTF-8?q?=E5=9B=BE=E5=B1=82=E6=8B=86=E5=88=86=E6=9B=B4=E5=A4=9A=E7=9A=84?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- app/globals.css | 454 ++++++- backend/internal/application/application.go | 11 + .../internal/httpapi/layer_compositions.go | 124 ++ .../httpapi/layer_compositions_test.go | 94 ++ .../internal/httpapi/method_compat_test.go | 4 +- backend/internal/httpapi/route_surface.go | 2 +- .../layercompositions/compositions.go | 181 +++ .../layercompositions/compositions_test.go | 96 ++ backend/internal/localstore/store.go | 47 +- backend/internal/postgres/database.go | 1 + backend/internal/postgres/jobs_test.go | 2 + .../internal/postgres/layer_compositions.go | 110 ++ .../postgres/layer_compositions_test.go | 75 ++ components/create-studio.tsx | 374 +----- components/seedream-layer-result-viewer.tsx | 1053 +++++++++++++++++ contracts/http/route-surface.v1.json | 2 + .../0005_seedream_layer_compositions.sql | 14 + docs/DEPLOYMENT.md | 4 +- lib/client/generation-result-sync.ts | 71 ++ lib/client/seedream-layer-compose.ts | 27 +- lib/seedream/layer-composition.ts | 28 + lib/server/database.ts | 1 + scripts/migrate-postgres.mjs | 1 + supabase/schema.sql | 15 + tests/database-readiness-contract.test.ts | 1 + tests/modal-compositing-contract.test.ts | 28 + tests/postgres-privilege-contract.test.ts | 1 + tests/seedream-layer-compose.test.ts | 12 +- tests/seedream-layer-compositions.test.ts | 28 + tests/seedream-layer-edit-result-sync.test.ts | 94 ++ 31 files changed, 2567 insertions(+), 391 deletions(-) create mode 100644 backend/internal/httpapi/layer_compositions.go create mode 100644 backend/internal/httpapi/layer_compositions_test.go create mode 100644 backend/internal/layercompositions/compositions.go create mode 100644 backend/internal/layercompositions/compositions_test.go create mode 100644 backend/internal/postgres/layer_compositions.go create mode 100644 backend/internal/postgres/layer_compositions_test.go create mode 100644 components/seedream-layer-result-viewer.tsx create mode 100644 database/migrations/0005_seedream_layer_compositions.sql create mode 100644 lib/client/generation-result-sync.ts create mode 100644 lib/seedream/layer-composition.ts create mode 100644 tests/modal-compositing-contract.test.ts create mode 100644 tests/seedream-layer-compositions.test.ts create mode 100644 tests/seedream-layer-edit-result-sync.test.ts diff --git a/README.md b/README.md index bd70ebd..8ecbb7a 100644 --- a/README.md +++ b/README.md @@ -241,12 +241,13 @@ cp .env.example .env.local database/migrations/ ``` -首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(依次执行 0001、0002、0003、0004,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。 +首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(当前依次执行 0001 至 0005,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。 当前仍保留必要数据表,供上传、生成任务和用量记录使用: - `assets` - `generation_jobs` +- `seedream_layer_compositions` - `usage_events` - `billing_price_rules` - `billing_wallets` diff --git a/app/globals.css b/app/globals.css index a0c6213..d6a9365 100644 --- a/app/globals.css +++ b/app/globals.css @@ -729,8 +729,7 @@ h3 { display: grid; place-items: center; padding: 20px; - background: rgba(15, 23, 42, 0.48); - backdrop-filter: blur(8px); + background: rgba(15, 23, 42, 0.56); } .task-detail-dialog { @@ -4421,11 +4420,12 @@ h3 { } .seedream-canvas-viewport { - min-height: 220px; + min-height: clamp(320px, 46vh, 560px); flex: 1 1 auto; display: grid; place-items: center; overflow: auto; + overscroll-behavior: contain; padding: 10px; border: 1px solid var(--line); border-radius: 10px; @@ -4450,7 +4450,7 @@ h3 { .seedream-canvas-stage > img { width: auto; max-width: 100%; - max-height: 390px; + max-height: clamp(300px, 44vh, 540px); display: block; user-select: none; } @@ -5060,6 +5060,444 @@ h3 { min-height: 32px; } +.seedream-layer-project { + min-width: 0; + display: grid; + gap: 9px; +} + +.seedream-layer-project-status { + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border: 1px solid #d7e4e1; + border-radius: 8px; + background: #f5faf9; +} + +.seedream-layer-project-status small { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.seedream-layer-project-status > button { + padding: 0; + border: 0; + background: transparent; + color: var(--danger); + font-size: 9px; + font-weight: 800; +} + +.seedream-layer-save-state { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--muted-strong); + font-size: 9px; + font-weight: 850; +} + +.seedream-layer-save-state.is-saved { + color: var(--green-dark); +} + +.seedream-layer-save-state.is-error { + color: var(--danger); +} + +.seedream-layer-save-state.is-dirty { + color: #a16207; +} + +.seedream-layer-editor-toolbar .mini-button.icon-only { + width: 30px; + min-width: 30px; + padding-inline: 0; +} + +.seedream-layer-rotation-stem { + position: absolute; + left: 50%; + top: -21px; + width: 1px; + height: 20px; + background: #2563eb; + opacity: 0; + pointer-events: none; +} + +.seedream-layer-rotation-handle { + position: absolute; + left: 50%; + top: -34px; + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 2px solid #ffffff; + border-radius: 50%; + background: #2563eb; + color: #ffffff; + box-shadow: 0 2px 7px rgba(15, 23, 42, 0.24); + cursor: grab; + opacity: 0; + transform: translateX(-50%); +} + +.seedream-layer-overlay.selected .seedream-layer-rotation-stem, +.seedream-layer-overlay.selected .seedream-layer-rotation-handle { + opacity: 1; +} + +.seedream-layer-rotation-handle:active { + cursor: grabbing; +} + +.seedream-layer-inspector label { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + color: var(--muted-strong); + font-size: 10px; + font-weight: 700; +} + +.seedream-layer-inspector input { + width: 56px; + height: 24px; + padding: 0 4px; + border: 1px solid var(--line); + border-radius: 5px; + background: #ffffff; + color: var(--ink); + font: inherit; + text-align: right; +} + +.seedream-layer-action-notice { + padding: 7px 9px; + border: 1px solid #b9ddd4; + border-radius: 7px; + background: #eff9f6; + color: var(--green-dark); + font-size: 10px; + font-weight: 700; +} + +.seedream-layer-list-column { + min-width: 0; + display: grid; + align-content: start; + gap: 7px; +} + +.seedream-layer-list-column > .seedream-layer-list-head { + align-items: center; + padding-top: 0; +} + +.seedream-layer-list-head > div:first-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.seedream-layer-list-head > div:first-child small { + font-weight: 600; +} + +.seedream-layer-list-column .seedream-layer-list article { + grid-template-columns: 30px 46px minmax(0, 1fr) 52px 82px; +} + +.seedream-layer-row-actions { + display: flex; + justify-content: flex-end; + gap: 3px; +} + +.seedream-layer-row-actions > button, +.seedream-layer-row-actions > a { + width: 24px; + height: 28px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: var(--muted-strong); +} + +.seedream-layer-row-actions > button:hover, +.seedream-layer-row-actions > a:hover { + border-color: #91bcb2; + color: var(--green-dark); +} + +.seedream-layer-row-actions > button:last-of-type:hover { + border-color: #e7a49e; + color: var(--danger); +} + +.seedream-layer-library { + display: grid; + gap: 7px; + padding: 8px; + border: 1px solid #bdd8d2; + border-radius: 9px; + background: #f7fbfa; + box-shadow: 0 8px 24px rgba(15, 46, 41, 0.08); +} + +.seedream-layer-library-actions { + display: grid; + grid-template-columns: auto minmax(90px, 1fr) 30px; + align-items: center; + gap: 6px; +} + +.seedream-layer-library-actions > label { + min-width: 0; + height: 30px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 7px; + border: 1px solid var(--line); + border-radius: 7px; + background: #ffffff; + color: var(--muted); +} + +.seedream-layer-library-actions input { + min-width: 0; + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font-size: 10px; +} + +.seedream-layer-library-grid { + max-height: 210px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + overflow-y: auto; +} + +.seedream-layer-library-grid > button { + min-width: 0; + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + grid-template-rows: 20px 20px; + align-items: center; + column-gap: 6px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 7px; + background: #ffffff; + text-align: left; +} + +.seedream-layer-library-grid > button:hover { + border-color: #75aa9d; + background: #eef7f5; +} + +.seedream-layer-library-grid img { + grid-row: 1 / 3; + width: 40px; + height: 40px; + display: block; + border-radius: 5px; + object-fit: cover; +} + +.seedream-layer-library-grid span { + min-width: 0; + overflow: hidden; + color: var(--ink); + font-size: 9px; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} + +.seedream-layer-library-grid small { + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--green-dark); + font-size: 8px; + font-weight: 750; +} + +.seedream-layer-library-empty { + grid-column: 1 / -1; + padding: 16px 8px; + color: var(--muted); + font-size: 10px; + text-align: center; +} + +.seedream-layer-edit-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 23, 42, 0.52); +} + +.seedream-layer-edit-dialog { + width: min(620px, 100%); + max-height: min(760px, calc(100vh - 40px)); + display: grid; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 14px; + background: #ffffff; + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.25); +} + +.seedream-layer-edit-dialog > header { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 15px 16px 12px; + border-bottom: 1px solid var(--line); +} + +.seedream-layer-edit-dialog > header > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.seedream-layer-edit-dialog > header span { + color: var(--green-dark); + font-size: 9px; + font-weight: 850; + letter-spacing: 0.04em; +} + +.seedream-layer-edit-dialog > header h3 { + margin: 0; + overflow: hidden; + color: var(--ink); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.seedream-layer-edit-body { + display: grid; + gap: 11px; + overflow-y: auto; + padding: 15px 16px; +} + +.seedream-layer-edit-preview { + position: relative; + min-height: 210px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 10px; + background-color: #f5f8f8; + background-image: linear-gradient(45deg, #e7ecec 25%, transparent 25%), linear-gradient(-45deg, #e7ecec 25%, transparent 25%); + background-size: 18px 18px; +} + +.seedream-layer-edit-preview img { + width: 100%; + max-height: 360px; + display: block; + object-fit: contain; +} + +.seedream-layer-edit-preview > span { + position: absolute; + left: 8px; + bottom: 8px; + padding: 4px 7px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.74); + color: #ffffff; + font-size: 9px; + font-weight: 800; +} + +.seedream-layer-edit-body > label { + display: grid; + gap: 6px; + color: var(--ink); + font-size: 10px; + font-weight: 850; +} + +.seedream-layer-edit-body textarea { + min-height: 92px; + resize: vertical; + padding: 10px; + border: 1px solid var(--line); + border-radius: 9px; + outline: 0; + color: var(--ink); + font: inherit; + font-weight: 500; + line-height: 1.55; +} + +.seedream-layer-edit-body textarea:focus { + border-color: #6ea89a; + box-shadow: 0 0 0 3px rgba(20, 120, 105, 0.12); +} + +.seedream-layer-edit-body > p { + margin: 0; + color: var(--muted); + font-size: 9px; + line-height: 1.5; +} + +.seedream-layer-edit-progress { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 9px; + border-radius: 8px; + background: #eef7f5; + color: var(--green-dark); + font-size: 10px; + font-weight: 750; +} + +.seedream-layer-edit-dialog > footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 11px 16px 14px; + border-top: 1px solid var(--line); + background: #fbfdfd; +} + @media (max-width: 720px) { .seedream-workspace { min-height: 360px; @@ -5083,6 +5521,14 @@ h3 { max-height: 360px; } + .seedream-layer-list-column .seedream-layer-list article { + grid-template-columns: 30px 42px minmax(0, 1fr) 52px 82px; + } + + .seedream-layer-library-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .main.create-main .prompt-field.specialized-prompt-field { flex-basis: 160px; } diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go index ff59d71..a4b5691 100644 --- a/backend/internal/application/application.go +++ b/backend/internal/application/application.go @@ -17,6 +17,7 @@ import ( "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/orchestration" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres" @@ -119,6 +120,7 @@ func New(options Options) (*App, error) { var usageRepository usage.Repository = postgres.NewUsageRepository(database.Store) var templateCatalog templates.Catalog = database.Store var jobStore jobs.Store = database.Store + var layerCompositionRepository layercompositions.Repository = database.Store var creationState orchestration.CreationStateWriter = database.Store var jobState orchestration.JobStateWriter = database.Store var settlementState orchestration.SettlementStateWriter = database.Store @@ -134,6 +136,7 @@ func New(options Options) (*App, error) { usageRepository = store templateCatalog = store jobStore = store + layerCompositionRepository = store creationState = store jobState = store settlementState = store @@ -277,6 +280,13 @@ func New(options Options) (*App, error) { providerResolver = newRuntimeProviderResolver(getenv, runtimeSettings) } jobService := jobs.NewService(jobStore, nil) + layerCompositionService := layercompositions.NewService(layerCompositionRepository, assetCatalog) + layerCompositionHandler, err := httpapi.NewLayerCompositionsHandler(platformAuthorizer, jobService, layerCompositionService, httpapi.LayerCompositionsConfig{ + MaxJSONBytes: positiveInt64Env(getenv, "ZHINIAN_MAX_JSON_BYTES", 1<<20), + }) + if err != nil { + return nil, err + } jobBuilder := runtimeProviderJobBuilder{fallback: getenv, settings: runtimeSettings, enforceAvailability: config.Backend == postgres.BackendPostgres} usageService := usage.Service{ Repository: usageRepository, @@ -358,6 +368,7 @@ func New(options Options) (*App, error) { mux.Handle("/api/v1/jobs", jobsHandler) mux.Handle("/api/v1/jobs/", jobsHandler) mux.Handle("/api/internal/worker/tick", jobsHandler) + mux.Handle("/api/layer-compositions/", layerCompositionHandler) mux.Handle("/api/image-templates", miscHandler) mux.Handle("/api/image-templates/", miscHandler) mux.Handle("/api/prompt/assemble", miscHandler) diff --git a/backend/internal/httpapi/layer_compositions.go b/backend/internal/httpapi/layer_compositions.go new file mode 100644 index 0000000..9107326 --- /dev/null +++ b/backend/internal/httpapi/layer_compositions.go @@ -0,0 +1,124 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions" +) + +type LayerCompositionsConfig struct { + MaxJSONBytes int64 +} + +type layerCompositionsHandler struct { + platform *PlatformAuthorizer + jobs *jobs.Service + compositions *layercompositions.Service + maxJSONBytes int64 +} + +func NewLayerCompositionsHandler(platform *PlatformAuthorizer, jobService *jobs.Service, compositions *layercompositions.Service, config LayerCompositionsConfig) (http.Handler, error) { + if platform == nil || jobService == nil || compositions == nil { + return nil, errors.New("layer composition HTTP dependencies are not configured") + } + if config.MaxJSONBytes <= 0 { + config.MaxJSONBytes = 1 << 20 + } + return &layerCompositionsHandler{platform: platform, jobs: jobService, compositions: compositions, maxJSONBytes: config.MaxJSONBytes}, nil +} + +func (handler *layerCompositionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + jobID, matched := matchLayerCompositionRoute(r.URL.Path) + if !matched { + http.NotFound(w, r) + return + } + if r.Method == http.MethodOptions { + w.Header().Set("Allow", "GET, PUT") + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodPut { + w.Header().Set("Allow", "GET, PUT") + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + session, err := handler.platform.Authorize(r, PlatformApp) + if err != nil { + writeLayerCompositionError(w, err) + return + } + job, err := handler.jobs.Get(r.Context(), jobs.Scope{OwnerID: session.User.ID}, jobID) + if err != nil || job.Capability != "image.generate" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "图层任务不存在"}) + return + } + if r.Method == http.MethodGet { + composition, found, err := handler.compositions.Get(r.Context(), session.User.ID, jobID) + if err != nil { + writeLayerCompositionError(w, err) + return + } + if !found { + writeJSON(w, http.StatusOK, map[string]any{"composition": nil}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"composition": composition}) + return + } + input := struct { + Version int64 `json:"version"` + BaseAssetID string `json:"baseAssetId"` + BaseVisible bool `json:"baseVisible"` + Layers []layercompositions.Layer `json:"layers"` + }{} + if !decodeJobJSON(w, r, handler.maxJSONBytes, &input, false) { + return + } + if !containsString(job.OutputAssetIDs, input.BaseAssetID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "底图必须来自当前图层拆分任务"}) + return + } + saved, err := handler.compositions.Save(r.Context(), session.User.ID, jobID, input.Version, layercompositions.Composition{ + BaseAssetID: input.BaseAssetID, + BaseVisible: input.BaseVisible, + Layers: input.Layers, + }) + if err != nil { + writeLayerCompositionError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"composition": saved}) +} + +func matchLayerCompositionRoute(path string) (string, bool) { + const prefix = "/api/layer-compositions/" + if !strings.HasPrefix(path, prefix) { + return "", false + } + jobID := strings.TrimPrefix(path, prefix) + return jobID, jobID != "" && !strings.Contains(jobID, "/") +} + +func writeLayerCompositionError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, layercompositions.ErrConflict): + writeJSON(w, http.StatusConflict, map[string]string{"error": "图层工程已在其他页面更新,请重试"}) + case errors.Is(err, layercompositions.ErrInvalid), errors.Is(err, layercompositions.ErrAssetNotFound): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + default: + writeJobError(w, err, false) + } +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/backend/internal/httpapi/layer_compositions_test.go b/backend/internal/httpapi/layer_compositions_test.go new file mode 100644 index 0000000..fb5f424 --- /dev/null +++ b/backend/internal/httpapi/layer_compositions_test.go @@ -0,0 +1,94 @@ +package httpapi + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions" +) + +type layerCompositionRepositoryStub struct { + value layercompositions.Composition + found bool +} + +func (repository *layerCompositionRepositoryStub) FindLayerComposition(_ context.Context, ownerID, jobID string) (layercompositions.Composition, bool, error) { + if !repository.found || repository.value.OwnerID != ownerID || repository.value.JobID != jobID { + return layercompositions.Composition{}, false, nil + } + return repository.value, true, nil +} + +func (repository *layerCompositionRepositoryStub) SaveLayerComposition(_ context.Context, value layercompositions.Composition, version int64) (layercompositions.Composition, error) { + if repository.found && repository.value.Version != version { + return layercompositions.Composition{}, layercompositions.ErrConflict + } + if !repository.found && version != 0 { + return layercompositions.Composition{}, layercompositions.ErrConflict + } + value.Version = version + 1 + value.CreatedAt = time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC) + value.UpdatedAt = value.CreatedAt + repository.value = value + repository.found = true + return value, nil +} + +type layerCompositionAssetCatalog map[string]assets.Asset + +func (catalog layerCompositionAssetCatalog) GetOwner(_ context.Context, ownerID, assetID string) (assets.Asset, bool, error) { + asset, found := catalog[assetID] + return asset, found && asset.OwnerID == ownerID, nil +} + +func TestLayerCompositionsHTTPPersistsOnlyOwnedJobOutputsAndAssets(t *testing.T) { + jobStore := &httpJobStore{values: map[string]jobs.Job{ + "job-1": {ID: "job-1", OwnerID: "owner", Capability: "image.generate", OutputAssetIDs: []string{"base"}}, + "job-2": {ID: "job-2", OwnerID: "another-owner", Capability: "image.generate", OutputAssetIDs: []string{"foreign-base"}}, + }} + jobService := jobs.NewService(jobStore, nil) + repository := &layerCompositionRepositoryStub{} + compositionService := layercompositions.NewService(repository, layerCompositionAssetCatalog{ + "base": {ID: "base", OwnerID: "owner", Kind: assets.KindImage}, + "layer": {ID: "layer", OwnerID: "owner", Kind: assets.KindImage}, + }) + authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: identity.Session{User: identity.User{ID: "owner", ClientID: "platform", Role: "user"}}}) + if err != nil { + t.Fatal(err) + } + handler, err := NewLayerCompositionsHandler(authorizer, jobService, compositionService, LayerCompositionsConfig{}) + if err != nil { + t.Fatal(err) + } + + missing := serveJSON(t, handler, http.MethodGet, "/api/layer-compositions/job-1", nil) + if missing.Code != http.StatusOK || strings.TrimSpace(missing.Body.String()) != `{"composition":null}` { + t.Fatalf("missing=%d %s", missing.Code, missing.Body.String()) + } + + saved := serveJSON(t, handler, http.MethodPut, "/api/layer-compositions/job-1", map[string]any{ + "version": 0, "baseAssetId": "base", "baseVisible": true, + "layers": []any{map[string]any{"id": "layer-1", "assetId": "layer", "name": "主体", "visible": true, "box": []any{100, 100, 900, 900}, "rotation": 15, "source": "provider"}}, + }) + if saved.Code != http.StatusOK || repository.value.Version != 1 || repository.value.Layers[0].Rotation != 15 { + t.Fatalf("saved=%d %s value=%#v", saved.Code, saved.Body.String(), repository.value) + } + + foreignJob := serveJSON(t, handler, http.MethodGet, "/api/layer-compositions/job-2", nil) + if foreignJob.Code != http.StatusNotFound { + t.Fatalf("foreign job=%d %s", foreignJob.Code, foreignJob.Body.String()) + } + + invalidBase := serveJSON(t, handler, http.MethodPut, "/api/layer-compositions/job-1", map[string]any{ + "version": 1, "baseAssetId": "layer", "baseVisible": true, "layers": []any{}, + }) + if invalidBase.Code != http.StatusBadRequest { + t.Fatalf("invalid base=%d %s", invalidBase.Code, invalidBase.Body.String()) + } +} diff --git a/backend/internal/httpapi/method_compat_test.go b/backend/internal/httpapi/method_compat_test.go index 1df0f88..dc8356f 100644 --- a/backend/internal/httpapi/method_compat_test.go +++ b/backend/internal/httpapi/method_compat_test.go @@ -12,8 +12,8 @@ import ( func TestRouteMethodCompatibilityDerivesEverySurfacePath(t *testing.T) { patterns := routeMethodPatterns(GoRouteSurface()) - if len(patterns) != 47 { - t.Fatalf("route patterns=%d want 47", len(patterns)) + if len(patterns) != 48 { + t.Fatalf("route patterns=%d want 48", len(patterns)) } for _, pattern := range patterns { if _, ok := pattern.methods[http.MethodOptions]; !ok { diff --git a/backend/internal/httpapi/route_surface.go b/backend/internal/httpapi/route_surface.go index f421ae4..2ddfd34 100644 --- a/backend/internal/httpapi/route_surface.go +++ b/backend/internal/httpapi/route_surface.go @@ -17,7 +17,7 @@ var goRouteSurface = []RouteSurface{ {"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"}, {"GET", "/api/auth/callback"}, {"GET", "/api/auth/captcha"}, {"GET", "/api/auth/login"}, {"GET", "/api/auth/logout"}, {"POST", "/api/auth/logout"}, {"GET", "/api/auth/me"}, {"POST", "/api/auth/password"}, {"POST", "/api/auth/password/change"}, {"GET", "/api/billing"}, {"POST", "/api/billing/quote"}, {"GET", "/api/generations/image"}, {"POST", "/api/generations/image"}, {"DELETE", "/api/generations/image/{id}"}, {"GET", "/api/generations/image/{id}"}, {"POST", "/api/generations/image/{id}/retry"}, {"GET", "/api/generations/video"}, {"POST", "/api/generations/video"}, {"DELETE", "/api/generations/video/{id}"}, {"GET", "/api/generations/video/{id}"}, - {"GET", "/api/health"}, {"GET", "/api/image-templates"}, {"POST", "/api/image-templates"}, {"DELETE", "/api/image-templates/{id}"}, {"PATCH", "/api/image-templates/{id}"}, {"POST", "/api/internal/worker/tick"}, {"DELETE", "/api/logs"}, {"GET", "/api/logs"}, {"POST", "/api/prompt/assemble"}, {"GET", "/api/ready"}, {"GET", "/api/settings"}, {"POST", "/api/settings"}, {"GET", "/api/usage"}, + {"GET", "/api/health"}, {"GET", "/api/image-templates"}, {"POST", "/api/image-templates"}, {"DELETE", "/api/image-templates/{id}"}, {"PATCH", "/api/image-templates/{id}"}, {"POST", "/api/internal/worker/tick"}, {"GET", "/api/layer-compositions/{id}"}, {"PUT", "/api/layer-compositions/{id}"}, {"DELETE", "/api/logs"}, {"GET", "/api/logs"}, {"POST", "/api/prompt/assemble"}, {"GET", "/api/ready"}, {"GET", "/api/settings"}, {"POST", "/api/settings"}, {"GET", "/api/usage"}, {"GET", "/api/v1/assets"}, {"POST", "/api/v1/assets"}, {"GET", "/api/v1/assets/{id}"}, {"GET", "/api/v1/assets/{id}/download"}, {"GET", "/api/v1/capabilities"}, {"GET", "/api/v1/jobs"}, {"POST", "/api/v1/jobs"}, {"GET", "/api/v1/jobs/{id}"}, {"POST", "/api/v1/jobs/{id}/cancel"}, {"GET", "/api/v1/openapi.json"}, {"GET", "/generated-results/{path...}"}, {"GET", "/uploads/{path...}"}, } diff --git a/backend/internal/layercompositions/compositions.go b/backend/internal/layercompositions/compositions.go new file mode 100644 index 0000000..fbd7062 --- /dev/null +++ b/backend/internal/layercompositions/compositions.go @@ -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 +} diff --git a/backend/internal/layercompositions/compositions_test.go b/backend/internal/layercompositions/compositions_test.go new file mode 100644 index 0000000..a5a5b3a --- /dev/null +++ b/backend/internal/layercompositions/compositions_test.go @@ -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) + } +} diff --git a/backend/internal/localstore/store.go b/backend/internal/localstore/store.go index 9cdc563..6d520b1 100644 --- a/backend/internal/localstore/store.go +++ b/backend/internal/localstore/store.go @@ -15,6 +15,7 @@ import ( "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/templates" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/usage" ) @@ -43,6 +44,7 @@ type Store struct { organizations map[string]administration.Organization assets map[string]assets.Asset jobs map[string]jobs.Job + compositions map[string]layercompositions.Composition templates map[string]templates.Template usageEvents map[string]usage.Event usageJobIDs map[string]string @@ -58,7 +60,7 @@ type walletRecord struct { } func New(options ...Option) *Store { - s := &Store{now: time.Now, accounts: map[string]administration.Account{}, organizations: map[string]administration.Organization{}, assets: map[string]assets.Asset{}, jobs: map[string]jobs.Job{}, templates: map[string]templates.Template{}, usageEvents: map[string]usage.Event{}, usageJobIDs: map[string]string{}, priceRules: map[string]billing.PriceRule{}, wallets: map[string]billing.Wallet{}, postings: map[string]walletRecord{}} + s := &Store{now: time.Now, accounts: map[string]administration.Account{}, organizations: map[string]administration.Organization{}, assets: map[string]assets.Asset{}, jobs: map[string]jobs.Job{}, compositions: map[string]layercompositions.Composition{}, templates: map[string]templates.Template{}, usageEvents: map[string]usage.Event{}, usageJobIDs: map[string]string{}, priceRules: map[string]billing.PriceRule{}, wallets: map[string]billing.Wallet{}, postings: map[string]walletRecord{}} for _, option := range options { option(s) } @@ -110,6 +112,10 @@ func cloneJob(j jobs.Job) jobs.Job { } return j } +func cloneLayerComposition(composition layercompositions.Composition) layercompositions.Composition { + composition.Layers = append([]layercompositions.Layer(nil), composition.Layers...) + return composition +} func cloneMap(m map[string]any) map[string]any { if m == nil { return nil @@ -370,8 +376,47 @@ func (s *Store) DeleteJob(_ context.Context, id string) error { return fmt.Errorf("generation job not found: %s", id) } delete(s.jobs, id) + delete(s.compositions, id) return nil } + +// Seedream layer compositions. +func (s *Store) FindLayerComposition(_ context.Context, ownerID, jobID string) (layercompositions.Composition, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + composition, found := s.compositions[jobID] + if !found || composition.OwnerID != ownerID { + return layercompositions.Composition{}, false, nil + } + return cloneLayerComposition(composition), true, nil +} + +func (s *Store) SaveLayerComposition(_ context.Context, composition layercompositions.Composition, expectedVersion int64) (layercompositions.Composition, error) { + s.mu.Lock() + defer s.mu.Unlock() + existing, found := s.compositions[composition.JobID] + if found { + if existing.OwnerID != composition.OwnerID || existing.Version != expectedVersion { + return layercompositions.Composition{}, layercompositions.ErrConflict + } + composition.Version = existing.Version + 1 + composition.CreatedAt = existing.CreatedAt + } else { + if expectedVersion != 0 { + return layercompositions.Composition{}, layercompositions.ErrConflict + } + job, jobFound := s.jobs[composition.JobID] + if !jobFound || job.OwnerID != composition.OwnerID { + return layercompositions.Composition{}, fmt.Errorf("generation job not found: %s", composition.JobID) + } + composition.Version = 1 + composition.CreatedAt = s.now().UTC() + } + composition.UpdatedAt = s.now().UTC() + s.compositions[composition.JobID] = cloneLayerComposition(composition) + return cloneLayerComposition(composition), nil +} + func (s *Store) ClaimJobs(_ context.Context, worker string, limit, timeoutSeconds int) ([]jobs.Job, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/backend/internal/postgres/database.go b/backend/internal/postgres/database.go index 18a8f43..e3f4948 100644 --- a/backend/internal/postgres/database.go +++ b/backend/internal/postgres/database.go @@ -12,6 +12,7 @@ WITH required_table_privileges(table_name, privilege_name) AS ( VALUES ('assets', 'SELECT'), ('assets', 'INSERT'), ('assets', 'DELETE'), ('generation_jobs', 'SELECT'), ('generation_jobs', 'INSERT'), ('generation_jobs', 'UPDATE'), ('generation_jobs', 'DELETE'), + ('seedream_layer_compositions', 'SELECT'), ('seedream_layer_compositions', 'INSERT'), ('seedream_layer_compositions', 'UPDATE'), ('seedream_layer_compositions', 'DELETE'), ('usage_events', 'SELECT'), ('usage_events', 'INSERT'), ('usage_events', 'UPDATE'), ('projects', 'SELECT'), ('projects', 'UPDATE'), ('image_templates', 'SELECT'), ('image_templates', 'INSERT'), ('image_templates', 'UPDATE'), ('image_templates', 'DELETE'), diff --git a/backend/internal/postgres/jobs_test.go b/backend/internal/postgres/jobs_test.go index 7d794ba..78f4226 100644 --- a/backend/internal/postgres/jobs_test.go +++ b/backend/internal/postgres/jobs_test.go @@ -266,6 +266,8 @@ func (rows *jobRows) Scan(dest ...any) error { *target = value.(int) case *int64: *target = value.(int64) + case *bool: + *target = value.(bool) case *time.Time: if value != nil { *target = value.(time.Time) diff --git a/backend/internal/postgres/layer_compositions.go b/backend/internal/postgres/layer_compositions.go new file mode 100644 index 0000000..3514c23 --- /dev/null +++ b/backend/internal/postgres/layer_compositions.go @@ -0,0 +1,110 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions" +) + +const FindLayerCompositionSQL = `SELECT job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at +FROM public.seedream_layer_compositions +WHERE owner_id = $1::text AND job_id = $2::text +LIMIT 1` + +const SaveLayerCompositionSQL = `WITH updated AS ( + UPDATE public.seedream_layer_compositions SET + base_asset_id = $3::text, + base_visible = $4::boolean, + layers = $5::jsonb, + version = seedream_layer_compositions.version + 1, + updated_at = now() + WHERE job_id = $1::text + AND owner_id = $2::text + AND version = $6::bigint + AND $6::bigint > 0 + RETURNING job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at +), inserted AS ( + INSERT INTO public.seedream_layer_compositions ( + job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at + ) + SELECT $1::text, $2::text, $3::text, $4::boolean, $5::jsonb, 1, now(), now() + WHERE $6::bigint = 0 + ON CONFLICT (job_id) DO NOTHING + RETURNING job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at +) +SELECT job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at FROM updated +UNION ALL +SELECT job_id, owner_id, base_asset_id, base_visible, layers, version, created_at, updated_at FROM inserted +LIMIT 1` + +func (db *Database) FindLayerComposition(ctx context.Context, ownerID, jobID string) (layercompositions.Composition, bool, error) { + if err := db.available(); err != nil { + return layercompositions.Composition{}, false, err + } + rows, err := db.querier.Query(ctx, FindLayerCompositionSQL, ownerID, jobID) + if err != nil { + return layercompositions.Composition{}, false, fmt.Errorf("find layer composition: %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return layercompositions.Composition{}, false, fmt.Errorf("read layer composition: %w", err) + } + return layercompositions.Composition{}, false, nil + } + composition, err := scanLayerComposition(rows) + if err != nil { + return layercompositions.Composition{}, false, err + } + return composition, true, rows.Err() +} + +func (db *Database) SaveLayerComposition(ctx context.Context, composition layercompositions.Composition, expectedVersion int64) (layercompositions.Composition, error) { + if err := db.available(); err != nil { + return layercompositions.Composition{}, err + } + layers, err := json.Marshal(composition.Layers) + if err != nil { + return layercompositions.Composition{}, fmt.Errorf("encode layer composition: %w", err) + } + rows, err := db.querier.Query(ctx, SaveLayerCompositionSQL, + composition.JobID, composition.OwnerID, composition.BaseAssetID, + composition.BaseVisible, layers, expectedVersion, + ) + if err != nil { + return layercompositions.Composition{}, fmt.Errorf("save layer composition: %w", err) + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return layercompositions.Composition{}, fmt.Errorf("read saved layer composition: %w", err) + } + return layercompositions.Composition{}, layercompositions.ErrConflict + } + saved, err := scanLayerComposition(rows) + if err != nil { + return layercompositions.Composition{}, err + } + return saved, rows.Err() +} + +func scanLayerComposition(rows Rows) (layercompositions.Composition, error) { + var composition layercompositions.Composition + var rawLayers []byte + if err := rows.Scan( + &composition.JobID, &composition.OwnerID, &composition.BaseAssetID, + &composition.BaseVisible, &rawLayers, &composition.Version, + &composition.CreatedAt, &composition.UpdatedAt, + ); err != nil { + return layercompositions.Composition{}, fmt.Errorf("scan layer composition: %w", err) + } + if err := json.Unmarshal(rawLayers, &composition.Layers); err != nil { + return layercompositions.Composition{}, fmt.Errorf("decode layer composition: %w", err) + } + if composition.Layers == nil { + composition.Layers = []layercompositions.Layer{} + } + return composition, nil +} diff --git a/backend/internal/postgres/layer_compositions_test.go b/backend/internal/postgres/layer_compositions_test.go new file mode 100644 index 0000000..5e60579 --- /dev/null +++ b/backend/internal/postgres/layer_compositions_test.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions" +) + +func TestLayerCompositionAdapterScopesReadsAndUsesOptimisticSave(t *testing.T) { + now := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC) + row := []any{"job-1", "owner", "base", true, []byte(`[{"id":"layer-1","assetId":"asset-1","name":"主体","visible":true,"box":[100,100,900,900],"rotation":0,"source":"provider"}]`), int64(2), now, now} + querier := &jobQuerier{rows: &jobRows{rows: [][]any{row}}} + database := NewDatabase(Config{Backend: BackendPostgres}, querier) + + composition, found, err := database.FindLayerComposition(context.Background(), "owner", "job-1") + if err != nil || !found || composition.Version != 2 || len(composition.Layers) != 1 { + t.Fatalf("composition=%#v found=%v err=%v", composition, found, err) + } + if querier.query != FindLayerCompositionSQL || !reflect.DeepEqual(querier.args, []any{"owner", "job-1"}) { + t.Fatalf("query=%q args=%#v", querier.query, querier.args) + } + + savedRow := append([]any(nil), row...) + savedRow[5] = int64(3) + querier.rows = &jobRows{rows: [][]any{savedRow}} + saved, err := database.SaveLayerComposition(context.Background(), layercompositions.Composition{ + JobID: "job-1", OwnerID: "owner", BaseAssetID: "base", BaseVisible: true, + Layers: []layercompositions.Layer{{ID: "layer-1", AssetID: "asset-1", Name: "主体", Visible: true, Box: layercompositions.Box{100, 100, 900, 900}, Source: "provider"}}, + }, 2) + if err != nil || saved.Version != 3 { + t.Fatalf("saved=%#v err=%v", saved, err) + } + if querier.query != SaveLayerCompositionSQL || len(querier.args) != 6 || querier.args[5] != int64(2) { + t.Fatalf("query=%q args=%#v", querier.query, querier.args) + } + for _, fragment := range []string{"WITH updated AS", "WHERE $6::bigint = 0", "version = seedream_layer_compositions.version + 1", "AND version = $6::bigint", "ON CONFLICT (job_id) DO NOTHING"} { + if !strings.Contains(SaveLayerCompositionSQL, fragment) { + t.Fatalf("upsert omits %q", fragment) + } + } +} + +func TestLayerCompositionSaveSQLKeepsExistingVersionedUpdatesReachable(t *testing.T) { + // A top-level INSERT ... SELECT ... WHERE expectedVersion = 0 filters the + // source row before ON CONFLICT can run. In that shape every normal update + // (expectedVersion > 0) returns no row and is reported as a false conflict. + if strings.Contains(SaveLayerCompositionSQL, "SELECT $1::text, $2::text, $3::text, $4::boolean, $5::jsonb, 1, now(), now()\nWHERE $6::bigint = 0\nON CONFLICT") { + t.Fatal("versioned updates are unreachable because the INSERT source is filtered before ON CONFLICT") + } + for _, fragment := range []string{ + "WITH updated AS", + "version = $6::bigint", + "$6::bigint > 0", + "WHERE $6::bigint = 0", + "UNION ALL", + } { + if !strings.Contains(SaveLayerCompositionSQL, fragment) { + t.Fatalf("save query does not preserve both update and create paths; missing %q", fragment) + } + } +} + +func TestLayerCompositionAdapterReturnsConflictWhenVersionUpdateMatchesNoRow(t *testing.T) { + querier := &jobQuerier{rows: &jobRows{}} + database := NewDatabase(Config{Backend: BackendPostgres}, querier) + _, err := database.SaveLayerComposition(context.Background(), layercompositions.Composition{JobID: "job-1", OwnerID: "owner", BaseAssetID: "base", Layers: []layercompositions.Layer{}}, 4) + if !errors.Is(err, layercompositions.ErrConflict) { + t.Fatalf("err=%v", err) + } +} diff --git a/components/create-studio.tsx b/components/create-studio.tsx index 5ac431f..cd80ebb 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -1,10 +1,11 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react"; -import { ArrowDown, ArrowUp, Check, CircleDollarSign, Download, Eye, EyeOff, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Move, Music, Pencil, Plus, RefreshCw, RotateCcw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; +import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Music, Pencil, Plus, RefreshCw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react"; import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { SeedreamWorkspace } from "@/components/seedream-workspace"; +import { SeedreamLayerResultViewer, type SeedreamLayerOutput } from "@/components/seedream-layer-result-viewer"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { SEEDANCE_20_MODEL, @@ -28,9 +29,7 @@ import { import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"; import { formatBillingAmount } from "@/lib/billing"; import { assetContentUrl, assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls"; -import { downloadSeedreamLayerComposition } from "@/lib/client/seedream-layer-compose"; import { createSeedreamMarkedFile } from "@/lib/client/seedream-markup"; -import { downloadFilesAsZip } from "@/lib/client/zip-download"; import { buildSeedreamInteractivePrompt, buildSeedreamLayerPrompt, @@ -2017,7 +2016,7 @@ function TaskDetailModal({ {layerOutputs.length ? ( - + ) : outputAssets.length ? (
{outputAssets.map((asset) => ( @@ -2128,365 +2127,6 @@ function TaskDetailModal({ ); } -type SeedreamLayerOutput = { - asset: Asset; - zIndex: number; - name: string; - description?: string; - size?: [number, number]; - boundingBox?: { - absolute?: [number, number, number, number]; - normalized?: [number, number, number, number]; - }; -}; - -function SeedreamLayerResultViewer({ jobId, outputs }: { jobId: string; outputs: SeedreamLayerOutput[] }) { - const ordered = useMemo(() => [...outputs].sort((a, b) => a.zIndex - b.zIndex), [outputs]); - const base = ordered.find((output) => output.zIndex === 0) || ordered[0]; - const layers = ordered.filter((output) => output !== base); - const baseSize = base.size || [1, 1]; - const layerSignature = `${jobId}:${ordered.map((output) => [output.asset.id, output.zIndex, output.size, output.boundingBox?.normalized, output.boundingBox?.absolute].join(":"))}`; - const [hiddenIds, setHiddenIds] = useState>(() => new Set()); - const [selectedId, setSelectedId] = useState(layers[0]?.asset.id ?? base.asset.id); - const [layerOrder, setLayerOrder] = useState(() => layers.map((layer) => layer.asset.id)); - const [transforms, setTransforms] = useState>(() => initialSeedreamLayerTransforms(layers, baseSize)); - const [interaction, setInteraction] = useState(null); - const [zipDownloading, setZipDownloading] = useState(false); - const [exporting, setExporting] = useState(false); - const [downloadError, setDownloadError] = useState(null); - const previewRef = useRef(null); - const layerById = useMemo(() => new Map(layers.map((layer) => [layer.asset.id, layer])), [layers]); - const displayLayers = layerOrder.map((id) => layerById.get(id)).filter((layer): layer is SeedreamLayerOutput => Boolean(layer)); - const selectedLayer = selectedId ? layerById.get(selectedId) : undefined; - const selectedTransform = selectedLayer ? transforms[selectedLayer.asset.id] : undefined; - const listOutputs = [...displayLayers].reverse().concat(base); - const previewMaximumWidth = Math.max(1, 500 * baseSize[0] / baseSize[1]); - - useEffect(() => { - setHiddenIds(new Set()); - setSelectedId(layers[0]?.asset.id ?? base.asset.id); - setLayerOrder(layers.map((layer) => layer.asset.id)); - setTransforms(initialSeedreamLayerTransforms(layers, baseSize)); - setInteraction(null); - setDownloadError(null); - }, [layerSignature]); - - function toggle(id: string) { - setHiddenIds((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } - - async function downloadAll() { - setZipDownloading(true); - setDownloadError(null); - try { - await downloadFilesAsZip(ordered.map((output) => ({ - name: layerDownloadName(output), - url: assetDownloadUrl(output.asset) - })), `seedream-layers-${jobId}.zip`); - } catch (err) { - setDownloadError(err instanceof Error ? err.message : "打包下载失败"); - } finally { - setZipDownloading(false); - } - } - - async function exportComposition() { - setExporting(true); - setDownloadError(null); - try { - await downloadSeedreamLayerComposition({ - width: baseSize[0], - height: baseSize[1], - fileName: `seedream-composition-${jobId}.png`, - items: [base, ...displayLayers].map((output) => ({ - url: assetContentUrl(output.asset), - visible: !hiddenIds.has(output.asset.id), - box: output === base ? fullSeedreamLayerBox : transforms[output.asset.id] || fullSeedreamLayerBox - })) - }); - } catch (err) { - setDownloadError(err instanceof Error ? err.message : "导出合成图片失败"); - } finally { - setExporting(false); - } - } - - function resetEditor() { - setHiddenIds(new Set()); - setLayerOrder(layers.map((layer) => layer.asset.id)); - setTransforms(initialSeedreamLayerTransforms(layers, baseSize)); - setSelectedId(layers[0]?.asset.id ?? base.asset.id); - setInteraction(null); - setDownloadError(null); - } - - function reorderLayer(id: string, direction: -1 | 1) { - setLayerOrder((current) => { - const index = current.indexOf(id); - const nextIndex = index + direction; - if (index < 0 || nextIndex < 0 || nextIndex >= current.length) return current; - const next = [...current]; - [next[index], next[nextIndex]] = [next[nextIndex], next[index]]; - return next; - }); - } - - function scaleLayer(id: string, factor: number) { - setTransforms((current) => { - const box = current[id]; - return box ? { ...current, [id]: scaleSeedreamLayerBox(box, factor) } : current; - }); - } - - function startInteraction(event: ReactPointerEvent, id: string, mode: SeedreamLayerInteraction["mode"]) { - const box = transforms[id]; - if (!box) return; - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - setSelectedId(id); - setInteraction({ id, mode, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startBox: box }); - } - - function continueInteraction(event: ReactPointerEvent) { - if (!interaction || interaction.pointerId !== event.pointerId || !previewRef.current) return; - event.preventDefault(); - const bounds = previewRef.current.getBoundingClientRect(); - if (!bounds.width || !bounds.height) return; - const deltaX = (event.clientX - interaction.startX) / bounds.width * 1000; - const deltaY = (event.clientY - interaction.startY) / bounds.height * 1000; - const nextBox = interaction.mode === "move" - ? moveSeedreamLayerBox(interaction.startBox, deltaX, deltaY) - : resizeSeedreamLayerBox(interaction.startBox, deltaX, deltaY); - setTransforms((current) => ({ ...current, [interaction.id]: nextBox })); - } - - function finishInteraction(event: ReactPointerEvent) { - if (interaction?.pointerId === event.pointerId) setInteraction(null); - } - - function handleLayerKeyDown(event: KeyboardEvent, id: string) { - const direction = event.key === "ArrowLeft" - ? [-1, 0] - : event.key === "ArrowRight" - ? [1, 0] - : event.key === "ArrowUp" - ? [0, -1] - : event.key === "ArrowDown" - ? [0, 1] - : null; - if (!direction) return; - event.preventDefault(); - const step = event.shiftKey ? 50 : 10; - setTransforms((current) => { - const box = current[id]; - return box ? { ...current, [id]: moveSeedreamLayerBox(box, direction[0] * step, direction[1] * step) } : current; - }); - } - - return ( -
-
-
- 拖动图层,右下角等比缩放;方向键可微调 -
- - - -
-
-
- {!hiddenIds.has(base.asset.id) ? {base.name} : null} - {displayLayers.map((layer, stackIndex) => { - const box = transforms[layer.asset.id]; - if (!box || hiddenIds.has(layer.asset.id)) return null; - return ( - - ); - })} -
- {selectedTransform ? ( -
- {selectedLayer?.name} - X {Math.round(selectedTransform[0] / 10)}% - Y {Math.round(selectedTransform[1] / 10)}% - 宽 {Math.round((selectedTransform[2] - selectedTransform[0]) / 10)}% - 高 {Math.round((selectedTransform[3] - selectedTransform[1]) / 10)}% -
- ) :
底图固定在画布底部
} -
- 合成导出采用当前显隐、位置、尺寸和层级 -
- - -
-
- {downloadError ?
{downloadError}
: null} -
-
-
图层上方图层优先显示
- {listOutputs.map((output) => { - const isBase = output === base; - const visible = !hiddenIds.has(output.asset.id); - const orderIndex = layerOrder.indexOf(output.asset.id); - return ( -
setSelectedId(output.asset.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setSelectedId(output.asset.id); - } - }} - > - - - - {isBase ? "底图" : output.name} - {output.description || (isBase ? "固定画布" : `原始层级 ${output.zIndex}`)} - - {isBase ? 固定 : ( - - - - - )} - event.stopPropagation()}> - - -
- ); - })} -
-
- ); -} - -type SeedreamLayerBox = [number, number, number, number]; - -type SeedreamLayerInteraction = { - id: string; - mode: "move" | "resize"; - pointerId: number; - startX: number; - startY: number; - startBox: SeedreamLayerBox; -}; - -const fullSeedreamLayerBox: SeedreamLayerBox = [0, 0, 1000, 1000]; - -function initialSeedreamLayerTransforms(layers: SeedreamLayerOutput[], baseSize: [number, number]) { - return Object.fromEntries(layers.map((layer, index) => [layer.asset.id, initialSeedreamLayerBox(layer, baseSize, index)])) as Record; -} - -function initialSeedreamLayerBox(layer: SeedreamLayerOutput, baseSize: [number, number], index: number): SeedreamLayerBox { - const normalized = layer.boundingBox?.normalized; - if (normalized) return sanitizeSeedreamLayerBox(normalized); - const absolute = layer.boundingBox?.absolute; - if (absolute && baseSize[0] > 1 && baseSize[1] > 1) { - return sanitizeSeedreamLayerBox([ - absolute[0] / baseSize[0] * 1000, - absolute[1] / baseSize[1] * 1000, - absolute[2] / baseSize[0] * 1000, - absolute[3] / baseSize[1] * 1000 - ]); - } - if (layer.size && baseSize[0] > 1 && baseSize[1] > 1) { - const width = Math.min(900, Math.max(80, layer.size[0] / baseSize[0] * 1000)); - const height = Math.min(900, Math.max(80, layer.size[1] / baseSize[1] * 1000)); - const offset = Math.min(index * 22, 120); - return sanitizeSeedreamLayerBox([(1000 - width) / 2 + offset, (1000 - height) / 2 + offset, (1000 + width) / 2 + offset, (1000 + height) / 2 + offset]); - } - const offset = Math.min(index * 25, 150); - return [150 + offset, 150 + offset, 850, 850]; -} - -function sanitizeSeedreamLayerBox(value: SeedreamLayerBox): SeedreamLayerBox { - const scale = Math.max(...value.map((item) => Math.abs(item))) <= 1 ? 1000 : 1; - const left = clampNumber(value[0] * scale, 0, 980); - const top = clampNumber(value[1] * scale, 0, 980); - const right = clampNumber(value[2] * scale, left + 20, 1000); - const bottom = clampNumber(value[3] * scale, top + 20, 1000); - return [left, top, right, bottom]; -} - -function moveSeedreamLayerBox(box: SeedreamLayerBox, deltaX: number, deltaY: number): SeedreamLayerBox { - const width = box[2] - box[0]; - const height = box[3] - box[1]; - const left = clampNumber(box[0] + deltaX, 0, 1000 - width); - const top = clampNumber(box[1] + deltaY, 0, 1000 - height); - return [left, top, left + width, top + height]; -} - -function resizeSeedreamLayerBox(box: SeedreamLayerBox, deltaX: number, deltaY: number): SeedreamLayerBox { - const width = box[2] - box[0]; - const height = box[3] - box[1]; - const requestedFactor = Math.max((width + deltaX) / width, (height + deltaY) / height); - const minimumFactor = Math.max(20 / width, 20 / height); - const maximumFactor = Math.min((1000 - box[0]) / width, (1000 - box[1]) / height); - const factor = clampNumber(requestedFactor, minimumFactor, maximumFactor); - return [box[0], box[1], box[0] + width * factor, box[1] + height * factor]; -} - -function scaleSeedreamLayerBox(box: SeedreamLayerBox, requestedFactor: number): SeedreamLayerBox { - const width = box[2] - box[0]; - const height = box[3] - box[1]; - const factor = clampNumber(requestedFactor, Math.max(20 / width, 20 / height), Math.min(1000 / width, 1000 / height)); - const nextWidth = width * factor; - const nextHeight = height * factor; - const centerX = (box[0] + box[2]) / 2; - const centerY = (box[1] + box[3]) / 2; - const left = clampNumber(centerX - nextWidth / 2, 0, 1000 - nextWidth); - const top = clampNumber(centerY - nextHeight / 2, 0, 1000 - nextHeight); - return [left, top, left + nextWidth, top + nextHeight]; -} - -function clampNumber(value: number, minimum: number, maximum: number) { - return Math.min(maximum, Math.max(minimum, value)); -} - function seedreamLayerOutputs(job: GenerationJob, outputAssets: Asset[]): SeedreamLayerOutput[] { const response = asRecord(job.responsePayload); const raw = asRecord(response.raw); @@ -2510,12 +2150,6 @@ function seedreamLayerOutputs(job: GenerationJob, outputAssets: Asset[]): Seedre }); } -function layerDownloadName(output: SeedreamLayerOutput) { - const extension = output.asset.name.match(/\.[a-z0-9]+$/i)?.[0] || (output.zIndex === 0 ? ".jpg" : ".png"); - const label = output.zIndex === 0 ? "00-base" : `${String(output.zIndex).padStart(2, "0")}-${output.name}`; - return `${label}${extension}`; -} - function coordinateTuple(value: unknown): [number, number, number, number] | undefined { if (!Array.isArray(value) || value.length !== 4) return undefined; const numbers = value.map(numericValue); diff --git a/components/seedream-layer-result-viewer.tsx b/components/seedream-layer-result-viewer.tsx new file mode 100644 index 0000000..279d25b --- /dev/null +++ b/components/seedream-layer-result-viewer.tsx @@ -0,0 +1,1053 @@ +"use client"; + +import { + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent +} from "react"; +import { + ArrowDown, + ArrowUp, + Check, + Download, + Eye, + EyeOff, + ImagePlus, + Loader2, + Move, + Pencil, + Plus, + RefreshCw, + RotateCcw, + RotateCw, + Search, + Trash2, + Upload, + X +} from "lucide-react"; +import clsx from "clsx"; + +import { assetContentUrl, assetPreviewUrl } from "@/lib/client/asset-urls"; +import { waitForJobOutputAssets } from "@/lib/client/generation-result-sync"; +import { downloadSeedreamLayerComposition } from "@/lib/client/seedream-layer-compose"; +import { downloadFilesAsZip } from "@/lib/client/zip-download"; +import type { + SeedreamCompositionLayer, + SeedreamLayerBox, + SeedreamLayerComposition, + SeedreamLayerDraft, + SeedreamLayerSource +} from "@/lib/seedream/layer-composition"; +import type { Asset, GenerationJob } from "@/lib/types"; + +export type SeedreamLayerOutput = { + asset: Asset; + zIndex: number; + name: string; + description?: string; + size?: [number, number]; + boundingBox?: { + absolute?: SeedreamLayerBox; + normalized?: SeedreamLayerBox; + }; +}; + +type SaveState = "loading" | "saved" | "dirty" | "saving" | "error"; + +type LayerInteraction = { + id: string; + mode: "move" | "resize" | "rotate"; + pointerId: number; + startX: number; + startY: number; + startBox: SeedreamLayerBox; + startRotation: number; + startPointerAngle?: number; +}; + +type EditDialogState = { + layerId: string; + prompt: string; + phase: "idle" | "submitting" | "polling" | "ready"; + job?: GenerationJob; + result?: Asset; + error?: string; +}; + +const baseSelectionID = "__seedream_base__"; +const fullLayerBox: SeedreamLayerBox = [0, 0, 1000, 1000]; + +export function SeedreamLayerResultViewer({ + jobId, + outputs, + assetLibrary +}: { + jobId: string; + outputs: SeedreamLayerOutput[]; + assetLibrary: Asset[]; +}) { + const ordered = useMemo(() => [...outputs].sort((left, right) => left.zIndex - right.zIndex), [outputs]); + const base = (ordered.find((output) => output.zIndex === 0) || ordered[0]) as SeedreamLayerOutput; + const providerLayers = useMemo(() => ordered.filter((output) => output !== base), [base, ordered]); + const baseSize: [number, number] = base.size || [1, 1]; + const outputSignature = `${jobId}:${ordered.map((output) => [output.asset.id, output.zIndex, output.size, output.boundingBox?.normalized, output.boundingBox?.absolute].join(":"))}`; + const assetLibrarySignature = assetLibrary.map((asset) => `${asset.id}:${asset.updatedAt}`).join("|"); + const initialDraft = useMemo(() => initialLayerDraft(base, providerLayers, baseSize), [base, baseSize, providerLayers]); + const [draft, setDraft] = useState(initialDraft); + const [assetCache, setAssetCache] = useState>(() => assetMap([...assetLibrary, ...ordered.map((output) => output.asset)])); + const [selectedId, setSelectedId] = useState(providerLayers[0] ? `provider-${providerLayers[0].asset.id}` : baseSelectionID); + const [interaction, setInteraction] = useState(null); + const [saveState, setSaveState] = useState("loading"); + const [saveError, setSaveError] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionNotice, setActionNotice] = useState(null); + const [libraryOpen, setLibraryOpen] = useState(false); + const [libraryQuery, setLibraryQuery] = useState(""); + const [uploading, setUploading] = useState(false); + const [zipDownloading, setZipDownloading] = useState(false); + const [exporting, setExporting] = useState(false); + const [editDialog, setEditDialog] = useState(null); + const previewRef = useRef(null); + const uploadInputRef = useRef(null); + const editAbortRef = useRef(null); + const hydratedRef = useRef(false); + const draftRef = useRef(draft); + const versionRef = useRef(0); + const lastSavedRef = useRef(serializeDraft(initialDraft)); + const savingRef = useRef(false); + const pendingSaveRef = useRef(false); + + const selectedLayer = draft.layers.find((layer) => layer.id === selectedId); + const baseAsset = assetCache.get(draft.baseAssetId) || base.asset; + const displayLayers = draft.layers; + const listLayers = [...displayLayers].reverse(); + const previewMaximumWidth = Math.max(1, 560 * baseSize[0] / baseSize[1]); + const currentSerializedDraft = serializeDraft(draft); + const libraryAssets = useMemo(() => { + const query = libraryQuery.trim().toLocaleLowerCase(); + return [...assetCache.values()] + .filter((asset) => isLayerAsset(asset) && asset.id !== draft.baseAssetId) + .filter((asset) => !query || `${asset.name} ${asset.tags.join(" ")}`.toLocaleLowerCase().includes(query)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, 80); + }, [assetCache, draft.baseAssetId, libraryQuery]); + + useEffect(() => { + setAssetCache((current) => assetMap([...current.values(), ...assetLibrary, ...ordered.map((output) => output.asset)])); + }, [assetLibrarySignature, outputSignature]); + + useEffect(() => { + let active = true; + hydratedRef.current = false; + setSaveState("loading"); + setSaveError(null); + setActionError(null); + setActionNotice(null); + setInteraction(null); + setLibraryOpen(false); + setEditDialog(null); + editAbortRef.current?.abort(); + + async function load() { + try { + const [compositionResponse, assetsResponse] = await Promise.all([ + fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { cache: "no-store" }), + fetch("/api/assets", { cache: "no-store" }) + ]); + const compositionPayload = await readJSON(compositionResponse); + if (!compositionResponse.ok) throw new Error(payloadError(compositionPayload, "读取图层工程失败")); + const assetsPayload = assetsResponse.ok ? await readJSON(assetsResponse) : {}; + if (!active) return; + const loadedAssets = Array.isArray(assetsPayload.assets) ? assetsPayload.assets as Asset[] : []; + setAssetCache((current) => assetMap([...current.values(), ...loadedAssets, ...ordered.map((output) => output.asset)])); + let composition = isLayerComposition(compositionPayload.composition) + ? compositionPayload.composition + : null; + if (!composition) { + let initializeResponse = await putLayerComposition(jobId, 0, initialDraft); + if (initializeResponse.status === 409) { + initializeResponse = await fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { cache: "no-store" }); + } + const initializePayload = await readJSON(initializeResponse); + if (!initializeResponse.ok) throw new Error(payloadError(initializePayload, "初始化图层工程失败")); + if (!isLayerComposition(initializePayload.composition)) throw new Error("初始化图层工程返回数据不完整"); + composition = initializePayload.composition; + } + const nextDraft = composition ? draftFromComposition(composition) : initialDraft; + versionRef.current = composition?.version || 0; + draftRef.current = nextDraft; + lastSavedRef.current = serializeDraft(nextDraft); + setDraft(nextDraft); + setSelectedId(nextDraft.layers[0]?.id || baseSelectionID); + setSaveState("saved"); + } catch (error) { + if (!active) return; + draftRef.current = initialDraft; + lastSavedRef.current = ""; + versionRef.current = 0; + setDraft(initialDraft); + setSelectedId(initialDraft.layers[0]?.id || baseSelectionID); + setSaveState("error"); + setSaveError(errorMessage(error, "读取图层工程失败;当前仍可临时编辑")); + } finally { + if (active) hydratedRef.current = true; + } + } + + void load(); + return () => { + active = false; + editAbortRef.current?.abort(); + }; + }, [jobId, outputSignature]); + + useEffect(() => { + draftRef.current = draft; + if (!hydratedRef.current || currentSerializedDraft === lastSavedRef.current) return; + setSaveState((current) => current === "saving" ? current : "dirty"); + const timer = window.setTimeout(() => void saveCurrentDraft(), 650); + return () => window.clearTimeout(timer); + }, [currentSerializedDraft, jobId]); + + useEffect(() => { + return () => { + if (!hydratedRef.current || serializeDraft(draftRef.current) === lastSavedRef.current) return; + if (savingRef.current) { + pendingSaveRef.current = true; + return; + } + const pending = draftRef.current; + void fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version: versionRef.current, ...pending }), + keepalive: true + }).catch(() => undefined); + }; + }, [jobId]); + + async function saveCurrentDraft() { + if (!hydratedRef.current) return; + if (savingRef.current) { + pendingSaveRef.current = true; + return; + } + const target = draftRef.current; + const targetSerialized = serializeDraft(target); + if (targetSerialized === lastSavedRef.current) { + setSaveState("saved"); + return; + } + savingRef.current = true; + pendingSaveRef.current = false; + let savedSuccessfully = false; + setSaveState("saving"); + setSaveError(null); + try { + let response = await putLayerComposition(jobId, versionRef.current, target); + if (response.status === 409) { + const latest = await fetch(`/api/layer-compositions/${encodeURIComponent(jobId)}`, { cache: "no-store" }); + const latestPayload = await readJSON(latest); + if (!latest.ok) throw new Error(payloadError(latestPayload, "同步图层工程版本失败")); + versionRef.current = isLayerComposition(latestPayload.composition) ? latestPayload.composition.version : 0; + response = await putLayerComposition(jobId, versionRef.current, target); + } + const payload = await readJSON(response); + if (!response.ok) throw new Error(payloadError(payload, "保存图层工程失败")); + if (!isLayerComposition(payload.composition)) throw new Error("保存图层工程返回数据不完整"); + versionRef.current = payload.composition.version; + lastSavedRef.current = targetSerialized; + savedSuccessfully = true; + setSaveState("saved"); + } catch (error) { + setSaveState("error"); + setSaveError(errorMessage(error, "保存图层工程失败")); + } finally { + savingRef.current = false; + if (savedSuccessfully && (pendingSaveRef.current || serializeDraft(draftRef.current) !== lastSavedRef.current)) { + pendingSaveRef.current = false; + window.setTimeout(() => void saveCurrentDraft(), 120); + } + } + } + + function updateDraft(updater: (current: SeedreamLayerDraft) => SeedreamLayerDraft) { + setDraft((current) => { + const next = updater(current); + draftRef.current = next; + return next; + }); + } + + function updateLayer(id: string, updater: (layer: SeedreamCompositionLayer) => SeedreamCompositionLayer) { + updateDraft((current) => ({ + ...current, + layers: current.layers.map((layer) => layer.id === id ? updater(layer) : layer) + })); + } + + function toggleVisibility(id: string) { + if (id === baseSelectionID) { + updateDraft((current) => ({ ...current, baseVisible: !current.baseVisible })); + return; + } + updateLayer(id, (layer) => ({ ...layer, visible: !layer.visible })); + } + + function reorderLayer(id: string, direction: -1 | 1) { + updateDraft((current) => { + const index = current.layers.findIndex((layer) => layer.id === id); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= current.layers.length) return current; + const layers = [...current.layers]; + [layers[index], layers[nextIndex]] = [layers[nextIndex], layers[index]]; + return { ...current, layers }; + }); + } + + function scaleLayer(id: string, factor: number) { + updateLayer(id, (layer) => ({ ...layer, box: scaleLayerBox(layer.box, factor) })); + } + + function rotateLayer(id: string, delta: number) { + updateLayer(id, (layer) => ({ ...layer, rotation: normalizeRotation(layer.rotation + delta) })); + } + + function resetLayout() { + const providerBoxes = new Map(providerLayers.map((output, index) => [output.asset.id, initialLayerBox(output, baseSize, index)])); + updateDraft((current) => ({ + ...current, + baseVisible: true, + layers: current.layers.map((layer) => ({ + ...layer, + visible: true, + rotation: 0, + box: providerBoxes.get(layer.assetId) || centeredLayerBox() + })) + })); + setInteraction(null); + setActionError(null); + setActionNotice("已重置当前图层布局,新增图层仍会保留。"); + } + + function removeLayer(id: string) { + const layer = draft.layers.find((item) => item.id === id); + if (!layer) return; + updateDraft((current) => ({ ...current, layers: current.layers.filter((item) => item.id !== id) })); + setSelectedId(baseSelectionID); + setActionNotice(`已从工程移除“${layer.name}”,原素材仍保留在素材库。`); + } + + function startInteraction(event: ReactPointerEvent, layer: SeedreamCompositionLayer, mode: LayerInteraction["mode"]) { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + setSelectedId(layer.id); + let startPointerAngle: number | undefined; + if (mode === "rotate" && previewRef.current) { + const bounds = previewRef.current.getBoundingClientRect(); + const centerX = bounds.left + ((layer.box[0] + layer.box[2]) / 2000) * bounds.width; + const centerY = bounds.top + ((layer.box[1] + layer.box[3]) / 2000) * bounds.height; + startPointerAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX); + } + setInteraction({ + id: layer.id, + mode, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startBox: layer.box, + startRotation: layer.rotation, + startPointerAngle + }); + } + + function continueInteraction(event: ReactPointerEvent) { + if (!interaction || interaction.pointerId !== event.pointerId || !previewRef.current) return; + event.preventDefault(); + const bounds = previewRef.current.getBoundingClientRect(); + if (!bounds.width || !bounds.height) return; + if (interaction.mode === "rotate") { + const centerX = bounds.left + ((interaction.startBox[0] + interaction.startBox[2]) / 2000) * bounds.width; + const centerY = bounds.top + ((interaction.startBox[1] + interaction.startBox[3]) / 2000) * bounds.height; + const pointerAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX); + const delta = (pointerAngle - (interaction.startPointerAngle || 0)) * 180 / Math.PI; + updateLayer(interaction.id, (layer) => ({ ...layer, rotation: normalizeRotation(interaction.startRotation + delta) })); + return; + } + const deltaX = (event.clientX - interaction.startX) / bounds.width * 1000; + const deltaY = (event.clientY - interaction.startY) / bounds.height * 1000; + const box = interaction.mode === "move" + ? moveLayerBox(interaction.startBox, deltaX, deltaY) + : resizeLayerBox(interaction.startBox, deltaX, deltaY); + updateLayer(interaction.id, (layer) => ({ ...layer, box })); + } + + function finishInteraction(event: ReactPointerEvent) { + if (interaction?.pointerId === event.pointerId) setInteraction(null); + } + + function handleLayerKeyDown(event: KeyboardEvent, id: string) { + const direction = event.key === "ArrowLeft" + ? [-1, 0] + : event.key === "ArrowRight" + ? [1, 0] + : event.key === "ArrowUp" + ? [0, -1] + : event.key === "ArrowDown" + ? [0, 1] + : null; + if (!direction) return; + event.preventDefault(); + const step = event.shiftKey ? 50 : 10; + updateLayer(id, (layer) => ({ ...layer, box: moveLayerBox(layer.box, direction[0] * step, direction[1] * step) })); + } + + async function exportComposition() { + setExporting(true); + setActionError(null); + try { + await downloadSeedreamLayerComposition({ + width: baseSize[0], + height: baseSize[1], + fileName: `seedream-composition-${jobId}.png`, + items: [ + { url: assetContentUrl(baseAsset), visible: draft.baseVisible, box: fullLayerBox, rotation: 0 }, + ...draft.layers.map((layer) => { + const asset = assetCache.get(layer.assetId); + return asset ? { url: assetContentUrl(asset), visible: layer.visible, box: layer.box, rotation: layer.rotation } : null; + }).filter((item): item is { url: string; visible: boolean; box: SeedreamLayerBox; rotation: number } => Boolean(item)) + ] + }); + } catch (error) { + setActionError(errorMessage(error, "导出合成图片失败")); + } finally { + setExporting(false); + } + } + + async function downloadAll() { + setZipDownloading(true); + setActionError(null); + try { + const items = [ + { asset: baseAsset, name: `00-底图-${baseAsset.name}` }, + ...draft.layers.map((layer, index) => ({ asset: assetCache.get(layer.assetId), name: `${String(index + 1).padStart(2, "0")}-${layer.name}` })) + ].filter((item): item is { asset: Asset; name: string } => Boolean(item.asset)); + await downloadFilesAsZip(items.map((item) => ({ name: safeDownloadName(item.name, item.asset.name), url: assetContentUrl(item.asset) })), `seedream-layer-project-${jobId}.zip`); + } catch (error) { + setActionError(errorMessage(error, "打包下载失败")); + } finally { + setZipDownloading(false); + } + } + + async function handleLayerUpload(event: ChangeEvent) { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (!files.length) return; + if (files.some((file) => !file.type.startsWith("image/"))) { + setActionError("新增图层只支持图片文件。"); + return; + } + setUploading(true); + setActionError(null); + try { + const formData = new FormData(); + files.forEach((file) => formData.append("files", file)); + const response = await fetch("/api/assets/upload", { method: "POST", body: formData }); + const payload = await readJSON(response); + if (!response.ok) throw new Error(payloadError(payload, "上传图层失败")); + const uploaded = Array.isArray(payload.assets) ? payload.assets as Asset[] : []; + if (!uploaded.length) throw new Error("上传成功,但没有返回可用图片素材"); + setAssetCache((current) => assetMap([...current.values(), ...uploaded])); + const additions = await Promise.all(uploaded.map((asset) => layerFromAsset(asset, "upload", baseSize))); + updateDraft((current) => ({ ...current, layers: [...current.layers, ...additions] })); + setSelectedId(additions[additions.length - 1].id); + setLibraryOpen(false); + setActionNotice(`已新增 ${additions.length} 个图层。`); + } catch (error) { + setActionError(errorMessage(error, "上传图层失败")); + } finally { + setUploading(false); + } + } + + async function addLibraryAsset(asset: Asset) { + setActionError(null); + const layer = await layerFromAsset(asset, "library", baseSize); + updateDraft((current) => ({ ...current, layers: [...current.layers, layer] })); + setSelectedId(layer.id); + setLibraryOpen(false); + setActionNotice(`已把“${asset.name}”加入图层工程。`); + } + + function openLayerEdit(layer: SeedreamCompositionLayer) { + setActionError(null); + setEditDialog({ layerId: layer.id, prompt: "", phase: "idle" }); + } + + async function submitLayerEdit() { + if (!editDialog) return; + const layer = draftRef.current.layers.find((item) => item.id === editDialog.layerId); + const asset = layer ? assetCache.get(layer.assetId) : undefined; + const instruction = editDialog.prompt.trim(); + if (!layer || !asset) { + setEditDialog((current) => current ? { ...current, error: "当前图层素材不存在" } : current); + return; + } + if (!instruction) { + setEditDialog((current) => current ? { ...current, error: "请先填写编辑指令" } : current); + return; + } + const controller = new AbortController(); + editAbortRef.current?.abort(); + editAbortRef.current = controller; + setEditDialog((current) => current ? { ...current, phase: "submitting", error: undefined, result: undefined, job: undefined } : current); + try { + const response = await fetch("/api/generations/image", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + capability: "image.generate", + engine: "seedream", + prompt: `请编辑 @图片1:${instruction}`, + materials: [{ id: asset.id, url: asset.url, name: asset.name, label: "@图片1", type: "image" }], + settings: { + size: "1.5K", + outputFormat: "png", + optimizeMode: "standard", + imageCount: 1, + creationMode: "interactive" + } + }), + signal: controller.signal + }); + const payload = await readJSON(response); + if (!response.ok) throw new Error(payloadError(payload, "提交图层编辑失败")); + const submitted = payload.job as GenerationJob | undefined; + if (!submitted?.id) throw new Error("图层编辑任务返回数据不完整"); + setEditDialog((current) => current ? { ...current, phase: "polling", job: submitted } : current); + const completed = await waitForImageJob(submitted.id, controller.signal); + if (completed.status !== "succeeded") throw new Error(completed.error?.message || "图层编辑失败"); + setEditDialog((current) => current ? { ...current, phase: "polling", job: completed } : current); + await resolveLayerEditResult(completed, controller.signal); + } catch (error) { + if (controller.signal.aborted) return; + setEditDialog((current) => current ? { ...current, phase: "idle", error: errorMessage(error, "图层编辑失败") } : current); + } + } + + async function retryLayerEditResultSync() { + const job = editDialog?.job; + if (!job?.id || job.status !== "succeeded") return; + const controller = new AbortController(); + editAbortRef.current?.abort(); + editAbortRef.current = controller; + setEditDialog((current) => current ? { ...current, phase: "polling", error: undefined } : current); + try { + await resolveLayerEditResult(job, controller.signal); + } catch (error) { + if (controller.signal.aborted) return; + setEditDialog((current) => current ? { ...current, phase: "idle", error: errorMessage(error, "同步编辑结果失败") } : current); + } + } + + async function resolveLayerEditResult(job: GenerationJob, signal: AbortSignal) { + const assets = await waitForJobOutputAssets(job, signal); + const result = assets[0]; + if (!result) throw new Error("编辑任务已完成,但结果素材同步超时;请点击“重新同步结果”,不会重复计费"); + setAssetCache((current) => assetMap([...current.values(), ...assets])); + setEditDialog((current) => current ? { ...current, phase: "ready", job, result, error: undefined } : current); + } + + function applyEditResult(mode: "replace" | "add") { + if (!editDialog?.result || !editDialog.job) return; + const source = draftRef.current.layers.find((layer) => layer.id === editDialog.layerId); + if (!source) return; + if (mode === "replace") { + updateLayer(source.id, (layer) => ({ + ...layer, + assetId: editDialog.result!.id, + source: "seedream-edit", + editJobId: editDialog.job!.id, + originalAssetId: layer.originalAssetId || layer.assetId + })); + setActionNotice(`已用即梦编辑结果替换“${source.name}”。`); + } else { + const addition: SeedreamCompositionLayer = { + ...source, + id: newLayerID(), + assetId: editDialog.result.id, + name: `${source.name} 编辑版`, + source: "seedream-edit", + editJobId: editDialog.job.id, + originalAssetId: source.assetId + }; + updateDraft((current) => { + const index = current.layers.findIndex((layer) => layer.id === source.id); + const layers = [...current.layers]; + layers.splice(index + 1, 0, addition); + return { ...current, layers }; + }); + setSelectedId(addition.id); + setActionNotice(`已把即梦编辑结果作为新图层加入工程。`); + } + setEditDialog(null); + } + + const selectedAsset = selectedLayer ? assetCache.get(selectedLayer.assetId) : undefined; + const editBusy = editDialog?.phase === "submitting" || editDialog?.phase === "polling"; + const canRetryEditResultSync = editDialog?.phase !== "ready" && editDialog?.job?.status === "succeeded" && !editDialog.result; + + return ( +
+
+ + {saveState === "loading" || saveState === "saving" ? : saveState === "saved" ? : null} + {saveStateLabel(saveState)} + + {saveState === "error" ? : null} + 图层位置、层级、显隐和旋转会保存到数据库 +
+ +
+
+
+ 拖动、缩放或旋转选中图层;方向键可微调 +
+ + + + + +
+
+
setSelectedId(baseSelectionID)} + > + {draft.baseVisible ? {base.name} : null} + {displayLayers.map((layer, stackIndex) => { + const asset = assetCache.get(layer.assetId); + if (!asset || !layer.visible) return null; + return ( + + ); + })} +
+ {selectedLayer ? ( +
+ {selectedLayer.name} + X {Math.round(selectedLayer.box[0] / 10)}% + Y {Math.round(selectedLayer.box[1] / 10)}% + 宽 {Math.round((selectedLayer.box[2] - selectedLayer.box[0]) / 10)}% + 高 {Math.round((selectedLayer.box[3] - selectedLayer.box[1]) / 10)}% + +
+ ) :
底图固定在画布底部
} +
+ 合成导出采用当前显隐、位置、尺寸、旋转和层级 +
+ + +
+
+ {saveError ?
{saveError}
: null} + {actionError ?
{actionError}
: null} + {actionNotice ?
{actionNotice}
: null} +
+ +
+
+
图层上方图层优先显示
+ +
+ {libraryOpen ? ( +
+
+ + + +
+ void handleLayerUpload(event)} /> +
+ {libraryAssets.map((asset) => ( + + ))} + {!libraryAssets.length ?
暂无匹配图片,可直接上传新图片。
: null} +
+
+ ) : null} + +
+ {listLayers.map((layer) => { + const asset = assetCache.get(layer.assetId); + const orderIndex = draft.layers.findIndex((item) => item.id === layer.id); + return ( +
setSelectedId(layer.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedId(layer.id); + } + }} + > + + {asset ? : } + + {layer.name} + {layerSourceLabel(layer.source)} · {Math.round(layer.rotation)}° + + + + + + + + + {asset ? event.stopPropagation()}> : null} + +
+ ); + })} +
setSelectedId(baseSelectionID)}> + + + 底图固定画布 + 固定 + event.stopPropagation()}> +
+
+
+
+ + {editDialog ? ( +
+
+
+
+ Seedream 5.0 Pro +

编辑图层:{draft.layers.find((layer) => layer.id === editDialog.layerId)?.name}

+
+ +
+
+
+ {editDialog.result ? 即梦编辑结果 : selectedAsset ? 待编辑图层 : } + {editDialog.result ? "编辑结果" : "当前图层"} +
+