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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user