From cb24ffd1854336ad48e5ff55a927c32eb8591de6 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 9 Jul 2026 20:42:42 +0800 Subject: [PATCH] feat(application): PSD layer separation and image quality/count threading Convert GPT Image 2 layered PSD output into canvas image/text nodes with a local separation fallback (new layer_separation.go, oov/psd dependency), default layer-separation/edit-elements output_format to psd, and thread imageQuality/imageCount through project generation and job processing. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/go.mod | 2 + server/go.sum | 4 + server/internal/application/design_service.go | 148 ++++---- .../application/generation_failure_test.go | 18 +- server/internal/application/generator_task.go | 19 +- .../internal/application/layer_separation.go | 339 ++++++++++++++++++ .../application/layer_separation_test.go | 113 +++++- 7 files changed, 565 insertions(+), 78 deletions(-) diff --git a/server/go.mod b/server/go.mod index 2b3d01f..4d6c712 100644 --- a/server/go.mod +++ b/server/go.mod @@ -10,6 +10,7 @@ require ( github.com/hibiken/asynq v0.26.0 github.com/jackc/pgx/v5 v5.8.0 github.com/minio/minio-go/v7 v7.2.1 + github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f github.com/redis/go-redis/v9 v9.19.0 github.com/sky-valley/pi v0.2.8 github.com/yalue/onnxruntime_go v1.22.0 @@ -28,6 +29,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/gopherjs/gopherjs v1.21.0 // indirect github.com/grafana/pyroscope-go v1.3.0 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect diff --git a/server/go.sum b/server/go.sum index 9931018..4ba42dc 100644 --- a/server/go.sum +++ b/server/go.sum @@ -34,6 +34,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.21.0 h1:5HEGrz+XhpCchubMGzuyLuGoCTlL/yCT7sGsT5Se/dw= +github.com/gopherjs/gopherjs v1.21.0/go.mod h1:R2HIOen3IzYSzvmvkeD8WOfiLN9wueR/T5Y+6z326Ck= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/pyroscope-go v1.3.0 h1:t3Jehad8vvqN4oRAB0LdmfQ5ZSUXQw3asoft+K4GAT8= @@ -79,6 +81,8 @@ github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9 github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f h1:67UmABhaufAdSFaRO/wcaTn0XgHWCirjwfFf21ZNPAc= +github.com/oov/psd v0.0.0-20260122084234-c463b6a89e2f/go.mod h1:GHI1bnmAcbp96z6LNfBJvtrjxhaXGkbsk967utPlvL8= github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= diff --git a/server/internal/application/design_service.go b/server/internal/application/design_service.go index adca3a9..4224288 100644 --- a/server/internal/application/design_service.go +++ b/server/internal/application/design_service.go @@ -51,25 +51,26 @@ const nodeActionResultNodeIDOption = "_resultNodeId" const generationRecoveryToolHint = "generation_recovery" type DesignService struct { - repo design.Repository - agent design.AgentRunner - assets design.AssetStorage - assetCleanup *assetCleanupQueue - jobs design.JobQueue - search design.Researcher - textExtractor design.TextExtractor - objectRecognizer design.ObjectRecognizer - imageVectorizer design.ImageVectorizer - backgroundRemover design.BackgroundRemover - mockupAnalyzer design.MockupModelAnalyzer - textExtractionCache textExtractionCacheStore - textExtractionCacheTTL time.Duration - creativeAgent design.CreativeAgent - cancelledAgentTasks sync.Map - agentTaskCancels sync.Map - generatorTaskMu sync.RWMutex - generatorTasks map[string]design.GeneratorTask - now func() time.Time + repo design.Repository + agent design.AgentRunner + assets design.AssetStorage + assetCleanup *assetCleanupQueue + jobs design.JobQueue + search design.Researcher + textExtractor design.TextExtractor + objectRecognizer design.ObjectRecognizer + imageVectorizer design.ImageVectorizer + backgroundRemover design.BackgroundRemover + layeredDocumentGenerator design.LayeredDocumentGenerator + mockupAnalyzer design.MockupModelAnalyzer + textExtractionCache textExtractionCacheStore + textExtractionCacheTTL time.Duration + creativeAgent design.CreativeAgent + cancelledAgentTasks sync.Map + agentTaskCancels sync.Map + generatorTaskMu sync.RWMutex + generatorTasks map[string]design.GeneratorTask + now func() time.Time } type agentTaskCancelHandle struct { @@ -136,6 +137,10 @@ func (s *DesignService) SetBackgroundRemover(remover design.BackgroundRemover) { s.backgroundRemover = remover } +func (s *DesignService) SetLayeredDocumentGenerator(generator design.LayeredDocumentGenerator) { + s.layeredDocumentGenerator = generator +} + func (s *DesignService) SetObjectRecognizer(recognizer design.ObjectRecognizer) { s.objectRecognizer = recognizer } @@ -313,7 +318,7 @@ func (s *DesignService) DeleteProjects(ctx context.Context, ids []string) (int64 return deleted, nil } -func (s *DesignService) CreateProject(ctx context.Context, title string, prompt string, imageModel string, imageSize string, enableWebSearch bool) (design.Project, error) { +func (s *DesignService) CreateProject(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool) (design.Project, error) { prompt = design.NormalizePrompt(prompt) if prompt == "" { project, _ := s.newProjectShell(ctx, title, prompt) @@ -347,11 +352,13 @@ func (s *DesignService) CreateProject(ctx context.Context, title string, prompt } agentReq := design.AgentRequest{ - Project: project, - Prompt: prompt, - Mode: "design", - ImageModel: strings.TrimSpace(imageModel), - ImageSize: strings.TrimSpace(imageSize), + Project: project, + Prompt: prompt, + Mode: "design", + ImageModel: strings.TrimSpace(imageModel), + ImageSize: strings.TrimSpace(imageSize), + ImageQuality: strings.TrimSpace(imageQuality), + ImageCount: imageCount, Messages: []design.AgentChatMessage{ { Role: "user", @@ -401,7 +408,7 @@ func (s *DesignService) CreateProject(ctx context.Context, title string, prompt return project, nil } -func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, prompt string, imageModel string, imageSize string, enableWebSearch bool) (design.Project, error) { +func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool) (design.Project, error) { project, prompt := s.newProjectShell(ctx, title, prompt) if prompt == "" { project = s.refreshCanvasSnapshot(project, project.UpdatedAt) @@ -425,11 +432,11 @@ func (s *DesignService) CreateProjectAsync(ctx context.Context, title string, pr return design.Project{}, err } - s.startCreateProjectGeneration(project.ID, project.UserID, threadID, prompt, imageModel, imageSize, enableWebSearch, design.AgentTaskPlan{}) + s.startCreateProjectGeneration(project.ID, project.UserID, threadID, prompt, imageModel, imageSize, imageQuality, imageCount, enableWebSearch, design.AgentTaskPlan{}) return project, nil } -func (s *DesignService) Generate(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string) (GenerateResult, error) { +func (s *DesignService) Generate(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int) (GenerateResult, error) { prompt = design.NormalizePrompt(prompt) if prompt == "" { return GenerateResult{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput) @@ -441,11 +448,13 @@ func (s *DesignService) Generate(ctx context.Context, projectID string, prompt s } agentReq := design.AgentRequest{ - Project: project, - Prompt: prompt, - Mode: mode, - ImageModel: strings.TrimSpace(imageModel), - ImageSize: strings.TrimSpace(imageSize), + Project: project, + Prompt: prompt, + Mode: mode, + ImageModel: strings.TrimSpace(imageModel), + ImageSize: strings.TrimSpace(imageSize), + ImageQuality: strings.TrimSpace(imageQuality), + ImageCount: imageCount, Messages: []design.AgentChatMessage{ { Role: "user", @@ -494,7 +503,7 @@ func (s *DesignService) Generate(ctx context.Context, projectID string, prompt s return GenerateResult{Project: project, Message: message, GeneratedNodes: generated}, nil } -func (s *DesignService) GenerateAsync(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string) (design.Project, error) { +func (s *DesignService) GenerateAsync(ctx context.Context, projectID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int) (design.Project, error) { thread, err := s.AgentChat(ctx, projectID, design.AgentChatRequest{ Messages: []design.AgentChatMessage{ { @@ -507,6 +516,8 @@ func (s *DesignService) GenerateAsync(ctx context.Context, projectID string, pro ThreadIDType: 9, ImageModel: imageModel, ImageSize: imageSize, + ImageQuality: imageQuality, + ImageCount: imageCount, Mode: mode, }) if err != nil { @@ -603,7 +614,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des } var taskPlan design.AgentTaskPlan if directImageMode { - taskPlan = directImageTaskPlan(prompt, req.Messages) + taskPlan = directImageTaskPlan(prompt, req.ImageCount, req.Messages) } else { taskPlan = s.planAgentTask(ctx, projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, req.Messages, enableWebSearch, isolatedImageGenerator, memory) } @@ -672,7 +683,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des } if shouldGenerateImage { - s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, mode, req.ImageModel, req.ImageSize, req.Messages, enableWebSearch, isolatedImageGenerator, taskPlan) + s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, mode, req.ImageModel, req.ImageSize, req.ImageQuality, req.ImageCount, req.Messages, enableWebSearch, isolatedImageGenerator, taskPlan) } else { if assistantMessageID == "" && len(project.Messages) > 0 { assistantMessageID = project.Messages[len(project.Messages)-1].ID @@ -1650,9 +1661,9 @@ func (s *DesignService) ProcessJob(ctx context.Context, job design.Job) error { ctx = design.ContextWithUserID(ctx, job.UserID) switch job.Kind { case design.JobCreateProjectGeneration: - return s.finishGenerationJob(job, s.completeCreateProjectGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.ImageModel, job.ImageSize, job.EnableWebSearch, job.TaskPlan)) + return s.finishGenerationJob(job, s.completeCreateProjectGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.ImageModel, job.ImageSize, job.ImageQuality, job.ImageCount, job.EnableWebSearch, job.TaskPlan)) case design.JobFollowupGeneration: - return s.finishGenerationJob(job, s.completeFollowupGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.Mode, job.ImageModel, job.ImageSize, job.Messages, job.EnableWebSearch, job.Isolated, job.TaskPlan)) + return s.finishGenerationJob(job, s.completeFollowupGeneration(ctx, job.ProjectID, job.ThreadID, job.Prompt, job.Mode, job.ImageModel, job.ImageSize, job.ImageQuality, job.ImageCount, job.Messages, job.EnableWebSearch, job.Isolated, job.TaskPlan)) case design.JobNodeActionGeneration: return s.completeNodeActionGeneration(ctx, job.ProjectID, job.ThreadID, job.Target, job.Request) case design.JobAgentConversation: @@ -2466,7 +2477,7 @@ func mergeSavedCanvasWorkState(existing []design.Node, incoming []design.Node) [ return next } -func (s *DesignService) startCreateProjectGeneration(projectID string, userID string, threadID string, prompt string, imageModel string, imageSize string, enableWebSearch bool, taskPlan design.AgentTaskPlan) { +func (s *DesignService) startCreateProjectGeneration(projectID string, userID string, threadID string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool, taskPlan design.AgentTaskPlan) { s.enqueueOrRunJob(design.Job{ Kind: design.JobCreateProjectGeneration, UserID: userID, @@ -2475,14 +2486,16 @@ func (s *DesignService) startCreateProjectGeneration(projectID string, userID st Prompt: prompt, ImageModel: imageModel, ImageSize: imageSize, + ImageQuality: imageQuality, + ImageCount: imageCount, TaskPlan: taskPlan, EnableWebSearch: enableWebSearch, }, func(ctx context.Context) error { - return s.completeCreateProjectGeneration(ctx, projectID, threadID, prompt, imageModel, imageSize, enableWebSearch, taskPlan) + return s.completeCreateProjectGeneration(ctx, projectID, threadID, prompt, imageModel, imageSize, imageQuality, imageCount, enableWebSearch, taskPlan) }) } -func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, projectID string, threadID string, prompt string, imageModel string, imageSize string, enableWebSearch bool, taskPlan design.AgentTaskPlan) error { +func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, projectID string, threadID string, prompt string, imageModel string, imageSize string, imageQuality string, imageCount int, enableWebSearch bool, taskPlan design.AgentTaskPlan) error { ctx, cleanup := s.withAgentTaskCancellation(ctx, projectID, threadID) defer cleanup() if err := ctx.Err(); err != nil { @@ -2570,14 +2583,16 @@ func (s *DesignService) completeCreateProjectGeneration(ctx context.Context, pro } plan, incremental, err := s.planWithIncrementalImages(ctx, projectID, threadID, prompt, "", true, design.AgentRequest{ - Project: project, - Prompt: prompt, - Mode: "design", - ImageModel: strings.TrimSpace(imageModel), - ImageSize: strings.TrimSpace(imageSize), - Messages: messages, - Memory: buildAgentMemory(project, prompt, "design", messages), - TaskPlan: taskPlan, + Project: project, + Prompt: prompt, + Mode: "design", + ImageModel: strings.TrimSpace(imageModel), + ImageSize: strings.TrimSpace(imageSize), + ImageQuality: strings.TrimSpace(imageQuality), + ImageCount: imageCount, + Messages: messages, + Memory: buildAgentMemory(project, prompt, "design", messages), + TaskPlan: taskPlan, }) if err != nil { if s.isUserCancelledGeneration(ctx, projectID, threadID, err) { @@ -2865,7 +2880,7 @@ func findPlannerMessageIndex(messages []design.Message, threadID string) int { return -1 } -func (s *DesignService) startFollowupGeneration(projectID string, userID string, threadID string, prompt string, mode string, imageModel string, imageSize string, messages []design.AgentChatMessage, enableWebSearch bool, isolated bool, taskPlan design.AgentTaskPlan) { +func (s *DesignService) startFollowupGeneration(projectID string, userID string, threadID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int, messages []design.AgentChatMessage, enableWebSearch bool, isolated bool, taskPlan design.AgentTaskPlan) { s.enqueueOrRunJob(design.Job{ Kind: design.JobFollowupGeneration, UserID: userID, @@ -2875,16 +2890,18 @@ func (s *DesignService) startFollowupGeneration(projectID string, userID string, Mode: mode, ImageModel: imageModel, ImageSize: imageSize, + ImageQuality: imageQuality, + ImageCount: imageCount, TaskPlan: taskPlan, Messages: messages, EnableWebSearch: enableWebSearch, Isolated: isolated, }, func(ctx context.Context) error { - return s.completeFollowupGeneration(ctx, projectID, threadID, prompt, mode, imageModel, imageSize, messages, enableWebSearch, isolated, taskPlan) + return s.completeFollowupGeneration(ctx, projectID, threadID, prompt, mode, imageModel, imageSize, imageQuality, imageCount, messages, enableWebSearch, isolated, taskPlan) }) } -func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectID string, threadID string, prompt string, mode string, imageModel string, imageSize string, messages []design.AgentChatMessage, enableWebSearch bool, isolated bool, taskPlan design.AgentTaskPlan) error { +func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectID string, threadID string, prompt string, mode string, imageModel string, imageSize string, imageQuality string, imageCount int, messages []design.AgentChatMessage, enableWebSearch bool, isolated bool, taskPlan design.AgentTaskPlan) error { ctx, cleanup := s.withAgentTaskCancellation(ctx, projectID, threadID) defer cleanup() if err := ctx.Err(); err != nil { @@ -2924,14 +2941,16 @@ func (s *DesignService) completeFollowupGeneration(ctx context.Context, projectI } request := design.AgentRequest{ - Project: projectScopedToAgentThread(project, threadID), - Prompt: prompt, - Mode: mode, - ImageModel: strings.TrimSpace(imageModel), - ImageSize: strings.TrimSpace(imageSize), - Messages: messages, - Memory: buildAgentMemory(projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, messages), - TaskPlan: taskPlan, + Project: projectScopedToAgentThread(project, threadID), + Prompt: prompt, + Mode: mode, + ImageModel: strings.TrimSpace(imageModel), + ImageSize: strings.TrimSpace(imageSize), + ImageQuality: strings.TrimSpace(imageQuality), + ImageCount: imageCount, + Messages: messages, + Memory: buildAgentMemory(projectScopedToAgentThread(projectWithoutGeneratingNodes(project), threadID), prompt, mode, messages), + TaskPlan: taskPlan, } var plan design.AgentPlan var incremental bool @@ -3507,8 +3526,11 @@ func isDirectImageGenerationMode(mode string, isolated bool) bool { return modeText == "image" || modeText == "pure-image" || modeText == "image-generator" || strings.Contains(modeText, "image-action") } -func directImageTaskPlan(prompt string, messages []design.AgentChatMessage) design.AgentTaskPlan { - count := imageModeSettingsCount(messages) +func directImageTaskPlan(prompt string, imageCount int, messages []design.AgentChatMessage) design.AgentTaskPlan { + count := imageCount + if count <= 0 { + count = imageModeSettingsCount(messages) + } if count <= 0 { count = 1 } @@ -3999,7 +4021,7 @@ func (s *DesignService) recoverOrphanedAgentGeneration(ctx context.Context, proj }, }, } - s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, "design", "gpt-image-2", imageSize, messages, false, false, taskPlan) + s.startFollowupGeneration(project.ID, project.UserID, threadID, prompt, "design", "gpt-image-2", imageSize, "", 0, messages, false, false, taskPlan) logx.Errorf("recovered orphaned image generation project=%s thread=%s attempt=%d placeholders=%d", project.ID, threadID, attempt, len(placeholders)) return project, true } diff --git a/server/internal/application/generation_failure_test.go b/server/internal/application/generation_failure_test.go index 91b59b1..6348c79 100644 --- a/server/internal/application/generation_failure_test.go +++ b/server/internal/application/generation_failure_test.go @@ -576,14 +576,14 @@ func TestAgentChatImageModeBypassesPlannerAndHidesSettings(t *testing.T) { } thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ - Mode: "image", - ImageSize: "1536x1024", + Mode: "image", + ImageSize: "1536x1024", + ImageCount: 3, Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "text", Text: "a cute bird flatten ui", TextSource: "input"}, - {Type: "text", Text: "图片生成参数:质量 自动,尺寸 1536x1024,比例 3:2,数量 3 张。纯生图模式,请直接围绕用户输入生成图片,不展开额外聊天。", TextSource: "settings"}, }, }, }, @@ -747,7 +747,7 @@ func TestCreateProjectAsyncReturnsBeforeTaskPlanning(t *testing.T) { service := NewDesignService(repo, nil, nil, nil, nil, planner) service.SetJobQueue(queue) - project, err := service.CreateProjectAsync(ctx, "", "设计一个二次元水墨角色,输出正面、侧面、细节。", "gpt-image-2", "", true) + project, err := service.CreateProjectAsync(ctx, "", "设计一个二次元水墨角色,输出正面、侧面、细节。", "gpt-image-2", "", "", 0, true) if err != nil { t.Fatal(err) } @@ -777,7 +777,7 @@ func TestCreateProjectAsyncWithEmptyPromptCreatesBlankCanvas(t *testing.T) { }) service.SetJobQueue(queue) - project, err := service.CreateProjectAsync(ctx, "", "", "gpt-image-2", "", true) + project, err := service.CreateProjectAsync(ctx, "", "", "gpt-image-2", "", "", 0, true) if err != nil { t.Fatal(err) } @@ -810,7 +810,7 @@ func TestCreateProjectGenerationAddsPlaceholdersAfterPlannerReturns(t *testing.T } service := NewDesignService(repo, inspectingCreateProjectAgent{repo: repo, t: t, expected: 3}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) - project, err := service.CreateProjectAsync(ctx, "", "设计二次元水墨人物形象,输出角色正面、侧面、细节。", "gpt-image-2", "", false) + project, err := service.CreateProjectAsync(ctx, "", "设计二次元水墨人物形象,输出角色正面、侧面、细节。", "gpt-image-2", "", "", 0, false) if err != nil { t.Fatal(err) } @@ -853,7 +853,7 @@ func TestCreateProjectGenerationStreamsEachImageAsItFinishes(t *testing.T) { } service := NewDesignService(repo, incrementalCreateProjectAgent{repo: repo, t: t}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) - project, err := service.CreateProjectAsync(ctx, "", "为桌游卡牌产品创建品牌视觉,包含卡背和卡面信息层级。", "gpt-image-2", "", false) + project, err := service.CreateProjectAsync(ctx, "", "为桌游卡牌产品创建品牌视觉,包含卡背和卡面信息层级。", "gpt-image-2", "", "", 0, false) if err != nil { t.Fatal(err) } @@ -902,7 +902,7 @@ func TestCreateProjectGenerationFinalizesAfterStaleHeartbeatOverwrite(t *testing } service := NewDesignService(repo, staleOverwriteIncrementalCreateProjectAgent{repo: repo, t: t}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) - project, err := service.CreateProjectAsync(ctx, "", "设计一张盲盒IP视觉体系。", "gpt-image-2", "", false) + project, err := service.CreateProjectAsync(ctx, "", "设计一张盲盒IP视觉体系。", "gpt-image-2", "", "", 0, false) if err != nil { t.Fatal(err) } @@ -959,7 +959,7 @@ func TestCreateProjectGenerationPartialFailurePreservesSuccessfulImagesAndCloses } service := NewDesignService(repo, partialFailingIncrementalCreateProjectAgent{}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) - project, err := service.CreateProjectAsync(ctx, "", "生成两张品牌视觉。", "gpt-image-2", "", false) + project, err := service.CreateProjectAsync(ctx, "", "生成两张品牌视觉。", "gpt-image-2", "", "", 0, false) if err != nil { t.Fatal(err) } diff --git a/server/internal/application/generator_task.go b/server/internal/application/generator_task.go index 32a8036..feddbb0 100644 --- a/server/internal/application/generator_task.go +++ b/server/internal/application/generator_task.go @@ -268,6 +268,7 @@ func normalizeMoveObjectInput(input design.GeneratorTaskInputArgs) design.Genera input.DstBox = &box } input.ImageURL = strings.TrimSpace(input.ImageURL) + input.OutputFormat = strings.TrimSpace(input.OutputFormat) input.Prompt = strings.TrimSpace(input.Prompt) input.Resolution = strings.TrimSpace(input.Resolution) input.AspectRatio = strings.TrimSpace(input.AspectRatio) @@ -279,6 +280,10 @@ func normalizeLayerSeparationInput(input design.GeneratorTaskInputArgs) design.G input.ImageURL = strings.TrimSpace(input.Image[0]) } input.ImageURL = strings.TrimSpace(input.ImageURL) + input.OutputFormat = strings.TrimSpace(input.OutputFormat) + if input.OutputFormat == "" { + input.OutputFormat = "psd" + } input.Prompt = strings.TrimSpace(input.Prompt) input.Resolution = strings.TrimSpace(input.Resolution) input.AspectRatio = strings.TrimSpace(input.AspectRatio) @@ -777,15 +782,19 @@ func generatorTaskInputArgsForNodeAction(target design.Node, req design.NodeActi } width, height := design.GeneratedImageDimensions(imageSize) input := design.GeneratorTaskInputArgs{ - AspectRatio: aspectRatioLabel(int64(math.Round(width)), int64(math.Round(height))), - Image: []string{target.Content}, - ImageURL: target.Content, - Prompt: generatorTaskPromptForNodeAction(req), - Resolution: resolutionLabel(imageSize), + AspectRatio: aspectRatioLabel(int64(math.Round(width)), int64(math.Round(height))), + Image: []string{target.Content}, + ImageURL: target.Content, + OutputFormat: strings.TrimSpace(req.Options["output_format"]), + Prompt: generatorTaskPromptForNodeAction(req), + Resolution: resolutionLabel(imageSize), } if normalizeNodeAction(req.Action) == "move-object" { input.SrcBox, input.DstBox = moveObjectBoxesFromSelection(req.Selection) } + if normalizeNodeAction(req.Action) == "edit-elements" && input.OutputFormat == "" { + input.OutputFormat = "psd" + } return input } diff --git a/server/internal/application/layer_separation.go b/server/internal/application/layer_separation.go index 3f41d86..4b2a7a8 100644 --- a/server/internal/application/layer_separation.go +++ b/server/internal/application/layer_separation.go @@ -10,13 +10,18 @@ import ( "image/color" "image/draw" "image/png" + "io" "math" + "net/http" "sort" "strconv" "strings" "time" "img_infinite_canvas/internal/domain/design" + + psd "github.com/oov/psd" + "github.com/zeromicro/go-zero/core/logx" ) const ( @@ -54,6 +59,18 @@ type layerSeparationForegroundCandidate struct { Image image.Image } +type layerSeparationDocumentLayer struct { + Name string + Rect image.Rectangle + Image image.Image + Opacity uint8 +} + +type layerSeparationDocument struct { + Bounds image.Rectangle + Layers []layerSeparationDocumentLayer +} + 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 { @@ -151,6 +168,9 @@ func (s *DesignService) createLayerSeparationResult(ctx context.Context, project if err != nil { return layerSeparationResult{}, err } + if result, ok := s.createLayerSeparationResultFromLayeredDocument(ctx, projectID, target, req, source, bounds, extraction); ok { + return result, nil + } for _, layer := range extraction.Layers { removeTextLayerFromImage(canvas, layer) } @@ -188,6 +208,10 @@ func (s *DesignService) createLayerSeparationResult(ctx context.Context, project } } + return s.finishLayerSeparationResult(ctx, projectID, canvasWidth, canvasHeight, canvas, foregrounds, extraction) +} + +func (s *DesignService) finishLayerSeparationResult(ctx context.Context, projectID string, canvasWidth int64, canvasHeight int64, canvas image.Image, foregrounds []layerSeparationForeground, extraction design.TextExtraction) (layerSeparationResult, error) { backgroundID := newID() backgroundURL, backgroundSize, err := s.persistLayerSeparationImage(ctx, projectID, backgroundID, "background", canvas) if err != nil { @@ -265,6 +289,321 @@ func (s *DesignService) extractLayerSeparationText(ctx context.Context, target d return s.extractTextExtraction(ctx, target, req) } +func (s *DesignService) createLayerSeparationResultFromLayeredDocument(ctx context.Context, projectID string, target design.Node, req design.NodeActionRequest, source image.Image, bounds image.Rectangle, extraction design.TextExtraction) (layerSeparationResult, bool) { + if s.layeredDocumentGenerator == nil { + return layerSeparationResult{}, false + } + document, err := s.layeredDocumentGenerator.GenerateLayeredDocument(ctx, design.LayeredDocumentRequest{ + ImageURL: target.Content, + Prompt: layerSeparationPSDPrompt(req.Prompt), + Model: "gpt-image-2", + Size: "auto", + OutputFormat: "psd", + IdempotencyKey: nodeActionResultNodeID(target, req) + "-psd", + }) + if err != nil { + logx.Errorf("layer separation psd generation failed: %v", err) + return layerSeparationResult{}, false + } + data, err := layeredDocumentData(ctx, document) + if err != nil { + logx.Errorf("layer separation psd download failed: %v", err) + return layerSeparationResult{}, false + } + decoded, err := decodeLayerSeparationDocument(data) + if err != nil { + logx.Errorf("layer separation psd decode failed: %v", err) + return layerSeparationResult{}, false + } + if !decoded.Bounds.Eq(bounds) { + logx.Errorf("layer separation psd dimensions mismatch source=%dx%d psd=%dx%d", bounds.Dx(), bounds.Dy(), decoded.Bounds.Dx(), decoded.Bounds.Dy()) + return layerSeparationResult{}, false + } + result, err := s.layerSeparationResultFromDocument(ctx, projectID, source, decoded, extraction) + if err != nil { + logx.Errorf("layer separation psd conversion failed: %v", err) + return layerSeparationResult{}, false + } + return result, true +} + +func layerSeparationPSDPrompt(userPrompt string) string { + var builder strings.Builder + builder.WriteString("Create a layered PSD from the provided image for element editing. Preserve the exact canvas size and visual alignment. The bottom layer must be one clean background image with all text and small standalone foreground elements removed. Put each small standalone decorative or object element on its own transparent image layer above the background. Keep all text as editable text layers when possible, preserving text, font style, color, alignment, spacing, rotation, and position. Do not rasterize text into the background. Do not split large poster scenery into foreground layers. Return only the PSD file.") + if prompt := strings.TrimSpace(userPrompt); prompt != "" { + builder.WriteString("\nAdditional instruction: ") + builder.WriteString(prompt) + } + return builder.String() +} + +func layeredDocumentData(ctx context.Context, document design.LayeredDocument) ([]byte, error) { + if len(document.Data) > 0 { + return document.Data, nil + } + content := strings.TrimSpace(document.Content) + if content == "" { + return nil, fmt.Errorf("layered document returned empty content") + } + if strings.HasPrefix(content, "data:") { + _, payload, ok := strings.Cut(content, ",") + if !ok { + return nil, fmt.Errorf("invalid layered document data url") + } + return base64.StdEncoding.DecodeString(payload) + } + if !strings.HasPrefix(content, "http://") && !strings.HasPrefix(content, "https://") { + return nil, fmt.Errorf("unsupported layered document url") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, content, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "img-infinite-canvas/1.0") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("download layered document failed: %s", resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 96<<20)) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, fmt.Errorf("download layered document returned empty body") + } + return data, nil +} + +func decodeLayerSeparationDocument(data []byte) (layerSeparationDocument, error) { + doc, _, err := psd.Decode(bytes.NewReader(data), &psd.DecodeOptions{SkipMergedImage: true}) + if err != nil { + return layerSeparationDocument{}, err + } + result := layerSeparationDocument{ + Bounds: doc.Config.Rect, + Layers: make([]layerSeparationDocumentLayer, 0, len(doc.Layer)), + } + var collect func([]psd.Layer) + collect = func(layers []psd.Layer) { + for i := len(layers) - 1; i >= 0; i-- { + layer := layers[i] + if len(layer.Layer) > 0 { + collect(layer.Layer) + } + if !layer.Visible() || !layer.HasImage() || layer.Picker == nil || layer.Rect.Empty() { + continue + } + name := strings.TrimSpace(layer.UnicodeName) + if name == "" { + name = strings.TrimSpace(layer.Name) + } + result.Layers = append(result.Layers, layerSeparationDocumentLayer{ + Name: name, + Rect: layer.Rect, + Image: layer.Picker, + Opacity: layer.Opacity, + }) + } + } + collect(doc.Layer) + if result.Bounds.Empty() || len(result.Layers) == 0 { + return layerSeparationDocument{}, fmt.Errorf("layered document has no usable layers") + } + return result, nil +} + +func (s *DesignService) layerSeparationResultFromDocument(ctx context.Context, projectID string, source image.Image, document layerSeparationDocument, extraction design.TextExtraction) (layerSeparationResult, error) { + backgroundIndex := layerSeparationBackgroundLayerIndex(document) + if backgroundIndex < 0 { + return layerSeparationResult{}, fmt.Errorf("layered document has no background layer") + } + background := image.NewRGBA(document.Bounds) + draw.Draw(background, document.Bounds, source, source.Bounds().Min, draw.Src) + drawLayerSeparationDocumentLayer(background, document.Layers[backgroundIndex]) + for _, layer := range extraction.Layers { + removeTextLayerFromImage(background, layer) + } + + foregrounds := make([]layerSeparationForeground, 0, len(document.Layers)) + allowLargeForeground := len(extraction.Layers) == 0 && layerSeparationImageIsPlain(background, document.Bounds) + for index, layer := range document.Layers { + if index == backgroundIndex || layerSeparationDocumentLayerIsText(layer, extraction, document.Bounds) { + continue + } + candidate, ok := layerSeparationForegroundCandidateFromDocumentLayer(layer, document.Bounds) + if !ok { + continue + } + count := alphaMaskCount(candidate.Mask, candidate.Rect) + if !keepLayerSeparationElement(candidate.Rect, count, document.Bounds) && !(allowLargeForeground && keepLayerSeparationLargeSubject(candidate.Rect, count, document.Bounds)) { + continue + } + foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, candidate) + if !ok { + continue + } + removeForegroundMaskFromImage(background, foreground.Mask, foreground.Rect) + foregrounds = append(foregrounds, foreground) + } + return s.finishLayerSeparationResult(ctx, projectID, int64(document.Bounds.Dx()), int64(document.Bounds.Dy()), background, foregrounds, extraction) +} + +func layerSeparationImageIsPlain(img *image.RGBA, bounds image.Rectangle) bool { + if img == nil || bounds.Empty() { + return false + } + mask := image.NewAlpha(bounds) + stats, ok := unmaskedColorStats(img, mask, bounds) + return ok && stats.StdDev <= 24 +} + +func keepLayerSeparationLargeSubject(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 + } + bboxArea := rect.Dx() * rect.Dy() + if float64(bboxArea)/float64(totalArea) > 0.78 { + return false + } + if float64(rect.Dx())/float64(bounds.Dx()) > 0.9 || float64(rect.Dy())/float64(bounds.Dy()) > 0.96 { + return false + } + return count >= totalArea/80 +} + +func layerSeparationBackgroundLayerIndex(document layerSeparationDocument) int { + bestIndex := -1 + bestScore := -1 + totalArea := document.Bounds.Dx() * document.Bounds.Dy() + for index, layer := range document.Layers { + if layer.Rect.Empty() || layer.Image == nil { + continue + } + name := strings.ToLower(layer.Name) + area := layer.Rect.Intersect(document.Bounds).Dx() * layer.Rect.Intersect(document.Bounds).Dy() + if strings.Contains(name, "bg") || strings.Contains(name, "background") || strings.Contains(name, "clean") || strings.Contains(name, "base") { + return index + } + if area > bestScore { + bestScore = area + bestIndex = index + } + } + if totalArea <= 0 || bestScore < int(float64(totalArea)*0.45) { + return -1 + } + return bestIndex +} + +func drawLayerSeparationDocumentLayer(dst *image.RGBA, layer layerSeparationDocumentLayer) { + if dst == nil || layer.Image == nil || layer.Rect.Empty() { + return + } + rect := layer.Rect.Intersect(dst.Bounds()) + if rect.Empty() { + return + } + if layer.Opacity == 0 { + return + } + if layer.Opacity == 255 { + draw.Draw(dst, rect, layer.Image, rect.Min, draw.Src) + return + } + tmp := image.NewNRGBA(rect) + draw.Draw(tmp, rect, layer.Image, rect.Min, draw.Src) + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + offset := tmp.PixOffset(x, y) + tmp.Pix[offset+3] = uint8(int(tmp.Pix[offset+3]) * int(layer.Opacity) / 255) + } + } + draw.Draw(dst, rect, tmp, rect.Min, draw.Over) +} + +func layerSeparationDocumentLayerIsText(layer layerSeparationDocumentLayer, extraction design.TextExtraction, bounds image.Rectangle) bool { + name := strings.ToLower(strings.TrimSpace(layer.Name)) + if strings.Contains(name, "text") || strings.Contains(name, "type") || strings.Contains(name, "font") || strings.Contains(name, "文字") { + return true + } + if len(extraction.Layers) == 0 || layer.Rect.Empty() { + return false + } + layerRect := layer.Rect.Intersect(bounds) + if layerRect.Empty() { + return false + } + layerArea := float64(layerRect.Dx() * layerRect.Dy()) + for _, textLayer := range extraction.Layers { + textRect := normalizedTextMaskRect(bounds, textLayer) + intersection := layerRect.Intersect(textRect) + if intersection.Empty() { + continue + } + intersectionArea := float64(intersection.Dx() * intersection.Dy()) + if intersectionArea/layerArea > 0.28 { + return true + } + } + return false +} + +func layerSeparationForegroundCandidateFromDocumentLayer(layer layerSeparationDocumentLayer, bounds image.Rectangle) (layerSeparationForegroundCandidate, bool) { + if layer.Image == nil || layer.Rect.Empty() { + return layerSeparationForegroundCandidate{}, false + } + if layer.Opacity == 0 { + return layerSeparationForegroundCandidate{}, false + } + foreground := image.NewNRGBA(bounds) + rect := layer.Rect.Intersect(bounds) + if rect.Empty() { + return layerSeparationForegroundCandidate{}, false + } + draw.Draw(foreground, rect, layer.Image, rect.Min, draw.Src) + if layer.Opacity > 0 && layer.Opacity < 255 { + 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] = uint8(int(foreground.Pix[offset+3]) * int(layer.Opacity) / 255) + } + } + } + contentRect, ok := alphaContentBounds(foreground, bounds, 12) + if !ok { + return layerSeparationForegroundCandidate{}, false + } + mask, count := alphaMask(foreground, bounds, 12) + if count == 0 { + return layerSeparationForegroundCandidate{}, false + } + return newLayerSeparationForegroundCandidate(foreground, mask, contentRect, bounds), true +} + +func alphaMaskCount(mask *image.Alpha, rect image.Rectangle) int { + if mask == nil || rect.Empty() { + return 0 + } + count := 0 + rect = rect.Intersect(mask.Bounds()) + 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 { + count++ + } + } + } + return count +} + 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 diff --git a/server/internal/application/layer_separation_test.go b/server/internal/application/layer_separation_test.go index 6a7bca6..27d7597 100644 --- a/server/internal/application/layer_separation_test.go +++ b/server/internal/application/layer_separation_test.go @@ -311,6 +311,98 @@ func TestLayerSeparationPosterKeepsComplexBackgroundAndExtractsSmallElements(t * } } +func TestLayerSeparationDocumentPlacesSmallImagesAndTextOverBackground(t *testing.T) { + ctx := context.Background() + service := NewDesignService(nil, nil, nil, nil, nil) + source := testPosterSourceImage() + background := testPosterCleanBackgroundImage() + ornament := image.NewNRGBA(image.Rect(54, 44, 66, 56)) + for y := 44; y < 56; y++ { + for x := 54; x < 66; x++ { + ornament.SetNRGBA(x, y, color.NRGBA{R: 225, G: 32, B: 44, A: 255}) + } + } + textRaster := image.NewNRGBA(image.Rect(8, 8, 36, 18)) + for y := 8; y < 18; y++ { + for x := 8; x < 36; x++ { + textRaster.SetNRGBA(x, y, color.NRGBA{R: 17, G: 17, B: 17, A: 255}) + } + } + + result, err := service.layerSeparationResultFromDocument(ctx, "project-layered-doc", source, layerSeparationDocument{ + Bounds: source.Bounds(), + Layers: []layerSeparationDocumentLayer{ + {Name: "clean background", Rect: source.Bounds(), Image: background, Opacity: 255}, + {Name: "decorative element", Rect: ornament.Bounds(), Image: ornament, Opacity: 255}, + {Name: "text title", Rect: textRaster.Bounds(), Image: textRaster, Opacity: 255}, + }, + }, design.TextExtraction{ + Layers: []design.ExtractedTextLayer{{Text: "SALE", X: 0.1, Y: 0.1, Width: 0.35, Height: 0.125, Color: "#111111"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(result.Foregrounds) != 1 { + t.Fatalf("expected one small image layer, 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 foreground bbox: %#v", got) + } + if len(result.TextExtraction.Layers) != 1 || result.TextExtraction.Layers[0].Text != "SALE" { + t.Fatalf("expected editable OCR text, got %#v", result.TextExtraction.Layers) + } + backgroundData, _, err := loadImageContent(ctx, result.BackgroundURL) + if err != nil { + t.Fatal(err) + } + decodedBackground, _, err := image.Decode(bytes.NewReader(backgroundData)) + if err != nil { + t.Fatal(err) + } + ornamentPixel := color.NRGBAModel.Convert(decodedBackground.At(59, 49)).(color.NRGBA) + if ornamentPixel.R > 200 && ornamentPixel.G < 80 { + t.Fatalf("expected foreground element removed from background, got %#v", ornamentPixel) + } +} + +func TestLayerSeparationDocumentAllowsLargeSubjectOnPlainBackground(t *testing.T) { + ctx := context.Background() + service := NewDesignService(nil, nil, nil, nil, nil) + bounds := image.Rect(0, 0, 100, 100) + source := image.NewNRGBA(bounds) + background := image.NewNRGBA(bounds) + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + source.SetNRGBA(x, y, color.NRGBA{R: 230, G: 228, B: 224, A: 255}) + background.SetNRGBA(x, y, color.NRGBA{R: 230, G: 228, B: 224, A: 255}) + } + } + subject := image.NewNRGBA(image.Rect(28, 14, 74, 92)) + for y := 14; y < 92; y++ { + for x := 28; x < 74; x++ { + subject.SetNRGBA(x, y, color.NRGBA{R: 36, G: 28, B: 22, A: 255}) + source.SetNRGBA(x, y, color.NRGBA{R: 36, G: 28, B: 22, A: 255}) + } + } + + result, err := service.layerSeparationResultFromDocument(ctx, "project-plain-subject", source, layerSeparationDocument{ + Bounds: bounds, + Layers: []layerSeparationDocumentLayer{ + {Name: "background", Rect: bounds, Image: background, Opacity: 255}, + {Name: "subject", Rect: subject.Bounds(), Image: subject, Opacity: 255}, + }, + }, design.TextExtraction{}) + if err != nil { + t.Fatal(err) + } + if len(result.Foregrounds) != 1 { + t.Fatalf("expected large subject foreground on plain background, got %#v", result.Foregrounds) + } + if got := result.Foregrounds[0].BBox; len(got) != 4 || got[0] != 28 || got[1] != 14 || got[2] != 74 || got[3] != 92 { + t.Fatalf("unexpected subject bbox: %#v", got) + } +} + func waitForStandaloneGeneratorTask(t *testing.T, service *DesignService, taskID string) design.GeneratorTask { t.Helper() deadline := time.Now().Add(2 * time.Second) @@ -360,6 +452,10 @@ func testLayerSeparationForegroundPNG(t *testing.T) []byte { } func testPosterSourceDataURL() string { + return testPNGDataURLFromImage(testPosterSourceImage()) +} + +func testPosterSourceImage() *image.NRGBA { img := image.NewNRGBA(image.Rect(0, 0, 80, 80)) for y := 0; y < 80; y++ { for x := 0; x < 80; x++ { @@ -381,7 +477,22 @@ func testPosterSourceDataURL() string { img.SetNRGBA(x, y, color.NRGBA{R: 225, G: 32, B: 44, A: 255}) } } - return testPNGDataURLFromImage(img) + return img +} + +func testPosterCleanBackgroundImage() *image.NRGBA { + 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}) + } + } + return img } func testPosterForegroundPNG(t *testing.T) []byte {