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:
@@ -238,6 +238,25 @@ Returns the updated project, the assistant message, and generated nodes.
|
|||||||
|
|
||||||
The generation path uses the configured design agent runner. The default is the `sky-valley/pi` Go agent adapter, with local desktop/MCP agent behavior disabled.
|
The generation path uses the configured design agent runner. The default is the `sky-valley/pi` Go agent adapter, with local desktop/MCP agent behavior disabled.
|
||||||
|
|
||||||
|
## Generator Tasks
|
||||||
|
|
||||||
|
`POST /api/generator/tasks`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cid": "client-request-id",
|
||||||
|
"project_id": "project-id",
|
||||||
|
"generator_name": "layer-separation",
|
||||||
|
"input_args": {
|
||||||
|
"image_url": "https://cdn.example.com/source.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`layer-separation` returns a task id that can be polled with `GET /api/generator/tasks?task_id=<id>&project_id=<project-id>`. Completed tasks return a clean background image, foreground image artifacts with `bbox`, and a `text_render_data` artifact for editable text reconstruction.
|
||||||
|
|
||||||
|
The canvas `edit-elements` node action uses the same separation path but writes the separated result beside the original image. The original image remains unchanged; the clean background is a bottom image layer, and a transparent frame above it holds the separated foreground images and editable text layers.
|
||||||
|
|
||||||
## Chat History Replay
|
## Chat History Replay
|
||||||
|
|
||||||
`GET /api/projects/:id/history`
|
`GET /api/projects/:id/history`
|
||||||
|
|||||||
@@ -670,14 +670,16 @@ type GeneratorTaskQueueInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeneratorTaskArtifactMetadata {
|
type GeneratorTaskArtifactMetadata {
|
||||||
ArtifactId string `json:"artifact_id,optional"`
|
ArtifactId string `json:"artifact_id,optional"`
|
||||||
Format string `json:"format,optional"`
|
BBox []int64 `json:"bbox,optional"`
|
||||||
Height int64 `json:"height,optional"`
|
Format string `json:"format,optional"`
|
||||||
Label string `json:"label,optional"`
|
GeneratorName string `json:"generator_name,optional"`
|
||||||
NodeId string `json:"node_id,optional"`
|
Height int64 `json:"height,optional"`
|
||||||
NodeName string `json:"node_name,optional"`
|
Label string `json:"label,optional"`
|
||||||
SizeBytes int64 `json:"size_bytes,optional"`
|
NodeId string `json:"node_id,optional"`
|
||||||
Width int64 `json:"width,optional"`
|
NodeName string `json:"node_name,optional"`
|
||||||
|
SizeBytes int64 `json:"size_bytes,optional"`
|
||||||
|
Width int64 `json:"width,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeneratorTaskArtifact {
|
type GeneratorTaskArtifact {
|
||||||
@@ -1013,3 +1015,4 @@ service img_infinite_canvas-api {
|
|||||||
@handler deleteAsset
|
@handler deleteAsset
|
||||||
delete /assets (DeleteAssetRequest) returns (DeleteAssetResponse)
|
delete /assets (DeleteAssetRequest) returns (DeleteAssetResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -892,12 +892,14 @@ func (s *DesignService) RunNodeActionAsync(ctx context.Context, projectID string
|
|||||||
now := s.now()
|
now := s.now()
|
||||||
threadID := newID()
|
threadID := newID()
|
||||||
target := project.Nodes[nodeIndex]
|
target := project.Nodes[nodeIndex]
|
||||||
if req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" {
|
if req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" || req.Action == "edit-elements" {
|
||||||
resultNode := newBackgroundRemovalPlaceholder(target)
|
resultNode := newBackgroundRemovalPlaceholder(target)
|
||||||
if req.Action == "vectorize" {
|
if req.Action == "vectorize" {
|
||||||
resultNode = newVectorizePlaceholder(target)
|
resultNode = newVectorizePlaceholder(target)
|
||||||
} else if req.Action == "move-object" {
|
} else if req.Action == "move-object" {
|
||||||
resultNode = newMoveObjectPlaceholder(target)
|
resultNode = newMoveObjectPlaceholder(target)
|
||||||
|
} else if req.Action == "edit-elements" {
|
||||||
|
resultNode = newLayerSeparationPlaceholder(target)
|
||||||
}
|
}
|
||||||
if req.Options == nil {
|
if req.Options == nil {
|
||||||
req.Options = make(map[string]string)
|
req.Options = make(map[string]string)
|
||||||
@@ -1845,7 +1847,10 @@ func (s *DesignService) completeNodeActionGeneration(ctx context.Context, projec
|
|||||||
if req.Action == "vectorize" {
|
if req.Action == "vectorize" {
|
||||||
return s.completeVectorizeGeneration(ctx, projectID, threadID, target, req)
|
return s.completeVectorizeGeneration(ctx, projectID, threadID, target, req)
|
||||||
}
|
}
|
||||||
if req.Action == "edit-elements" || req.Action == "edit-text" {
|
if req.Action == "edit-elements" {
|
||||||
|
return s.completeLayerSeparationGeneration(ctx, projectID, threadID, target, req)
|
||||||
|
}
|
||||||
|
if req.Action == "edit-text" {
|
||||||
return s.completeEditableLayerGeneration(ctx, projectID, threadID, target, req)
|
return s.completeEditableLayerGeneration(ctx, projectID, threadID, target, req)
|
||||||
}
|
}
|
||||||
editToolContent := "正在根据选中图片执行模型编辑,完成后会替换画布中的图片节点。"
|
editToolContent := "正在根据选中图片执行模型编辑,完成后会替换画布中的图片节点。"
|
||||||
@@ -4709,8 +4714,30 @@ func newMoveObjectPlaceholder(target design.Node) design.Node {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newLayerSeparationPlaceholder(target design.Node) design.Node {
|
||||||
|
result := target
|
||||||
|
result.ID = newID()
|
||||||
|
result.Type = design.NodeTypeFrame
|
||||||
|
result.Title = nodeActionWorkingTitle("edit-elements")
|
||||||
|
result.X = target.X + target.Width + 32
|
||||||
|
result.Y = target.Y
|
||||||
|
result.Content = target.Content
|
||||||
|
result.Tone = "frame"
|
||||||
|
result.Status = "generating"
|
||||||
|
result.ParentID = ""
|
||||||
|
result.LayerRole = "manual-frame"
|
||||||
|
result.FillColor = "transparent"
|
||||||
|
result.StrokeColor = "#C7C7C7"
|
||||||
|
result.StrokeWidth = 1
|
||||||
|
result.StrokeStyle = "solid"
|
||||||
|
result.MockupSurface = design.MockupSurface{}
|
||||||
|
result.MockupModel = design.MockupModel{}
|
||||||
|
result.MockupSourceContent = ""
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func nodeActionResultNodeID(target design.Node, req design.NodeActionRequest) string {
|
func nodeActionResultNodeID(target design.Node, req design.NodeActionRequest) string {
|
||||||
if (req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object") && len(req.Options) > 0 {
|
if (req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" || req.Action == "edit-elements") && len(req.Options) > 0 {
|
||||||
if id := strings.TrimSpace(req.Options[nodeActionResultNodeIDOption]); id != "" {
|
if id := strings.TrimSpace(req.Options[nodeActionResultNodeIDOption]); id != "" {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ type generatorTaskMetadata struct {
|
|||||||
const (
|
const (
|
||||||
removeBackgroundGeneratorName = "removeBG"
|
removeBackgroundGeneratorName = "removeBG"
|
||||||
imageVectorizerGeneratorName = "gpt-5.5-vectorizer"
|
imageVectorizerGeneratorName = "gpt-5.5-vectorizer"
|
||||||
|
layerSeparationGeneratorName = "layer-separation"
|
||||||
moveObjectGeneratorName = "move-object"
|
moveObjectGeneratorName = "move-object"
|
||||||
|
layerSeparationMessageName = "layer_separation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *DesignService) GeneratorTask(ctx context.Context, projectID string, taskID string) (design.GeneratorTask, error) {
|
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 == "" {
|
if generatorName == "" {
|
||||||
generatorName = moveObjectGeneratorName
|
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)
|
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)
|
input := normalizeMoveObjectInput(req.InputArgs)
|
||||||
if strings.TrimSpace(input.ImageURL) == "" {
|
if strings.TrimSpace(input.ImageURL) == "" {
|
||||||
return design.GeneratorTask{}, fmt.Errorf("%w: input_args.image_url is required", design.ErrInvalidInput)
|
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
|
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) {
|
func (s *DesignService) completeStandaloneMoveObjectTask(taskID string, projectID string, input design.GeneratorTaskInputArgs) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), generationBudget)
|
ctx, cancel := context.WithTimeout(context.Background(), generationBudget)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -218,6 +274,17 @@ func normalizeMoveObjectInput(input design.GeneratorTaskInputArgs) design.Genera
|
|||||||
return input
|
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 {
|
func normalizeBox(box design.NormalizedBox) design.NormalizedBox {
|
||||||
width := clampUnit(box.Width)
|
width := clampUnit(box.Width)
|
||||||
height := clampUnit(box.Height)
|
height := clampUnit(box.Height)
|
||||||
@@ -427,6 +494,7 @@ func generatorTaskMessageCompleted(message design.Message) bool {
|
|||||||
}
|
}
|
||||||
return name == "canvas_action" ||
|
return name == "canvas_action" ||
|
||||||
name == "text_extraction" ||
|
name == "text_extraction" ||
|
||||||
|
name == layerSeparationMessageName ||
|
||||||
name == "mockup_model" ||
|
name == "mockup_model" ||
|
||||||
strings.TrimSpace(message.Role) == "assistant"
|
strings.TrimSpace(message.Role) == "assistant"
|
||||||
}
|
}
|
||||||
@@ -470,6 +538,8 @@ func generatorTaskGeneratorNameFromMessages(messages []design.Message, taskID st
|
|||||||
return "gpt-image-2"
|
return "gpt-image-2"
|
||||||
case strings.Contains(strings.ToLower(message.Title), "rmbg"):
|
case strings.Contains(strings.ToLower(message.Title), "rmbg"):
|
||||||
return removeBackgroundGeneratorName
|
return removeBackgroundGeneratorName
|
||||||
|
case message.Name == layerSeparationMessageName:
|
||||||
|
return layerSeparationGeneratorName
|
||||||
case message.Name == "mockup_model":
|
case message.Name == "mockup_model":
|
||||||
return "mockup/model"
|
return "mockup/model"
|
||||||
case message.Name == "text_extraction":
|
case message.Name == "text_extraction":
|
||||||
@@ -498,6 +568,10 @@ func generatorTaskArtifacts(messages []design.Message, taskID string) []design.G
|
|||||||
if message.ThreadID != taskID || !isToolArtifactsMessage(message) {
|
if message.ThreadID != taskID || !isToolArtifactsMessage(message) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if message.Name == layerSeparationMessageName || message.ToolHint == layerSeparationMessageName {
|
||||||
|
artifacts = append(artifacts, parseLayerSeparationArtifacts(message.Content)...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
artifacts = append(artifacts, parseGeneratorArtifacts(message.Content)...)
|
artifacts = append(artifacts, parseGeneratorArtifacts(message.Content)...)
|
||||||
}
|
}
|
||||||
return artifacts
|
return artifacts
|
||||||
@@ -549,6 +623,55 @@ func parseGeneratorArtifacts(content string) []design.GeneratorTaskArtifact {
|
|||||||
return artifacts
|
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 {
|
func generatorArtifactFormat(format string, imageURL string) string {
|
||||||
format = strings.Trim(strings.ToLower(strings.TrimSpace(format)), ".")
|
format = strings.Trim(strings.ToLower(strings.TrimSpace(format)), ".")
|
||||||
if format != "" {
|
if format != "" {
|
||||||
@@ -605,6 +728,8 @@ func generatorTaskPrice(input design.GeneratorTaskInputArgs, generatorName strin
|
|||||||
return 4
|
return 4
|
||||||
case imageVectorizerGeneratorName:
|
case imageVectorizerGeneratorName:
|
||||||
return 14
|
return 14
|
||||||
|
case layerSeparationGeneratorName:
|
||||||
|
return 8
|
||||||
case moveObjectGeneratorName:
|
case moveObjectGeneratorName:
|
||||||
return 5
|
return 5
|
||||||
}
|
}
|
||||||
@@ -668,6 +793,8 @@ func generatorTaskNameForNodeAction(req design.NodeActionRequest) string {
|
|||||||
switch normalizeNodeAction(req.Action) {
|
switch normalizeNodeAction(req.Action) {
|
||||||
case "remove-background":
|
case "remove-background":
|
||||||
return removeBackgroundGeneratorName
|
return removeBackgroundGeneratorName
|
||||||
|
case "edit-elements":
|
||||||
|
return layerSeparationGeneratorName
|
||||||
case "edit-text":
|
case "edit-text":
|
||||||
return "gpt-5.4-mini"
|
return "gpt-5.4-mini"
|
||||||
case "move-object":
|
case "move-object":
|
||||||
@@ -749,6 +876,9 @@ func normalizeGeneratorTaskName(generatorName string) string {
|
|||||||
if normalized == "move-object" || normalized == "moveobject" {
|
if normalized == "move-object" || normalized == "moveobject" {
|
||||||
return moveObjectGeneratorName
|
return moveObjectGeneratorName
|
||||||
}
|
}
|
||||||
|
if normalized == "layer-separation" || normalized == "layer_separation" || normalized == "edit-elements" || strings.HasSuffix(normalized, "/layer-separation") {
|
||||||
|
return layerSeparationGeneratorName
|
||||||
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,892 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/domain/design"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
layerSeparationForegroundLabel = "fg_image"
|
||||||
|
layerSeparationBackgroundLabel = "bg_image"
|
||||||
|
layerSeparationTextLabel = "text_render_data"
|
||||||
|
)
|
||||||
|
|
||||||
|
type layerSeparationResult struct {
|
||||||
|
CanvasWidth int64
|
||||||
|
CanvasHeight int64
|
||||||
|
BackgroundID string
|
||||||
|
BackgroundURL string
|
||||||
|
Foregrounds []layerSeparationForeground
|
||||||
|
TextExtraction design.TextExtraction
|
||||||
|
Artifacts []design.GeneratorTaskArtifact
|
||||||
|
}
|
||||||
|
|
||||||
|
type layerSeparationForeground struct {
|
||||||
|
ID string
|
||||||
|
URL string
|
||||||
|
BBox []int64
|
||||||
|
Width int64
|
||||||
|
Height int64
|
||||||
|
Mask *image.Alpha
|
||||||
|
Rect image.Rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
type layerSeparationForegroundCandidate struct {
|
||||||
|
BBox []int64
|
||||||
|
Width int64
|
||||||
|
Height int64
|
||||||
|
Mask *image.Alpha
|
||||||
|
Rect image.Rectangle
|
||||||
|
Image image.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DesignService) completeLayerSeparationGeneration(ctx context.Context, projectID string, threadID string, target design.Node, req design.NodeActionRequest) error {
|
||||||
|
resultNodeID := nodeActionResultNodeID(target, req)
|
||||||
|
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "图层分离", "正在拆分底图、前景元素和可编辑文字层。"); err != nil {
|
||||||
|
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultTarget := target
|
||||||
|
resultTarget.ID = resultNodeID
|
||||||
|
result, err := s.createLayerSeparationResult(ctx, projectID, resultTarget, req)
|
||||||
|
if err != nil {
|
||||||
|
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := s.repo.Get(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nodeIndex := findNodeIndex(project.Nodes, resultNodeID)
|
||||||
|
if nodeIndex < 0 {
|
||||||
|
project.Nodes = append(project.Nodes, newLayerSeparationPlaceholder(target))
|
||||||
|
nodeIndex = len(project.Nodes) - 1
|
||||||
|
project.Nodes[nodeIndex].ID = resultNodeID
|
||||||
|
}
|
||||||
|
|
||||||
|
frameNode := project.Nodes[nodeIndex]
|
||||||
|
if frameNode.Width <= 0 {
|
||||||
|
frameNode.Width = float64(result.CanvasWidth)
|
||||||
|
}
|
||||||
|
if frameNode.Height <= 0 {
|
||||||
|
frameNode.Height = float64(result.CanvasHeight)
|
||||||
|
}
|
||||||
|
frameNode.Type = design.NodeTypeFrame
|
||||||
|
frameNode.Title = "Frame"
|
||||||
|
frameNode.Content = ""
|
||||||
|
frameNode.Tone = "frame"
|
||||||
|
frameNode.Status = "success"
|
||||||
|
frameNode.ParentID = ""
|
||||||
|
frameNode.LayerRole = "manual-frame"
|
||||||
|
frameNode.FillColor = "transparent"
|
||||||
|
frameNode.StrokeColor = "#C7C7C7"
|
||||||
|
frameNode.StrokeWidth = 1
|
||||||
|
frameNode.StrokeStyle = "solid"
|
||||||
|
frameNode.MockupSurface = design.MockupSurface{}
|
||||||
|
frameNode.MockupModel = design.MockupModel{}
|
||||||
|
frameNode.MockupSourceContent = ""
|
||||||
|
|
||||||
|
now := s.now()
|
||||||
|
backgroundNode := newLayerSeparationBackgroundNode(frameNode, result)
|
||||||
|
foregroundNodes := newForegroundImageLayers(frameNode, result.Foregrounds, result.CanvasWidth, result.CanvasHeight)
|
||||||
|
editableTextNodes := newEditableTextLayers(frameNode, result.TextExtraction)
|
||||||
|
replacementNodes := make([]design.Node, 0, len(foregroundNodes)+len(editableTextNodes)+2)
|
||||||
|
replacementNodes = append(replacementNodes, backgroundNode, frameNode)
|
||||||
|
replacementNodes = append(replacementNodes, foregroundNodes...)
|
||||||
|
replacementNodes = append(replacementNodes, editableTextNodes...)
|
||||||
|
project.Nodes = append(project.Nodes[:nodeIndex], append(replacementNodes, project.Nodes[nodeIndex+1:]...)...)
|
||||||
|
project.Messages = append(project.Messages, layerSeparationArtifactsMessage(threadID, result.Artifacts, now.Add(-time.Millisecond)))
|
||||||
|
project.Messages = append(project.Messages, design.Message{
|
||||||
|
ID: newID(),
|
||||||
|
Role: "assistant",
|
||||||
|
Type: "assistant",
|
||||||
|
Title: nodeActionDoneTitle(req.Action),
|
||||||
|
Content: nodeActionDoneContent(req.Action, req.Prompt),
|
||||||
|
ThreadID: threadID,
|
||||||
|
ActionID: threadID,
|
||||||
|
StepID: newID(),
|
||||||
|
Name: "canvas_action",
|
||||||
|
ToolHint: "canvas_action",
|
||||||
|
Status: "success",
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
project.Connections = nil
|
||||||
|
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
|
||||||
|
project.UpdatedAt = now
|
||||||
|
project = s.refreshCanvasSnapshot(project, now)
|
||||||
|
|
||||||
|
return s.repo.Save(ctx, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DesignService) createLayerSeparationResult(ctx context.Context, projectID string, target design.Node, req design.NodeActionRequest) (layerSeparationResult, error) {
|
||||||
|
sourceData, sourceContentType, err := loadImageContent(ctx, target.Content)
|
||||||
|
if err != nil {
|
||||||
|
return layerSeparationResult{}, err
|
||||||
|
}
|
||||||
|
source, _, err := image.Decode(bytes.NewReader(sourceData))
|
||||||
|
if err != nil {
|
||||||
|
return layerSeparationResult{}, err
|
||||||
|
}
|
||||||
|
bounds := source.Bounds()
|
||||||
|
canvasWidth := int64(bounds.Dx())
|
||||||
|
canvasHeight := int64(bounds.Dy())
|
||||||
|
canvas := image.NewRGBA(bounds)
|
||||||
|
draw.Draw(canvas, bounds, source, bounds.Min, draw.Src)
|
||||||
|
|
||||||
|
extraction, err := s.extractLayerSeparationText(ctx, target, req)
|
||||||
|
if err != nil {
|
||||||
|
return layerSeparationResult{}, err
|
||||||
|
}
|
||||||
|
for _, layer := range extraction.Layers {
|
||||||
|
removeTextLayerFromImage(canvas, layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
foregrounds := make([]layerSeparationForeground, 0, 1)
|
||||||
|
if fullForeground, elementForegrounds, ok := s.createLayerSeparationForegroundCandidates(ctx, target, sourceData, sourceContentType, bounds, extraction); ok {
|
||||||
|
if len(extraction.Layers) == 0 {
|
||||||
|
if plainBackground, ok := plainLayerSeparationBackgroundImage(canvas, fullForeground.Mask, fullForeground.Rect); ok {
|
||||||
|
if foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, fullForeground); ok {
|
||||||
|
foregrounds = append(foregrounds, foreground)
|
||||||
|
}
|
||||||
|
canvas = plainBackground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(foregrounds) == 0 {
|
||||||
|
for _, candidate := range elementForegrounds {
|
||||||
|
foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, candidate)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removeForegroundMaskFromImage(canvas, foreground.Mask, foreground.Rect)
|
||||||
|
foregrounds = append(foregrounds, foreground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(foregrounds) == 0 && len(extraction.Layers) == 0 {
|
||||||
|
if foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, fullForeground); ok {
|
||||||
|
removeForegroundMaskFromImage(canvas, foreground.Mask, foreground.Rect)
|
||||||
|
foregrounds = append(foregrounds, foreground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(extraction.Layers) == 0 && len(foregrounds) == 1 {
|
||||||
|
if plainBackground, ok := plainLayerSeparationBackgroundImage(canvas, foregrounds[0].Mask, foregrounds[0].Rect); ok {
|
||||||
|
canvas = plainBackground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundID := newID()
|
||||||
|
backgroundURL, backgroundSize, err := s.persistLayerSeparationImage(ctx, projectID, backgroundID, "background", canvas)
|
||||||
|
if err != nil {
|
||||||
|
return layerSeparationResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
textRenderData, err := layerSeparationTextRenderData(extraction, canvasWidth, canvasHeight)
|
||||||
|
if err != nil {
|
||||||
|
return layerSeparationResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts := make([]design.GeneratorTaskArtifact, 0, len(foregrounds)+2)
|
||||||
|
for _, foreground := range foregrounds {
|
||||||
|
artifacts = append(artifacts, design.GeneratorTaskArtifact{
|
||||||
|
Type: "image",
|
||||||
|
Content: foreground.URL,
|
||||||
|
Metadata: design.GeneratorTaskArtifactMetadata{
|
||||||
|
ArtifactID: foreground.ID,
|
||||||
|
BBox: append([]int64(nil), foreground.BBox...),
|
||||||
|
Format: generatorArtifactFormat("", foreground.URL),
|
||||||
|
GeneratorName: layerSeparationGeneratorName,
|
||||||
|
Height: foreground.Height,
|
||||||
|
Label: layerSeparationForegroundLabel,
|
||||||
|
NodeID: foreground.ID,
|
||||||
|
NodeName: "Foreground Layer",
|
||||||
|
SizeBytes: 0,
|
||||||
|
Width: foreground.Width,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
artifacts = append(artifacts, design.GeneratorTaskArtifact{
|
||||||
|
Type: "image",
|
||||||
|
Content: backgroundURL,
|
||||||
|
Metadata: design.GeneratorTaskArtifactMetadata{
|
||||||
|
ArtifactID: backgroundID,
|
||||||
|
Format: generatorArtifactFormat("", backgroundURL),
|
||||||
|
GeneratorName: layerSeparationGeneratorName,
|
||||||
|
Height: canvasHeight,
|
||||||
|
Label: layerSeparationBackgroundLabel,
|
||||||
|
NodeID: backgroundID,
|
||||||
|
NodeName: "Image",
|
||||||
|
SizeBytes: backgroundSize,
|
||||||
|
Width: canvasWidth,
|
||||||
|
},
|
||||||
|
}, design.GeneratorTaskArtifact{
|
||||||
|
Type: "text",
|
||||||
|
Content: textRenderData,
|
||||||
|
Metadata: design.GeneratorTaskArtifactMetadata{
|
||||||
|
ArtifactID: newID(),
|
||||||
|
GeneratorName: layerSeparationGeneratorName,
|
||||||
|
Height: canvasHeight,
|
||||||
|
Label: layerSeparationTextLabel,
|
||||||
|
Width: canvasWidth,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return layerSeparationResult{
|
||||||
|
CanvasWidth: canvasWidth,
|
||||||
|
CanvasHeight: canvasHeight,
|
||||||
|
BackgroundID: backgroundID,
|
||||||
|
BackgroundURL: backgroundURL,
|
||||||
|
Foregrounds: foregrounds,
|
||||||
|
TextExtraction: extraction,
|
||||||
|
Artifacts: artifacts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DesignService) extractLayerSeparationText(ctx context.Context, target design.Node, req design.NodeActionRequest) (design.TextExtraction, error) {
|
||||||
|
if extraction, ok, err := textExtractionFromOptions(req.Options); ok || err != nil {
|
||||||
|
return extraction, err
|
||||||
|
}
|
||||||
|
if s.textExtractor == nil {
|
||||||
|
return design.TextExtraction{}, nil
|
||||||
|
}
|
||||||
|
return s.extractTextExtraction(ctx, target, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DesignService) createLayerSeparationForegroundCandidates(ctx context.Context, target design.Node, sourceData []byte, sourceContentType string, bounds image.Rectangle, extraction design.TextExtraction) (layerSeparationForegroundCandidate, []layerSeparationForegroundCandidate, bool) {
|
||||||
|
if s.backgroundRemover == nil {
|
||||||
|
return layerSeparationForegroundCandidate{}, nil, false
|
||||||
|
}
|
||||||
|
removed, err := s.backgroundRemover.RemoveBackground(ctx, design.BackgroundRemovalRequest{
|
||||||
|
Image: sourceData,
|
||||||
|
ContentType: sourceContentType,
|
||||||
|
Width: bounds.Dx(),
|
||||||
|
Height: bounds.Dy(),
|
||||||
|
})
|
||||||
|
if err != nil || len(removed.Image) == 0 {
|
||||||
|
return layerSeparationForegroundCandidate{}, nil, false
|
||||||
|
}
|
||||||
|
foregroundImage, _, err := image.Decode(bytes.NewReader(removed.Image))
|
||||||
|
if err != nil {
|
||||||
|
return layerSeparationForegroundCandidate{}, nil, false
|
||||||
|
}
|
||||||
|
foreground := image.NewNRGBA(foregroundImage.Bounds())
|
||||||
|
draw.Draw(foreground, foreground.Bounds(), foregroundImage, foregroundImage.Bounds().Min, draw.Src)
|
||||||
|
eraseTextLayersFromForeground(foreground, extraction)
|
||||||
|
foregroundBounds := foreground.Bounds()
|
||||||
|
rect, ok := alphaContentBounds(foreground, foregroundBounds, 12)
|
||||||
|
if !ok {
|
||||||
|
return layerSeparationForegroundCandidate{}, nil, false
|
||||||
|
}
|
||||||
|
mask, count := alphaMask(foreground, foregroundBounds, 12)
|
||||||
|
if count == 0 {
|
||||||
|
return layerSeparationForegroundCandidate{}, nil, false
|
||||||
|
}
|
||||||
|
full := newLayerSeparationForegroundCandidate(foreground, mask, rect, foregroundBounds)
|
||||||
|
return full, layerSeparationElementCandidates(foreground, foregroundBounds), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DesignService) persistLayerSeparationForeground(ctx context.Context, projectID string, candidate layerSeparationForegroundCandidate) (layerSeparationForeground, bool) {
|
||||||
|
if candidate.Image == nil || candidate.Mask == nil || candidate.Rect.Empty() || len(candidate.BBox) < 4 {
|
||||||
|
return layerSeparationForeground{}, false
|
||||||
|
}
|
||||||
|
foregroundID := newID()
|
||||||
|
foregroundURL, _, err := s.persistLayerSeparationImage(ctx, projectID, foregroundID, "foreground", candidate.Image)
|
||||||
|
if err != nil {
|
||||||
|
return layerSeparationForeground{}, false
|
||||||
|
}
|
||||||
|
return layerSeparationForeground{
|
||||||
|
ID: foregroundID,
|
||||||
|
URL: foregroundURL,
|
||||||
|
BBox: append([]int64(nil), candidate.BBox...),
|
||||||
|
Width: candidate.Width,
|
||||||
|
Height: candidate.Height,
|
||||||
|
Mask: candidate.Mask,
|
||||||
|
Rect: candidate.Rect,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLayerSeparationForegroundCandidate(foreground *image.NRGBA, mask *image.Alpha, rect image.Rectangle, bounds image.Rectangle) layerSeparationForegroundCandidate {
|
||||||
|
return layerSeparationForegroundCandidate{
|
||||||
|
BBox: []int64{
|
||||||
|
int64(rect.Min.X - bounds.Min.X),
|
||||||
|
int64(rect.Min.Y - bounds.Min.Y),
|
||||||
|
int64(rect.Max.X - bounds.Min.X),
|
||||||
|
int64(rect.Max.Y - bounds.Min.Y),
|
||||||
|
},
|
||||||
|
Width: int64(rect.Dx()),
|
||||||
|
Height: int64(rect.Dy()),
|
||||||
|
Mask: mask,
|
||||||
|
Rect: rect,
|
||||||
|
Image: foregroundImageFromMask(foreground, mask, rect),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eraseTextLayersFromForeground(foreground *image.NRGBA, extraction design.TextExtraction) {
|
||||||
|
if foreground == nil || len(extraction.Layers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bounds := foreground.Bounds()
|
||||||
|
if bounds.Dx()*bounds.Dy() < 4096 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, layer := range extraction.Layers {
|
||||||
|
rect := normalizedTextMaskRect(bounds, layer)
|
||||||
|
if rect.Empty() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
padding := clampInt(int(math.Round(math.Min(float64(rect.Dx()), float64(rect.Dy()))*0.14)), 2, 18)
|
||||||
|
rect = image.Rect(rect.Min.X-padding, rect.Min.Y-padding, rect.Max.X+padding, rect.Max.Y+padding).Intersect(bounds)
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
offset := foreground.PixOffset(x, y)
|
||||||
|
foreground.Pix[offset+3] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationElementCandidates(foreground *image.NRGBA, bounds image.Rectangle) []layerSeparationForegroundCandidate {
|
||||||
|
if foreground == nil || bounds.Empty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
width := bounds.Dx()
|
||||||
|
height := bounds.Dy()
|
||||||
|
visited := make([]bool, width*height)
|
||||||
|
components := make([]layerSeparationForegroundCandidate, 0, 4)
|
||||||
|
indexOf := func(x int, y int) int {
|
||||||
|
return (y-bounds.Min.Y)*width + (x - bounds.Min.X)
|
||||||
|
}
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
index := indexOf(x, y)
|
||||||
|
if visited[index] || foreground.NRGBAAt(x, y).A <= 12 {
|
||||||
|
visited[index] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mask, rect, count := layerSeparationComponentMask(foreground, bounds, visited, x, y, indexOf)
|
||||||
|
if !keepLayerSeparationElement(rect, count, bounds) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
components = append(components, newLayerSeparationForegroundCandidate(foreground, mask, rect, bounds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(components, func(i int, j int) bool {
|
||||||
|
leftArea := components[i].Rect.Dx() * components[i].Rect.Dy()
|
||||||
|
rightArea := components[j].Rect.Dx() * components[j].Rect.Dy()
|
||||||
|
return leftArea > rightArea
|
||||||
|
})
|
||||||
|
if len(components) > 12 {
|
||||||
|
components = components[:12]
|
||||||
|
}
|
||||||
|
return components
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationComponentMask(foreground *image.NRGBA, bounds image.Rectangle, visited []bool, startX int, startY int, indexOf func(int, int) int) (*image.Alpha, image.Rectangle, int) {
|
||||||
|
mask := image.NewAlpha(bounds)
|
||||||
|
stack := []image.Point{{X: startX, Y: startY}}
|
||||||
|
minX, minY := startX, startY
|
||||||
|
maxX, maxY := startX+1, startY+1
|
||||||
|
count := 0
|
||||||
|
for len(stack) > 0 {
|
||||||
|
point := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
if point.X < bounds.Min.X || point.X >= bounds.Max.X || point.Y < bounds.Min.Y || point.Y >= bounds.Max.Y {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index := indexOf(point.X, point.Y)
|
||||||
|
if visited[index] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited[index] = true
|
||||||
|
if foreground.NRGBAAt(point.X, point.Y).A <= 12 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mask.SetAlpha(point.X, point.Y, color.Alpha{A: 255})
|
||||||
|
count++
|
||||||
|
if point.X < minX {
|
||||||
|
minX = point.X
|
||||||
|
}
|
||||||
|
if point.Y < minY {
|
||||||
|
minY = point.Y
|
||||||
|
}
|
||||||
|
if point.X+1 > maxX {
|
||||||
|
maxX = point.X + 1
|
||||||
|
}
|
||||||
|
if point.Y+1 > maxY {
|
||||||
|
maxY = point.Y + 1
|
||||||
|
}
|
||||||
|
stack = append(stack,
|
||||||
|
image.Point{X: point.X + 1, Y: point.Y},
|
||||||
|
image.Point{X: point.X - 1, Y: point.Y},
|
||||||
|
image.Point{X: point.X, Y: point.Y + 1},
|
||||||
|
image.Point{X: point.X, Y: point.Y - 1},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return mask, image.Rect(minX, minY, maxX, maxY), count
|
||||||
|
}
|
||||||
|
|
||||||
|
func keepLayerSeparationElement(rect image.Rectangle, count int, bounds image.Rectangle) bool {
|
||||||
|
if rect.Empty() || count <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
totalArea := bounds.Dx() * bounds.Dy()
|
||||||
|
if totalArea <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if totalArea < 4096 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
bboxArea := rect.Dx() * rect.Dy()
|
||||||
|
minArea := maxInt(96, totalArea/6000)
|
||||||
|
if count < minArea || bboxArea < minArea {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if float64(bboxArea)/float64(totalArea) > 0.18 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if float64(rect.Dx())/float64(bounds.Dx()) > 0.62 || float64(rect.Dy())/float64(bounds.Dy()) > 0.62 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func foregroundImageFromMask(foreground *image.NRGBA, mask *image.Alpha, rect image.Rectangle) image.Image {
|
||||||
|
cropped := image.NewNRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
if mask.AlphaAt(x, y).A == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cropped.SetNRGBA(x-rect.Min.X, y-rect.Min.Y, foreground.NRGBAAt(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cropped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DesignService) persistLayerSeparationImage(ctx context.Context, projectID string, nodeID string, suffix string, img image.Image) (string, int64, error) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := png.Encode(&output, img); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
if s.assets == nil {
|
||||||
|
data := output.Bytes()
|
||||||
|
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), int64(len(data)), nil
|
||||||
|
}
|
||||||
|
data, contentType, err := encodeRasterAssetAsWebP(output.Bytes(), "image/png")
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
object, err := s.assets.PutObject(ctx, design.AssetObjectRequest{
|
||||||
|
FileName: fmt.Sprintf("%s-%s-%s.png", projectID, nodeID, suffix),
|
||||||
|
ContentType: contentType,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(object.PublicURL) == "" {
|
||||||
|
return "", 0, fmt.Errorf("layer separation upload returned empty public URL")
|
||||||
|
}
|
||||||
|
return object.PublicURL, int64(len(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLayerSeparationBackgroundNode(frame design.Node, result layerSeparationResult) design.Node {
|
||||||
|
return design.Node{
|
||||||
|
ID: result.BackgroundID,
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Title: "Image",
|
||||||
|
X: frame.X,
|
||||||
|
Y: frame.Y,
|
||||||
|
Width: frame.Width,
|
||||||
|
Height: frame.Height,
|
||||||
|
Content: result.BackgroundURL,
|
||||||
|
Tone: "visual",
|
||||||
|
Status: "success",
|
||||||
|
LayerRole: "clean-image",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newForegroundImageLayers(target design.Node, foregrounds []layerSeparationForeground, canvasWidth int64, canvasHeight int64) []design.Node {
|
||||||
|
if len(foregrounds) == 0 || canvasWidth <= 0 || canvasHeight <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nodes := make([]design.Node, 0, len(foregrounds))
|
||||||
|
for index, foreground := range foregrounds {
|
||||||
|
if len(foreground.BBox) < 4 || strings.TrimSpace(foreground.URL) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x1 := clampFloat(float64(foreground.BBox[0])/float64(canvasWidth), 0, 1)
|
||||||
|
y1 := clampFloat(float64(foreground.BBox[1])/float64(canvasHeight), 0, 1)
|
||||||
|
x2 := clampFloat(float64(foreground.BBox[2])/float64(canvasWidth), x1, 1)
|
||||||
|
y2 := clampFloat(float64(foreground.BBox[3])/float64(canvasHeight), y1, 1)
|
||||||
|
width := target.Width * (x2 - x1)
|
||||||
|
height := target.Height * (y2 - y1)
|
||||||
|
if width <= 1 || height <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nodes = append(nodes, design.Node{
|
||||||
|
ID: foreground.ID,
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Title: fmt.Sprintf("Foreground Layer %d", index+1),
|
||||||
|
X: target.X + target.Width*x1,
|
||||||
|
Y: target.Y + target.Height*y1,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Content: foreground.URL,
|
||||||
|
Tone: "separated-element",
|
||||||
|
Status: "success",
|
||||||
|
ParentID: target.ID,
|
||||||
|
LayerRole: "foreground-image",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationArtifactsMessage(threadID string, artifacts []design.GeneratorTaskArtifact, createdAt time.Time) design.Message {
|
||||||
|
content, _ := json.Marshal(toLayerSeparationArtifactWire(artifacts))
|
||||||
|
return design.Message{
|
||||||
|
ID: newID(),
|
||||||
|
Role: "tool",
|
||||||
|
Type: "tool_use",
|
||||||
|
Title: layerSeparationMessageName,
|
||||||
|
Content: string(content),
|
||||||
|
ThreadID: threadID,
|
||||||
|
ActionID: threadID,
|
||||||
|
StepID: newID(),
|
||||||
|
ToolCallID: "call_" + strings.ReplaceAll(newID(), "-", ""),
|
||||||
|
Name: layerSeparationMessageName,
|
||||||
|
ToolHint: layerSeparationMessageName,
|
||||||
|
Status: "success",
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLayerSeparationArtifactWire(artifacts []design.GeneratorTaskArtifact) []map[string]any {
|
||||||
|
items := make([]map[string]any, 0, len(artifacts))
|
||||||
|
for _, artifact := range artifacts {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"type": artifact.Type,
|
||||||
|
"content": artifact.Content,
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"artifact_id": artifact.Metadata.ArtifactID,
|
||||||
|
"bbox": artifact.Metadata.BBox,
|
||||||
|
"format": artifact.Metadata.Format,
|
||||||
|
"generator_name": artifact.Metadata.GeneratorName,
|
||||||
|
"height": artifact.Metadata.Height,
|
||||||
|
"label": artifact.Metadata.Label,
|
||||||
|
"node_id": artifact.Metadata.NodeID,
|
||||||
|
"node_name": artifact.Metadata.NodeName,
|
||||||
|
"size_bytes": artifact.Metadata.SizeBytes,
|
||||||
|
"width": artifact.Metadata.Width,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeForegroundMaskFromImage(img *image.RGBA, mask *image.Alpha, rect image.Rectangle) {
|
||||||
|
if mask == nil || rect.Empty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
rect = rect.Intersect(bounds)
|
||||||
|
if rect.Empty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
radius := clampInt(int(math.Round(math.Min(float64(rect.Dx()), float64(rect.Dy()))*0.025)), 2, 10)
|
||||||
|
mask, count := dilateTextMask(mask, rect, radius)
|
||||||
|
if count == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source := image.NewRGBA(bounds)
|
||||||
|
draw.Draw(source, bounds, img, bounds.Min, draw.Src)
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
if mask.AlphaAt(x, y).A == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img.SetRGBA(x, y, estimatedRectBackgroundColor(source, rect, x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
softenMaskedPixels(img, mask, rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func plainLayerSeparationBackgroundImage(img *image.RGBA, mask *image.Alpha, rect image.Rectangle) (*image.RGBA, bool) {
|
||||||
|
if img == nil || mask == nil || rect.Empty() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
if bounds.Empty() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
radius := clampInt(int(math.Round(math.Min(float64(rect.Dx()), float64(rect.Dy()))*0.02)), 3, 18)
|
||||||
|
expandedMask, _ := dilateTextMask(mask, bounds, radius)
|
||||||
|
stats, ok := unmaskedColorStats(img, expandedMask, bounds)
|
||||||
|
if !ok || stats.StdDev > 24 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
bg := image.NewRGBA(bounds)
|
||||||
|
fill := colorRGBA(int(math.Round(stats.R)), int(math.Round(stats.G)), int(math.Round(stats.B)), int(math.Round(stats.A)))
|
||||||
|
draw.Draw(bg, bounds, &image.Uniform{C: fill}, image.Point{}, draw.Src)
|
||||||
|
return bg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type layerSeparationColorStats struct {
|
||||||
|
Count int64
|
||||||
|
R float64
|
||||||
|
G float64
|
||||||
|
B float64
|
||||||
|
A float64
|
||||||
|
StdDev float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmaskedColorStats(img *image.RGBA, mask *image.Alpha, bounds image.Rectangle) (layerSeparationColorStats, bool) {
|
||||||
|
stride := clampInt(int(math.Ceil(float64(maxInt(bounds.Dx(), bounds.Dy()))/640)), 1, 12)
|
||||||
|
var count int64
|
||||||
|
var r, g, b, a float64
|
||||||
|
var rr, gg, bb float64
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y += stride {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x += stride {
|
||||||
|
if mask.AlphaAt(x, y).A != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := img.RGBAAt(x, y)
|
||||||
|
cfR := float64(c.R)
|
||||||
|
cfG := float64(c.G)
|
||||||
|
cfB := float64(c.B)
|
||||||
|
cfA := float64(c.A)
|
||||||
|
r += cfR
|
||||||
|
g += cfG
|
||||||
|
b += cfB
|
||||||
|
a += cfA
|
||||||
|
rr += cfR * cfR
|
||||||
|
gg += cfG * cfG
|
||||||
|
bb += cfB * cfB
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count < 32 {
|
||||||
|
return layerSeparationColorStats{}, false
|
||||||
|
}
|
||||||
|
divisor := float64(count)
|
||||||
|
meanR := r / divisor
|
||||||
|
meanG := g / divisor
|
||||||
|
meanB := b / divisor
|
||||||
|
varR := math.Max(0, rr/divisor-meanR*meanR)
|
||||||
|
varG := math.Max(0, gg/divisor-meanG*meanG)
|
||||||
|
varB := math.Max(0, bb/divisor-meanB*meanB)
|
||||||
|
return layerSeparationColorStats{
|
||||||
|
Count: count,
|
||||||
|
R: meanR,
|
||||||
|
G: meanG,
|
||||||
|
B: meanB,
|
||||||
|
A: a / divisor,
|
||||||
|
StdDev: math.Sqrt((varR + varG + varB) / 3),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func alphaContentBounds(img image.Image, bounds image.Rectangle, threshold uint8) (image.Rectangle, bool) {
|
||||||
|
minX, minY := bounds.Max.X, bounds.Max.Y
|
||||||
|
maxX, maxY := bounds.Min.X, bounds.Min.Y
|
||||||
|
found := false
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
if alphaAt(img, x, y) <= threshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if x < minX {
|
||||||
|
minX = x
|
||||||
|
}
|
||||||
|
if y < minY {
|
||||||
|
minY = y
|
||||||
|
}
|
||||||
|
if x+1 > maxX {
|
||||||
|
maxX = x + 1
|
||||||
|
}
|
||||||
|
if y+1 > maxY {
|
||||||
|
maxY = y + 1
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return image.Rectangle{}, false
|
||||||
|
}
|
||||||
|
return image.Rect(minX, minY, maxX, maxY), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func alphaMask(img image.Image, bounds image.Rectangle, threshold uint8) (*image.Alpha, int) {
|
||||||
|
mask := image.NewAlpha(bounds)
|
||||||
|
count := 0
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
if alphaAt(img, x, y) <= threshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mask.SetAlpha(x, y, color.Alpha{A: 255})
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask, count
|
||||||
|
}
|
||||||
|
|
||||||
|
func alphaAt(img image.Image, x int, y int) uint8 {
|
||||||
|
_, _, _, a := img.At(x, y).RGBA()
|
||||||
|
return uint8(a >> 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cropImage(img image.Image, rect image.Rectangle) image.Image {
|
||||||
|
cropped := image.NewNRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
|
||||||
|
draw.Draw(cropped, cropped.Bounds(), img, rect.Min, draw.Src)
|
||||||
|
return cropped
|
||||||
|
}
|
||||||
|
|
||||||
|
type layerSeparationTextDocument struct {
|
||||||
|
Layers []layerSeparationTextLayer `json:"layers"`
|
||||||
|
CanvasSize layerSeparationCanvasSize `json:"canvas_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type layerSeparationCanvasSize struct {
|
||||||
|
Width int64 `json:"width"`
|
||||||
|
Height int64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type layerSeparationTextLayer struct {
|
||||||
|
TextInfo layerSeparationTextInfo `json:"text_info"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
W float64 `json:"w"`
|
||||||
|
H float64 `json:"h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type layerSeparationTextInfo struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
FontSizePx int64 `json:"font_size_px"`
|
||||||
|
FontFamily string `json:"font_family"`
|
||||||
|
FontWeight any `json:"font_weight"`
|
||||||
|
FontStyle string `json:"font_style"`
|
||||||
|
ColorCSS string `json:"color_css"`
|
||||||
|
TextAlign string `json:"text_align"`
|
||||||
|
Leading float64 `json:"leading"`
|
||||||
|
Tracking float64 `json:"tracking"`
|
||||||
|
Rotation float64 `json:"rotation"`
|
||||||
|
LeadingTrim string `json:"leading_trim"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationTextRenderData(extraction design.TextExtraction, canvasWidth int64, canvasHeight int64) (string, error) {
|
||||||
|
doc := layerSeparationTextDocument{
|
||||||
|
Layers: make([]layerSeparationTextLayer, 0, len(extraction.Layers)),
|
||||||
|
CanvasSize: layerSeparationCanvasSize{
|
||||||
|
Width: canvasWidth,
|
||||||
|
Height: canvasHeight,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, layer := range extraction.Layers {
|
||||||
|
text := strings.TrimSpace(layer.Text)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x := clamp01(layer.X)
|
||||||
|
y := clamp01(layer.Y)
|
||||||
|
width := clamp01(layer.Width)
|
||||||
|
height := clamp01(layer.Height)
|
||||||
|
if x+width > 1 {
|
||||||
|
width = 1 - x
|
||||||
|
}
|
||||||
|
if y+height > 1 {
|
||||||
|
height = 1 - y
|
||||||
|
}
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fontSize := layer.FontSize
|
||||||
|
if fontSize <= 0 {
|
||||||
|
fontSize = math.Max(10, float64(canvasHeight)*height*0.72)
|
||||||
|
}
|
||||||
|
lineHeight := layer.LineHeight
|
||||||
|
if lineHeight <= 0 {
|
||||||
|
lineHeight = 1.2
|
||||||
|
}
|
||||||
|
fontFamily := strings.TrimSpace(layer.FontFamily)
|
||||||
|
if fontFamily == "" {
|
||||||
|
fontFamily = "Inter"
|
||||||
|
}
|
||||||
|
doc.Layers = append(doc.Layers, layerSeparationTextLayer{
|
||||||
|
TextInfo: layerSeparationTextInfo{
|
||||||
|
Text: text,
|
||||||
|
FontSizePx: int64(math.Round(fontSize)),
|
||||||
|
FontFamily: fontFamily,
|
||||||
|
FontWeight: layerSeparationFontWeight(layer.FontWeight),
|
||||||
|
FontStyle: layerSeparationFontStyle(layer.FontWeight),
|
||||||
|
ColorCSS: layerSeparationColorCSS(layer.Color),
|
||||||
|
TextAlign: layerSeparationTextAlign(layer.TextAlign),
|
||||||
|
Leading: lineHeight,
|
||||||
|
Tracking: layer.LetterSpacing,
|
||||||
|
Rotation: layer.Rotation,
|
||||||
|
LeadingTrim: "CAP_HEIGHT",
|
||||||
|
},
|
||||||
|
X: x * float64(canvasWidth),
|
||||||
|
Y: y * float64(canvasHeight),
|
||||||
|
W: width * float64(canvasWidth),
|
||||||
|
H: height * float64(canvasHeight),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationFontWeight(value string) any {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
if weight, err := strconv.Atoi(value); err == nil {
|
||||||
|
return weight
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationFontStyle(value string) string {
|
||||||
|
if strings.Contains(strings.ToLower(strings.TrimSpace(value)), "italic") {
|
||||||
|
return "italic"
|
||||||
|
}
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationTextAlign(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "left":
|
||||||
|
return "start"
|
||||||
|
case "right":
|
||||||
|
return "end"
|
||||||
|
case "center":
|
||||||
|
return "center"
|
||||||
|
default:
|
||||||
|
return "start"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func layerSeparationColorCSS(value string) string {
|
||||||
|
parsed, ok := parseCanvasColor(value)
|
||||||
|
if !ok {
|
||||||
|
return "rgba(25, 25, 25, 1.0)"
|
||||||
|
}
|
||||||
|
alpha := float64(parsed.A) / 255
|
||||||
|
return fmt.Sprintf("rgba(%d, %d, %d, %.3g)", parsed.R, parsed.G, parsed.B, alpha)
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"img_infinite_canvas/internal/domain/design"
|
||||||
|
"img_infinite_canvas/internal/infrastructure/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunEditElementsSeparatesBackgroundForegroundAndTextLayers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := memory.NewProjectRepository()
|
||||||
|
assets := newFakeAssetStorage()
|
||||||
|
queue := &fakeJobQueue{}
|
||||||
|
extractor := &countingTextExtractor{
|
||||||
|
result: design.TextExtraction{
|
||||||
|
Layers: []design.ExtractedTextLayer{
|
||||||
|
{Text: "Hi", X: 0.5, Y: 0.05, Width: 0.2, Height: 0.1, FontSize: 18, FontFamily: "Inter", FontWeight: "700", Color: "#111111"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
remover := &fakeBackgroundRemover{result: design.BackgroundRemoval{
|
||||||
|
Image: testLayerSeparationForegroundPNG(t),
|
||||||
|
ContentType: "image/png",
|
||||||
|
Width: 4,
|
||||||
|
Height: 4,
|
||||||
|
}}
|
||||||
|
service := NewDesignService(repo, nil, assets, nil, extractor)
|
||||||
|
service.SetJobQueue(queue)
|
||||||
|
service.SetBackgroundRemover(remover)
|
||||||
|
|
||||||
|
source := testLayerSeparationSourceDataURL()
|
||||||
|
project := design.Project{
|
||||||
|
ID: "project-layer-separation",
|
||||||
|
Title: "Layer separation",
|
||||||
|
Status: design.StatusReady,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Nodes: []design.Node{{
|
||||||
|
ID: "image-1",
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Title: "Source",
|
||||||
|
X: 100,
|
||||||
|
Y: 120,
|
||||||
|
Width: 400,
|
||||||
|
Height: 400,
|
||||||
|
Content: source,
|
||||||
|
Status: "success",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := repo.Save(ctx, project); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
started, err := service.RunNodeActionAsync(ctx, project.ID, "image-1", design.NodeActionRequest{Action: "edit-elements"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(queue.jobs) != 1 {
|
||||||
|
t.Fatalf("expected one queued job, got %#v", queue.jobs)
|
||||||
|
}
|
||||||
|
if len(started.Nodes) != 2 {
|
||||||
|
t.Fatalf("expected source plus separated placeholder, got %#v", started.Nodes)
|
||||||
|
}
|
||||||
|
if started.Nodes[0].Status != "success" || started.Nodes[0].Content != source {
|
||||||
|
t.Fatalf("expected source node to remain unchanged, got %#v", started.Nodes[0])
|
||||||
|
}
|
||||||
|
if started.Nodes[1].Type != design.NodeTypeFrame || started.Nodes[1].Status != "generating" || started.Nodes[1].LayerRole != "manual-frame" || started.Nodes[1].ParentID != "" || started.Nodes[1].FillColor != "transparent" {
|
||||||
|
t.Fatalf("expected separated placeholder beside source, got %#v", started.Nodes[1])
|
||||||
|
}
|
||||||
|
if started.Nodes[1].Content != source {
|
||||||
|
t.Fatalf("expected separated placeholder to preview source image, got %#v", started.Nodes[1])
|
||||||
|
}
|
||||||
|
task, err := service.GeneratorTask(ctx, project.ID, started.LastThreadID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task.GeneratorName != layerSeparationGeneratorName || task.Status != "submitted" {
|
||||||
|
t.Fatalf("expected submitted layer separation task, got %#v", task)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.ProcessJob(ctx, queue.jobs[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saved, err := repo.Get(ctx, project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.Status != design.StatusReady {
|
||||||
|
t.Fatalf("expected ready project, got %s", saved.Status)
|
||||||
|
}
|
||||||
|
if len(saved.Nodes) != 5 {
|
||||||
|
t.Fatalf("expected original, background, frame, foreground, and text nodes, got %#v", saved.Nodes)
|
||||||
|
}
|
||||||
|
if saved.Nodes[0].Status != "success" || saved.Nodes[0].Content != source {
|
||||||
|
t.Fatalf("expected original source to remain unchanged, got %#v", saved.Nodes[0])
|
||||||
|
}
|
||||||
|
background := saved.Nodes[1]
|
||||||
|
if background.Type != design.NodeTypeImage || background.LayerRole != "clean-image" || background.Status != "success" {
|
||||||
|
t.Fatalf("expected clean background node, got %#v", background)
|
||||||
|
}
|
||||||
|
if background.X != 532 || background.Y != 120 {
|
||||||
|
t.Fatalf("expected clean background at separated result origin, got %#v", background)
|
||||||
|
}
|
||||||
|
frame := saved.Nodes[2]
|
||||||
|
if frame.Type != design.NodeTypeFrame || frame.LayerRole != "manual-frame" || frame.Status != "success" {
|
||||||
|
t.Fatalf("expected separated frame beside source, got %#v", frame)
|
||||||
|
}
|
||||||
|
if frame.X != 532 || frame.Y != 120 {
|
||||||
|
t.Fatalf("expected frame beside source, got %#v", frame)
|
||||||
|
}
|
||||||
|
if frame.ParentID != "" || frame.FillColor != "transparent" {
|
||||||
|
t.Fatalf("expected top-level transparent separated frame, got %#v", frame)
|
||||||
|
}
|
||||||
|
if background.ParentID != "" {
|
||||||
|
t.Fatalf("expected clean background to be a bottom image layer outside frame, got %#v", background)
|
||||||
|
}
|
||||||
|
foreground := saved.Nodes[3]
|
||||||
|
if foreground.LayerRole != "foreground-image" || foreground.ParentID != frame.ID {
|
||||||
|
t.Fatalf("expected separated foreground node, got %#v", foreground)
|
||||||
|
}
|
||||||
|
if foreground.X != 632 || foreground.Y != 220 || foreground.Width != 200 || foreground.Height != 200 {
|
||||||
|
t.Fatalf("unexpected foreground geometry: %#v", foreground)
|
||||||
|
}
|
||||||
|
text := saved.Nodes[4]
|
||||||
|
if text.LayerRole != "editable-text" || text.ParentID != frame.ID || text.Content != "Hi" {
|
||||||
|
t.Fatalf("expected editable text node, got %#v", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err = service.GeneratorTask(ctx, project.ID, started.LastThreadID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task.Status != "completed" {
|
||||||
|
t.Fatalf("expected completed task, got %s", task.Status)
|
||||||
|
}
|
||||||
|
if len(task.Artifacts) != 3 {
|
||||||
|
t.Fatalf("expected foreground, background, and text artifacts, got %#v", task.Artifacts)
|
||||||
|
}
|
||||||
|
if task.Artifacts[0].Metadata.Label != layerSeparationForegroundLabel || task.Artifacts[1].Metadata.Label != layerSeparationBackgroundLabel || task.Artifacts[2].Metadata.Label != layerSeparationTextLabel {
|
||||||
|
t.Fatalf("unexpected artifact labels: %#v", task.Artifacts)
|
||||||
|
}
|
||||||
|
if got := task.Artifacts[0].Metadata.BBox; len(got) != 4 || got[0] != 1 || got[1] != 1 || got[2] != 3 || got[3] != 3 {
|
||||||
|
t.Fatalf("unexpected foreground bbox: %#v", got)
|
||||||
|
}
|
||||||
|
if task.Artifacts[2].Type != "text" || !bytes.Contains([]byte(task.Artifacts[2].Content), []byte(`"text":"Hi"`)) {
|
||||||
|
t.Fatalf("expected text render data artifact, got %#v", task.Artifacts[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitLayerSeparationTaskCompletesStandaloneArtifacts(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
assets := newFakeAssetStorage()
|
||||||
|
extractor := &countingTextExtractor{
|
||||||
|
result: design.TextExtraction{
|
||||||
|
Layers: []design.ExtractedTextLayer{{Text: "Title", X: 0.1, Y: 0.1, Width: 0.3, Height: 0.1}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
remover := &fakeBackgroundRemover{result: design.BackgroundRemoval{
|
||||||
|
Image: testLayerSeparationForegroundPNG(t),
|
||||||
|
ContentType: "image/png",
|
||||||
|
Width: 4,
|
||||||
|
Height: 4,
|
||||||
|
}}
|
||||||
|
service := NewDesignService(nil, nil, assets, nil, extractor)
|
||||||
|
service.SetBackgroundRemover(remover)
|
||||||
|
|
||||||
|
task, err := service.SubmitGeneratorTask(ctx, design.GeneratorTaskSubmitRequest{
|
||||||
|
ProjectID: "project-standalone-layer-separation",
|
||||||
|
GeneratorName: layerSeparationGeneratorName,
|
||||||
|
InputArgs: design.GeneratorTaskInputArgs{
|
||||||
|
ImageURL: testLayerSeparationSourceDataURL(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
completed := waitForStandaloneGeneratorTask(t, service, task.GeneratorTaskID)
|
||||||
|
if completed.Status != "completed" {
|
||||||
|
t.Fatalf("expected completed standalone task, got %#v", completed)
|
||||||
|
}
|
||||||
|
if completed.GeneratorName != layerSeparationGeneratorName || len(completed.Artifacts) != 3 {
|
||||||
|
t.Fatalf("unexpected standalone task: %#v", completed)
|
||||||
|
}
|
||||||
|
if completed.Artifacts[0].Metadata.Label != layerSeparationForegroundLabel || completed.Artifacts[2].Metadata.Label != layerSeparationTextLabel {
|
||||||
|
t.Fatalf("unexpected standalone artifacts: %#v", completed.Artifacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlainLayerSeparationBackgroundUsesSolidColorWhenBackdropIsPlain(t *testing.T) {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 24, 24))
|
||||||
|
for y := 0; y < 24; y++ {
|
||||||
|
for x := 0; x < 24; x++ {
|
||||||
|
img.SetRGBA(x, y, color.RGBA{R: 188, G: 185, B: 178, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mask := image.NewAlpha(img.Bounds())
|
||||||
|
rect := image.Rect(8, 4, 16, 22)
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
img.SetRGBA(x, y, color.RGBA{R: 32, G: 26, B: 22, A: 255})
|
||||||
|
mask.SetAlpha(x, y, color.Alpha{A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bg, ok := plainLayerSeparationBackgroundImage(img, mask, rect)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected plain background branch")
|
||||||
|
}
|
||||||
|
center := bg.RGBAAt(12, 12)
|
||||||
|
if center.R != 188 || center.G != 185 || center.B != 178 || center.A != 255 {
|
||||||
|
t.Fatalf("expected solid background color, got %#v", center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlainLayerSeparationBackgroundSkipsComplexBackdrop(t *testing.T) {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 24, 24))
|
||||||
|
for y := 0; y < 24; y++ {
|
||||||
|
for x := 0; x < 24; x++ {
|
||||||
|
img.SetRGBA(x, y, color.RGBA{R: uint8(20 + x*8), G: uint8(40 + y*7), B: uint8(80 + (x+y)*3), A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mask := image.NewAlpha(img.Bounds())
|
||||||
|
rect := image.Rect(9, 6, 15, 20)
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
mask.SetAlpha(x, y, color.Alpha{A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := plainLayerSeparationBackgroundImage(img, mask, rect); ok {
|
||||||
|
t.Fatal("expected complex background to use repair branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayerSeparationPosterKeepsComplexBackgroundAndExtractsSmallElements(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
extractor := &countingTextExtractor{
|
||||||
|
result: design.TextExtraction{
|
||||||
|
Layers: []design.ExtractedTextLayer{{
|
||||||
|
Text: "SALE",
|
||||||
|
X: 0.1,
|
||||||
|
Y: 0.1,
|
||||||
|
Width: 0.35,
|
||||||
|
Height: 0.125,
|
||||||
|
FontSize: 12,
|
||||||
|
FontFamily: "Inter",
|
||||||
|
FontWeight: "700",
|
||||||
|
Color: "#111111",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
remover := &fakeBackgroundRemover{result: design.BackgroundRemoval{
|
||||||
|
Image: testPosterForegroundPNG(t),
|
||||||
|
ContentType: "image/png",
|
||||||
|
Width: 80,
|
||||||
|
Height: 80,
|
||||||
|
}}
|
||||||
|
service := NewDesignService(nil, nil, nil, nil, extractor)
|
||||||
|
service.SetBackgroundRemover(remover)
|
||||||
|
|
||||||
|
result, err := service.createLayerSeparationResult(ctx, "project-poster-layer-separation", design.Node{
|
||||||
|
ID: "poster",
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Title: "Poster",
|
||||||
|
Content: testPosterSourceDataURL(),
|
||||||
|
Status: "success",
|
||||||
|
}, design.NodeActionRequest{Action: "edit-elements"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.TextExtraction.Layers) != 1 {
|
||||||
|
t.Fatalf("expected editable text extraction to remain, got %#v", result.TextExtraction.Layers)
|
||||||
|
}
|
||||||
|
if len(result.Foregrounds) != 1 {
|
||||||
|
t.Fatalf("expected only the small decorative foreground to be extracted, got %#v", result.Foregrounds)
|
||||||
|
}
|
||||||
|
if got := result.Foregrounds[0].BBox; len(got) != 4 || got[0] != 54 || got[1] != 44 || got[2] != 66 || got[3] != 56 {
|
||||||
|
t.Fatalf("unexpected decorative foreground bbox: %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundData, _, err := loadImageContent(ctx, result.BackgroundURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
background, _, err := image.Decode(bytes.NewReader(backgroundData))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
topLeft := color.NRGBAModel.Convert(background.At(2, 2)).(color.NRGBA)
|
||||||
|
bottomRight := color.NRGBAModel.Convert(background.At(76, 76)).(color.NRGBA)
|
||||||
|
if topLeft == bottomRight {
|
||||||
|
t.Fatalf("expected complex poster background to be preserved, got solid-looking pixels %#v", topLeft)
|
||||||
|
}
|
||||||
|
textPixel := color.NRGBAModel.Convert(background.At(14, 12)).(color.NRGBA)
|
||||||
|
if textPixel.R < 32 && textPixel.G < 32 && textPixel.B < 32 {
|
||||||
|
t.Fatalf("expected text pixels to be removed from background, got %#v", textPixel)
|
||||||
|
}
|
||||||
|
ornamentPixel := color.NRGBAModel.Convert(background.At(59, 49)).(color.NRGBA)
|
||||||
|
if ornamentPixel.R > 200 && ornamentPixel.G < 80 && ornamentPixel.B < 80 {
|
||||||
|
t.Fatalf("expected extracted ornament to be removed from background, got %#v", ornamentPixel)
|
||||||
|
}
|
||||||
|
scenePixel := color.NRGBAModel.Convert(background.At(20, 48)).(color.NRGBA)
|
||||||
|
if scenePixel.B < 170 {
|
||||||
|
t.Fatalf("expected large poster scenery to stay on the background, got %#v", scenePixel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForStandaloneGeneratorTask(t *testing.T, service *DesignService, taskID string) design.GeneratorTask {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
task, err := service.GeneratorTask(context.Background(), "", taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task.Status == "completed" || task.Status == "failed" {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
task, _ := service.GeneratorTask(context.Background(), "", taskID)
|
||||||
|
t.Fatalf("timed out waiting for standalone task: %#v", task)
|
||||||
|
return design.GeneratorTask{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLayerSeparationSourceDataURL() string {
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
|
||||||
|
for y := 0; y < 4; y++ {
|
||||||
|
for x := 0; x < 4; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 240, G: 240, B: 240, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := 1; y < 3; y++ {
|
||||||
|
for x := 1; x < 3; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 200, G: 20, B: 30, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return testPNGDataURLFromImage(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLayerSeparationForegroundPNG(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
|
||||||
|
for y := 1; y < 3; y++ {
|
||||||
|
for x := 1; x < 3; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 200, G: 20, B: 30, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPosterSourceDataURL() string {
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, 80, 80))
|
||||||
|
for y := 0; y < 80; y++ {
|
||||||
|
for x := 0; x < 80; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: uint8(190 + x/3), G: uint8(186 + y/4), B: uint8(174 + (x+y)/8), A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := 38; y < 70; y++ {
|
||||||
|
for x := 8; x < 50; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 64, G: 106, B: 204, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := 8; y < 18; y++ {
|
||||||
|
for x := 8; x < 36; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 17, G: 17, B: 17, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := 44; y < 56; y++ {
|
||||||
|
for x := 54; x < 66; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 225, G: 32, B: 44, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return testPNGDataURLFromImage(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPosterForegroundPNG(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, 80, 80))
|
||||||
|
for y := 8; y < 18; y++ {
|
||||||
|
for x := 8; x < 36; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 17, G: 17, B: 17, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := 38; y < 70; y++ {
|
||||||
|
for x := 8; x < 50; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 64, G: 106, B: 204, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := 44; y < 56; y++ {
|
||||||
|
for x := 54; x < 66; x++ {
|
||||||
|
img.SetNRGBA(x, y, color.NRGBA{R: 225, G: 32, B: 44, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
@@ -107,14 +107,16 @@ type GeneratorTaskQueueInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeneratorTaskArtifactMetadata struct {
|
type GeneratorTaskArtifactMetadata struct {
|
||||||
ArtifactID string
|
ArtifactID string
|
||||||
Format string
|
BBox []int64
|
||||||
Height int64
|
Format string
|
||||||
Label string
|
GeneratorName string
|
||||||
NodeID string
|
Height int64
|
||||||
NodeName string
|
Label string
|
||||||
SizeBytes int64
|
NodeID string
|
||||||
Width int64
|
NodeName string
|
||||||
|
SizeBytes int64
|
||||||
|
Width int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeneratorTaskArtifact struct {
|
type GeneratorTaskArtifact struct {
|
||||||
|
|||||||
@@ -133,14 +133,16 @@ func toAPIGeneratorTaskArtifacts(items []design.GeneratorTaskArtifact) []types.G
|
|||||||
Type: item.Type,
|
Type: item.Type,
|
||||||
Content: item.Content,
|
Content: item.Content,
|
||||||
Metadata: types.GeneratorTaskArtifactMetadata{
|
Metadata: types.GeneratorTaskArtifactMetadata{
|
||||||
ArtifactId: item.Metadata.ArtifactID,
|
ArtifactId: item.Metadata.ArtifactID,
|
||||||
Format: item.Metadata.Format,
|
BBox: append([]int64(nil), item.Metadata.BBox...),
|
||||||
Height: item.Metadata.Height,
|
Format: item.Metadata.Format,
|
||||||
Label: item.Metadata.Label,
|
GeneratorName: item.Metadata.GeneratorName,
|
||||||
NodeId: item.Metadata.NodeID,
|
Height: item.Metadata.Height,
|
||||||
NodeName: item.Metadata.NodeName,
|
Label: item.Metadata.Label,
|
||||||
SizeBytes: item.Metadata.SizeBytes,
|
NodeId: item.Metadata.NodeID,
|
||||||
Width: item.Metadata.Width,
|
NodeName: item.Metadata.NodeName,
|
||||||
|
SizeBytes: item.Metadata.SizeBytes,
|
||||||
|
Width: item.Metadata.Width,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,14 +417,16 @@ type GeneratorTaskArtifact struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeneratorTaskArtifactMetadata struct {
|
type GeneratorTaskArtifactMetadata struct {
|
||||||
ArtifactId string `json:"artifact_id,optional"`
|
ArtifactId string `json:"artifact_id,optional"`
|
||||||
Format string `json:"format,optional"`
|
BBox []int64 `json:"bbox,optional"`
|
||||||
Height int64 `json:"height,optional"`
|
Format string `json:"format,optional"`
|
||||||
Label string `json:"label,optional"`
|
GeneratorName string `json:"generator_name,optional"`
|
||||||
NodeId string `json:"node_id,optional"`
|
Height int64 `json:"height,optional"`
|
||||||
NodeName string `json:"node_name,optional"`
|
Label string `json:"label,optional"`
|
||||||
SizeBytes int64 `json:"size_bytes,optional"`
|
NodeId string `json:"node_id,optional"`
|
||||||
Width int64 `json:"width,optional"`
|
NodeName string `json:"node_name,optional"`
|
||||||
|
SizeBytes int64 `json:"size_bytes,optional"`
|
||||||
|
Width int64 `json:"width,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeneratorTaskData struct {
|
type GeneratorTaskData struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user