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) ?
})
: 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