feat(canvas): build PSD export in the browser from canvas layers

Move PSD generation off the server. The image-generation API only
produces raster formats, so drop the PSD request path, the oov/psd
decoder, and the LayeredDocumentGenerator wiring. Layer separation now
returns clean background, foreground, and text_render_data artifacts
into a transparent frame.

Export that frame (or any image node) as PSD from the canvas context
menu: ag-psd assembles layers, rotation/flip, opacity, editable text,
and stroke effects from the current canvas state at download time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:01:46 +08:00
parent 4ff246a294
commit 60130b3d9b
17 changed files with 424 additions and 614 deletions
+19 -24
View File
@@ -51,26 +51,25 @@ 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
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
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
}
type agentTaskCancelHandle struct {
@@ -137,10 +136,6 @@ 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
}
@@ -280,10 +280,7 @@ 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.OutputFormat = ""
input.Prompt = strings.TrimSpace(input.Prompt)
input.Resolution = strings.TrimSpace(input.Resolution)
input.AspectRatio = strings.TrimSpace(input.AspectRatio)
@@ -792,8 +789,8 @@ func generatorTaskInputArgsForNodeAction(target design.Node, req design.NodeActi
if normalizeNodeAction(req.Action) == "move-object" {
input.SrcBox, input.DstBox = moveObjectBoxesFromSelection(req.Selection)
}
if normalizeNodeAction(req.Action) == "edit-elements" && input.OutputFormat == "" {
input.OutputFormat = "psd"
if normalizeNodeAction(req.Action) == "edit-elements" {
input.OutputFormat = ""
}
return input
}
@@ -7,6 +7,15 @@ import (
"img_infinite_canvas/internal/domain/design"
)
func TestNormalizeLayerSeparationInputDoesNotClaimPSDOutput(t *testing.T) {
input := normalizeLayerSeparationInput(design.GeneratorTaskInputArgs{
ImageURL: "https://example.com/source.png",
})
if input.OutputFormat != "" {
t.Fatalf("expected layer separation task output format to describe no upstream raster format, got %q", input.OutputFormat)
}
}
func TestGeneratorTaskStatusWaitsForActiveCanvasWork(t *testing.T) {
threadID := "thread-image"
project := design.Project{
+1 -335
View File
@@ -10,18 +10,13 @@ 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 (
@@ -59,18 +54,6 @@ 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 {
@@ -168,9 +151,6 @@ 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)
}
@@ -289,321 +269,6 @@ 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
@@ -854,6 +519,7 @@ func newLayerSeparationBackgroundNode(frame design.Node, result layerSeparationR
Content: result.BackgroundURL,
Tone: "visual",
Status: "success",
ParentID: frame.ID,
LayerRole: "clean-image",
}
}
@@ -117,8 +117,8 @@ func TestRunEditElementsSeparatesBackgroundForegroundAndTextLayers(t *testing.T)
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)
if background.ParentID != frame.ID {
t.Fatalf("expected clean background to be the bottom layer inside the frame, got %#v", background)
}
foreground := saved.Nodes[3]
if foreground.LayerRole != "foreground-image" || foreground.ParentID != frame.ID {
@@ -311,98 +311,6 @@ 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)
@@ -480,21 +388,6 @@ func testPosterSourceImage() *image.NRGBA {
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 {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, 80, 80))