feat(server): add layer-separation generator task

Introduce the `layer-separation` generator (edit-elements node action)
that splits a source image into a clean background layer, foreground
image artifacts with bounding boxes, and editable text render data.

- New layer_separation.go builds the separated result and artifacts
- SubmitGeneratorTask routes layer-separation and move-object via a
  switch; standalone task completion + polling support
- Extend artifact metadata with BBox and GeneratorName across the
  domain, API types, mapper, .api schema, and API.md
- edit-elements gets a transparent manual-frame placeholder holding the
  separated foreground images and text layers beside the original

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:26:43 +08:00
parent 676e1c4c67
commit 039d166972
9 changed files with 1523 additions and 36 deletions
+131 -1
View File
@@ -21,7 +21,9 @@ type generatorTaskMetadata struct {
const (
removeBackgroundGeneratorName = "removeBG"
imageVectorizerGeneratorName = "gpt-5.5-vectorizer"
layerSeparationGeneratorName = "layer-separation"
moveObjectGeneratorName = "move-object"
layerSeparationMessageName = "layer_separation"
)
func (s *DesignService) GeneratorTask(ctx context.Context, projectID string, taskID string) (design.GeneratorTask, error) {
@@ -72,10 +74,17 @@ func (s *DesignService) SubmitGeneratorTask(ctx context.Context, req design.Gene
if generatorName == "" {
generatorName = moveObjectGeneratorName
}
if normalizeGeneratorTaskName(generatorName) != moveObjectGeneratorName {
switch normalizeGeneratorTaskName(generatorName) {
case moveObjectGeneratorName:
return s.submitMoveObjectTask(ctx, req)
case layerSeparationGeneratorName:
return s.submitLayerSeparationTask(ctx, req)
default:
return design.GeneratorTask{}, fmt.Errorf("%w: unsupported generator_name %s", design.ErrInvalidInput, req.GeneratorName)
}
}
func (s *DesignService) submitMoveObjectTask(ctx context.Context, req design.GeneratorTaskSubmitRequest) (design.GeneratorTask, error) {
input := normalizeMoveObjectInput(req.InputArgs)
if strings.TrimSpace(input.ImageURL) == "" {
return design.GeneratorTask{}, fmt.Errorf("%w: input_args.image_url is required", design.ErrInvalidInput)
@@ -101,6 +110,53 @@ func (s *DesignService) SubmitGeneratorTask(ctx context.Context, req design.Gene
return task, nil
}
func (s *DesignService) submitLayerSeparationTask(ctx context.Context, req design.GeneratorTaskSubmitRequest) (design.GeneratorTask, error) {
input := normalizeLayerSeparationInput(req.InputArgs)
if strings.TrimSpace(input.ImageURL) == "" {
return design.GeneratorTask{}, fmt.Errorf("%w: input_args.image_url is required", design.ErrInvalidInput)
}
taskID := newID()
price := generatorTaskPrice(input, layerSeparationGeneratorName)
task := design.GeneratorTask{
GeneratorTaskID: taskID,
GeneratorName: layerSeparationGeneratorName,
Status: "submitted",
OriginalInputArgs: input,
QueueInfo: generatorTaskQueueInfo(layerSeparationGeneratorName, input, price),
}
s.storeStandaloneGeneratorTask(task)
go s.completeStandaloneLayerSeparationTask(taskID, strings.TrimSpace(req.ProjectID), input)
return task, nil
}
func (s *DesignService) completeStandaloneLayerSeparationTask(taskID string, projectID string, input design.GeneratorTaskInputArgs) {
ctx, cancel := context.WithTimeout(context.Background(), generationBudget)
defer cancel()
imageURL := strings.TrimSpace(input.ImageURL)
target := design.Node{
ID: "source",
Type: design.NodeTypeImage,
Title: "Source image",
Content: imageURL,
Status: "success",
}
result, err := s.createLayerSeparationResult(ctx, standaloneArtifactProjectID(projectID, taskID), target, design.NodeActionRequest{
Action: "edit-elements",
Prompt: strings.TrimSpace(input.Prompt),
})
if err != nil {
s.markStandaloneGeneratorTaskFailed(taskID, err)
return
}
if len(result.Artifacts) == 0 {
s.markStandaloneGeneratorTaskFailed(taskID, fmt.Errorf("layer separation returned no artifacts"))
return
}
s.completeStandaloneGeneratorTask(taskID, result.Artifacts)
}
func (s *DesignService) completeStandaloneMoveObjectTask(taskID string, projectID string, input design.GeneratorTaskInputArgs) {
ctx, cancel := context.WithTimeout(context.Background(), generationBudget)
defer cancel()
@@ -218,6 +274,17 @@ func normalizeMoveObjectInput(input design.GeneratorTaskInputArgs) design.Genera
return input
}
func normalizeLayerSeparationInput(input design.GeneratorTaskInputArgs) design.GeneratorTaskInputArgs {
if strings.TrimSpace(input.ImageURL) == "" && len(input.Image) > 0 {
input.ImageURL = strings.TrimSpace(input.Image[0])
}
input.ImageURL = strings.TrimSpace(input.ImageURL)
input.Prompt = strings.TrimSpace(input.Prompt)
input.Resolution = strings.TrimSpace(input.Resolution)
input.AspectRatio = strings.TrimSpace(input.AspectRatio)
return input
}
func normalizeBox(box design.NormalizedBox) design.NormalizedBox {
width := clampUnit(box.Width)
height := clampUnit(box.Height)
@@ -427,6 +494,7 @@ func generatorTaskMessageCompleted(message design.Message) bool {
}
return name == "canvas_action" ||
name == "text_extraction" ||
name == layerSeparationMessageName ||
name == "mockup_model" ||
strings.TrimSpace(message.Role) == "assistant"
}
@@ -470,6 +538,8 @@ func generatorTaskGeneratorNameFromMessages(messages []design.Message, taskID st
return "gpt-image-2"
case strings.Contains(strings.ToLower(message.Title), "rmbg"):
return removeBackgroundGeneratorName
case message.Name == layerSeparationMessageName:
return layerSeparationGeneratorName
case message.Name == "mockup_model":
return "mockup/model"
case message.Name == "text_extraction":
@@ -498,6 +568,10 @@ func generatorTaskArtifacts(messages []design.Message, taskID string) []design.G
if message.ThreadID != taskID || !isToolArtifactsMessage(message) {
continue
}
if message.Name == layerSeparationMessageName || message.ToolHint == layerSeparationMessageName {
artifacts = append(artifacts, parseLayerSeparationArtifacts(message.Content)...)
continue
}
artifacts = append(artifacts, parseGeneratorArtifacts(message.Content)...)
}
return artifacts
@@ -549,6 +623,55 @@ func parseGeneratorArtifacts(content string) []design.GeneratorTaskArtifact {
return artifacts
}
func parseLayerSeparationArtifacts(content string) []design.GeneratorTaskArtifact {
var raw []struct {
Type string `json:"type"`
Content string `json:"content"`
Metadata struct {
ArtifactID string `json:"artifact_id"`
BBox []int64 `json:"bbox"`
Format string `json:"format"`
GeneratorName string `json:"generator_name"`
Height int64 `json:"height"`
Label string `json:"label"`
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
SizeBytes int64 `json:"size_bytes"`
Width int64 `json:"width"`
} `json:"metadata"`
}
if err := json.Unmarshal([]byte(content), &raw); err != nil {
return nil
}
artifacts := make([]design.GeneratorTaskArtifact, 0, len(raw))
for _, item := range raw {
artifactType := strings.TrimSpace(item.Type)
if artifactType == "" {
continue
}
if strings.TrimSpace(item.Content) == "" && artifactType != "text" {
continue
}
artifacts = append(artifacts, design.GeneratorTaskArtifact{
Type: artifactType,
Content: item.Content,
Metadata: design.GeneratorTaskArtifactMetadata{
ArtifactID: item.Metadata.ArtifactID,
BBox: append([]int64(nil), item.Metadata.BBox...),
Format: item.Metadata.Format,
GeneratorName: item.Metadata.GeneratorName,
Height: item.Metadata.Height,
Label: item.Metadata.Label,
NodeID: item.Metadata.NodeID,
NodeName: item.Metadata.NodeName,
SizeBytes: item.Metadata.SizeBytes,
Width: item.Metadata.Width,
},
})
}
return artifacts
}
func generatorArtifactFormat(format string, imageURL string) string {
format = strings.Trim(strings.ToLower(strings.TrimSpace(format)), ".")
if format != "" {
@@ -605,6 +728,8 @@ func generatorTaskPrice(input design.GeneratorTaskInputArgs, generatorName strin
return 4
case imageVectorizerGeneratorName:
return 14
case layerSeparationGeneratorName:
return 8
case moveObjectGeneratorName:
return 5
}
@@ -668,6 +793,8 @@ func generatorTaskNameForNodeAction(req design.NodeActionRequest) string {
switch normalizeNodeAction(req.Action) {
case "remove-background":
return removeBackgroundGeneratorName
case "edit-elements":
return layerSeparationGeneratorName
case "edit-text":
return "gpt-5.4-mini"
case "move-object":
@@ -749,6 +876,9 @@ func normalizeGeneratorTaskName(generatorName string) string {
if normalized == "move-object" || normalized == "moveobject" {
return moveObjectGeneratorName
}
if normalized == "layer-separation" || normalized == "layer_separation" || normalized == "edit-elements" || strings.HasSuffix(normalized, "/layer-separation") {
return layerSeparationGeneratorName
}
return name
}