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:
@@ -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