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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user