feat(canvas): add visual annotation edit action
Add a VisualAnnotationPanel letting users mark regions on an image (brush, circle, rectangle, cross) with per-mark edit instructions, composited into an annotation guide image. The new "visual-annotation" node action sends the unmarked source plus the annotated guide to the image model, editing only numbered regions. Server validates the annotation image/instructions and threads them through the generator task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1021,6 +1021,9 @@ func (s *DesignService) RunNodeActionAsync(ctx context.Context, projectID string
|
||||
if req.Action == "" {
|
||||
return design.Project{}, fmt.Errorf("%w: action is required", design.ErrInvalidInput)
|
||||
}
|
||||
if req.Action == "visual-annotation" && (strings.TrimSpace(req.AnnotationImage) == "" || strings.TrimSpace(req.Prompt) == "") {
|
||||
return design.Project{}, fmt.Errorf("%w: visual annotation requires an annotation image and instructions", design.ErrInvalidInput)
|
||||
}
|
||||
|
||||
project, err := s.repo.Get(ctx, strings.TrimSpace(projectID))
|
||||
if err != nil {
|
||||
@@ -1038,7 +1041,7 @@ func (s *DesignService) RunNodeActionAsync(ctx context.Context, projectID string
|
||||
now := s.now()
|
||||
threadID := newID()
|
||||
target := project.Nodes[nodeIndex]
|
||||
if req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" || req.Action == "edit-elements" {
|
||||
if req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" || req.Action == "edit-elements" || req.Action == "visual-annotation" {
|
||||
resultNode := newBackgroundRemovalPlaceholder(target)
|
||||
if req.Action == "vectorize" {
|
||||
resultNode = newVectorizePlaceholder(target)
|
||||
@@ -1046,6 +1049,8 @@ func (s *DesignService) RunNodeActionAsync(ctx context.Context, projectID string
|
||||
resultNode = newMoveObjectPlaceholder(target)
|
||||
} else if req.Action == "edit-elements" {
|
||||
resultNode = newLayerSeparationPlaceholder(target)
|
||||
} else if req.Action == "visual-annotation" {
|
||||
resultNode = newVisualAnnotationPlaceholder(target)
|
||||
}
|
||||
if req.Options == nil {
|
||||
req.Options = make(map[string]string)
|
||||
@@ -2020,13 +2025,17 @@ func (s *DesignService) completeNodeActionGeneration(ctx context.Context, projec
|
||||
}
|
||||
|
||||
actionPrompt := buildNodeActionPrompt(req, target)
|
||||
actionContents := []design.AgentContent{
|
||||
{Type: "text", Text: req.Prompt, TextSource: "input"},
|
||||
{Type: "image", ImageURL: target.Content, ImageWidth: int64(target.Width), ImageHeight: int64(target.Height)},
|
||||
}
|
||||
if req.Action == "visual-annotation" && strings.TrimSpace(req.AnnotationImage) != "" {
|
||||
actionContents = append(actionContents, design.AgentContent{Type: "image", ImageURL: strings.TrimSpace(req.AnnotationImage), ImageWidth: int64(target.Width), ImageHeight: int64(target.Height)})
|
||||
}
|
||||
actionMessages := []design.AgentChatMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Contents: []design.AgentContent{
|
||||
{Type: "text", Text: req.Prompt, TextSource: "input"},
|
||||
{Type: "image", ImageURL: target.Content, ImageWidth: int64(target.Width), ImageHeight: int64(target.Height)},
|
||||
},
|
||||
Role: "user",
|
||||
Contents: actionContents,
|
||||
},
|
||||
}
|
||||
projectForPlan := projectWithoutGeneratingNodes(project)
|
||||
@@ -2059,10 +2068,14 @@ func (s *DesignService) completeNodeActionGeneration(ctx context.Context, projec
|
||||
|
||||
nodeIndex := findNodeIndex(project.Nodes, resultNodeID)
|
||||
if nodeIndex < 0 {
|
||||
if req.Action != "move-object" {
|
||||
if req.Action != "move-object" && req.Action != "visual-annotation" {
|
||||
return design.ErrNotFound
|
||||
}
|
||||
project.Nodes = append(project.Nodes, newMoveObjectPlaceholder(target))
|
||||
resultNode := newMoveObjectPlaceholder(target)
|
||||
if req.Action == "visual-annotation" {
|
||||
resultNode = newVisualAnnotationPlaceholder(target)
|
||||
}
|
||||
project.Nodes = append(project.Nodes, resultNode)
|
||||
nodeIndex = len(project.Nodes) - 1
|
||||
project.Nodes[nodeIndex].ID = resultNodeID
|
||||
}
|
||||
@@ -4825,7 +4838,7 @@ func normalizeNodeAction(action string) string {
|
||||
action = strings.ToLower(strings.TrimSpace(action))
|
||||
action = strings.ReplaceAll(action, "_", "-")
|
||||
switch action {
|
||||
case "quick-edit", "upscale", "remove-background", "eraser", "edit-elements", "edit-text", "multi-angle", "move-object", "mockup", "expand", "adjust", "crop", "vectorize":
|
||||
case "quick-edit", "visual-annotation", "upscale", "remove-background", "eraser", "edit-elements", "edit-text", "multi-angle", "move-object", "mockup", "expand", "adjust", "crop", "vectorize":
|
||||
return action
|
||||
default:
|
||||
return ""
|
||||
@@ -4878,6 +4891,21 @@ func newMoveObjectPlaceholder(target design.Node) design.Node {
|
||||
return result
|
||||
}
|
||||
|
||||
func newVisualAnnotationPlaceholder(target design.Node) design.Node {
|
||||
result := target
|
||||
result.ID = newID()
|
||||
result.Title = nodeActionWorkingTitle("visual-annotation")
|
||||
result.X = target.X + target.Width + 32
|
||||
result.Y = target.Y
|
||||
result.Status = "generating"
|
||||
result.ParentID = target.ID
|
||||
result.LayerRole = "visual-annotation-edit"
|
||||
result.MockupSurface = design.MockupSurface{}
|
||||
result.MockupModel = design.MockupModel{}
|
||||
result.MockupSourceContent = ""
|
||||
return result
|
||||
}
|
||||
|
||||
func newLayerSeparationPlaceholder(target design.Node) design.Node {
|
||||
result := target
|
||||
result.ID = newID()
|
||||
@@ -4901,7 +4929,7 @@ func newLayerSeparationPlaceholder(target design.Node) design.Node {
|
||||
}
|
||||
|
||||
func nodeActionResultNodeID(target design.Node, req design.NodeActionRequest) string {
|
||||
if (req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" || req.Action == "edit-elements") && len(req.Options) > 0 {
|
||||
if (req.Action == "remove-background" || req.Action == "vectorize" || req.Action == "move-object" || req.Action == "edit-elements" || req.Action == "visual-annotation") && len(req.Options) > 0 {
|
||||
if id := strings.TrimSpace(req.Options[nodeActionResultNodeIDOption]); id != "" {
|
||||
return id
|
||||
}
|
||||
@@ -5324,6 +5352,12 @@ func buildNodeActionPrompt(req design.NodeActionRequest, target design.Node) str
|
||||
switch req.Action {
|
||||
case "quick-edit":
|
||||
builder.WriteString("Action: quick image edit. Apply the user's prompt directly to the selected image.")
|
||||
case "visual-annotation":
|
||||
builder.WriteString("Action: visual annotation edit. The first image is the clean source and must remain the base image. The second image is an annotated guide only. Match its numbered blue marks to the numbered user instructions, edit only those indicated regions, preserve every unmarked region, and never reproduce guide marks, lines, badges, or numbers in the final image.")
|
||||
if strings.TrimSpace(req.AnnotationImage) != "" {
|
||||
builder.WriteString("\nAnnotated guide image URL: ")
|
||||
builder.WriteString(strings.TrimSpace(req.AnnotationImage))
|
||||
}
|
||||
case "upscale":
|
||||
builder.WriteString("Action: model upscale. Increase detail, sharpness, texture fidelity, and perceived resolution while preserving the image exactly.")
|
||||
if value := req.Options["scale"]; value != "" {
|
||||
@@ -5412,6 +5446,8 @@ func nodeActionWorkingTitle(action string) string {
|
||||
switch action {
|
||||
case "quick-edit":
|
||||
return "快捷编辑中"
|
||||
case "visual-annotation":
|
||||
return "标注改图中"
|
||||
case "upscale":
|
||||
return "模型放大中"
|
||||
case "remove-background":
|
||||
@@ -5445,6 +5481,8 @@ func nodeActionResultTitle(action string) string {
|
||||
switch action {
|
||||
case "quick-edit":
|
||||
return "Quick Edit"
|
||||
case "visual-annotation":
|
||||
return "Annotated Edit"
|
||||
case "upscale":
|
||||
return "Upscaled Image"
|
||||
case "remove-background":
|
||||
@@ -5502,6 +5540,8 @@ func nodeActionThinkingContent(action string) string {
|
||||
switch action {
|
||||
case "quick-edit":
|
||||
return "正在按提示词对选中图片做快速编辑。"
|
||||
case "visual-annotation":
|
||||
return "正在读取图片标记和对应修改说明,按区域执行精准编辑。"
|
||||
case "upscale":
|
||||
return "正在通过模型增强分辨率、细节和边缘质量。"
|
||||
case "remove-background":
|
||||
@@ -5535,6 +5575,8 @@ func nodeActionToolContent(action string) string {
|
||||
switch action {
|
||||
case "quick-edit":
|
||||
return "准备图片引用、用户提示词和目标约束。"
|
||||
case "visual-annotation":
|
||||
return "准备未标注原图、标注引导图和逐项修改说明。"
|
||||
case "upscale":
|
||||
return "准备高清放大提示词,约束构图不漂移。"
|
||||
case "remove-background":
|
||||
@@ -5568,6 +5610,8 @@ func nodeActionDoneTitle(action string) string {
|
||||
switch action {
|
||||
case "quick-edit":
|
||||
return "快捷编辑完成"
|
||||
case "visual-annotation":
|
||||
return "标注改图完成"
|
||||
case "upscale":
|
||||
return "模型放大完成"
|
||||
case "remove-background":
|
||||
@@ -5611,6 +5655,9 @@ func nodeActionDoneContent(action string, prompt string) string {
|
||||
if action == "vectorize" {
|
||||
return fmt.Sprintf("已完成图片动作:%s。SVG 矢量结果已生成在原图旁边,可直接下载。", prompt)
|
||||
}
|
||||
if action == "visual-annotation" {
|
||||
return fmt.Sprintf("已完成图片动作:%s。原图保持不变,标注修改结果已生成在原图旁边。", prompt)
|
||||
}
|
||||
return fmt.Sprintf("已完成图片动作:%s。结果已回写到无限画布中的选中图片。", prompt)
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +786,9 @@ func generatorTaskInputArgsForNodeAction(target design.Node, req design.NodeActi
|
||||
Prompt: generatorTaskPromptForNodeAction(req),
|
||||
Resolution: resolutionLabel(imageSize),
|
||||
}
|
||||
if normalizeNodeAction(req.Action) == "visual-annotation" && strings.TrimSpace(req.AnnotationImage) != "" {
|
||||
input.Image = append(input.Image, strings.TrimSpace(req.AnnotationImage))
|
||||
}
|
||||
if normalizeNodeAction(req.Action) == "move-object" {
|
||||
input.SrcBox, input.DstBox = moveObjectBoxesFromSelection(req.Selection)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
"img_infinite_canvas/internal/infrastructure/memory"
|
||||
)
|
||||
|
||||
func TestRunVisualAnnotationCarriesCleanSourceAndGuideImage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := memory.NewProjectRepository()
|
||||
queue := &fakeJobQueue{}
|
||||
service := NewDesignService(repo, nil, nil, nil, nil)
|
||||
service.SetJobQueue(queue)
|
||||
|
||||
project := design.Project{
|
||||
ID: "project-visual-annotation",
|
||||
Title: "Annotated edit",
|
||||
Status: design.StatusReady,
|
||||
UpdatedAt: time.Now(),
|
||||
Nodes: []design.Node{{
|
||||
ID: "image-1",
|
||||
Type: design.NodeTypeImage,
|
||||
Title: "Source",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
Content: "https://example.com/source.png",
|
||||
Status: "success",
|
||||
}},
|
||||
}
|
||||
if err := repo.Save(ctx, project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := service.RunNodeActionAsync(ctx, project.ID, "image-1", design.NodeActionRequest{
|
||||
Action: "visual-annotation",
|
||||
Prompt: "1. Remove the logo",
|
||||
})
|
||||
if !errors.Is(err, design.ErrInvalidInput) {
|
||||
t.Fatalf("expected missing guide image to be rejected, got %v", err)
|
||||
}
|
||||
|
||||
selection := `{"annotations":[{"index":1,"type":"cross","instruction":"Remove the logo","points":[{"x":0.4,"y":0.3},{"x":0.6,"y":0.5}]}]}`
|
||||
guideURL := "https://example.com/annotation-guide.png"
|
||||
next, err := service.RunNodeActionAsync(ctx, project.ID, "image-1", design.NodeActionRequest{
|
||||
Action: "visual_annotation",
|
||||
Prompt: "1. Cross: Remove the logo",
|
||||
Selection: selection,
|
||||
AnnotationImage: guideURL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(next.Nodes) != 2 {
|
||||
t.Fatalf("expected the source plus an annotated-edit copy, got %#v", next.Nodes)
|
||||
}
|
||||
sourceNode := next.Nodes[0]
|
||||
placeholder := next.Nodes[1]
|
||||
if sourceNode.Status != "success" || sourceNode.Title != "Source" || sourceNode.Content != project.Nodes[0].Content {
|
||||
t.Fatalf("expected the source image to remain unchanged, got %#v", sourceNode)
|
||||
}
|
||||
if placeholder.Status != "generating" || placeholder.Title != "标注改图中" || placeholder.ParentID != sourceNode.ID || placeholder.LayerRole != "visual-annotation-edit" {
|
||||
t.Fatalf("expected a generating annotated-edit copy, got %#v", placeholder)
|
||||
}
|
||||
if placeholder.X != sourceNode.X+sourceNode.Width+32 || placeholder.Y != sourceNode.Y || placeholder.Content != sourceNode.Content {
|
||||
t.Fatalf("expected the annotated-edit copy beside the source, got %#v", placeholder)
|
||||
}
|
||||
if len(queue.jobs) != 1 {
|
||||
t.Fatalf("expected one visual annotation job, got %#v", queue.jobs)
|
||||
}
|
||||
request := queue.jobs[0].Request
|
||||
if request.Action != "visual-annotation" || request.AnnotationImage != guideURL || request.Selection != selection {
|
||||
t.Fatalf("visual annotation request was not preserved in the job: %#v", request)
|
||||
}
|
||||
if request.Options[nodeActionResultNodeIDOption] != placeholder.ID {
|
||||
t.Fatalf("expected the job to target copied node %q, got %#v", placeholder.ID, request.Options)
|
||||
}
|
||||
|
||||
task, err := service.GeneratorTask(ctx, project.ID, next.LastThreadID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(task.OriginalInputArgs.Image) != 2 || task.OriginalInputArgs.Image[0] != project.Nodes[0].Content || task.OriginalInputArgs.Image[1] != guideURL {
|
||||
t.Fatalf("expected clean source followed by annotation guide, got %#v", task.OriginalInputArgs.Image)
|
||||
}
|
||||
prompt := buildNodeActionPrompt(request, project.Nodes[0])
|
||||
for _, fragment := range []string{"first image is the clean source", "second image is an annotated guide only", "never reproduce guide marks", selection} {
|
||||
if !strings.Contains(prompt, fragment) {
|
||||
t.Fatalf("expected visual annotation prompt to contain %q, got %q", fragment, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user