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
@@ -117,7 +117,7 @@ func normalizeInputImageTransport(value string) string {
func normalizeImageOutputFormat(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "psd", "png", "jpeg", "jpg", "webp":
case "png", "jpeg", "jpg", "webp":
if strings.EqualFold(strings.TrimSpace(value), "jpg") {
return "jpeg"
}
@@ -136,47 +136,6 @@ func normalizeImageQuality(value string) string {
}
}
func (c *ImageClient) GenerateLayeredDocument(ctx context.Context, req design.LayeredDocumentRequest) (design.LayeredDocument, error) {
imageURL := strings.TrimSpace(req.ImageURL)
if imageURL == "" {
return design.LayeredDocument{}, fmt.Errorf("layered document image_url is required")
}
outputFormat := normalizeImageOutputFormat(req.OutputFormat)
if outputFormat == "" {
outputFormat = "psd"
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = "Create a layered PSD from the provided image. Preserve the exact canvas and visual design. Put a single clean background layer at the bottom with all text and small foreground elements removed. Put each small standalone foreground element on its own image layer. Keep text as editable text layers when possible. Do not merge text into the background. Return only the layered PSD file."
}
size := strings.TrimSpace(req.Size)
if size == "" {
size = "auto"
}
generated, err := c.Generate(ctx, prompt, ImageRequestOptions{
Model: req.Model,
Size: size,
Count: 1,
Images: []string{imageURL},
OutputFormat: outputFormat,
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
})
if err != nil {
return design.LayeredDocument{}, err
}
content := strings.TrimSpace(generated.URL)
if content == "" && len(generated.URLs) > 0 {
content = strings.TrimSpace(generated.URLs[0])
}
if content == "" {
return design.LayeredDocument{}, fmt.Errorf("layered document generation returned empty file")
}
return design.LayeredDocument{
Content: content,
ContentType: outputFormatContentType(outputFormat),
}, nil
}
func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
@@ -948,8 +907,6 @@ func stripDataURLPrefix(value string) string {
func outputFormatContentType(format string) string {
switch normalizeImageOutputFormat(format) {
case "psd":
return "image/vnd.adobe.photoshop"
case "jpeg":
return "image/jpeg"
case "webp":
@@ -11,8 +11,6 @@ import (
"sync"
"testing"
"time"
"img_infinite_canvas/internal/domain/design"
)
func TestNewImageClientDefaultsToTenMinuteTimeout(t *testing.T) {
@@ -167,62 +165,36 @@ func TestImageClientSendsQuality(t *testing.T) {
}
}
func TestImageClientSendsOutputFormatAndParsesPSDBase64(t *testing.T) {
func TestNormalizeImageOutputFormatRejectsPSD(t *testing.T) {
if got := normalizeImageOutputFormat("psd"); got != "" {
t.Fatalf("expected unsupported PSD output format to be omitted, got %q", got)
}
}
func TestImageClientSendsOutputFormatAndParsesWebPBase64(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"b64_json":"cHNkLWJ5dGVz"}]}`))
_, _ = w.Write([]byte(`{"data":[{"b64_json":"d2VicC1ieXRlcw=="}]}`))
}))
defer server.Close()
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
image, err := client.Generate(context.Background(), "create layered file", ImageRequestOptions{Model: "gpt-image-2", OutputFormat: "psd"})
image, err := client.Generate(context.Background(), "create an image", ImageRequestOptions{Model: "gpt-image-2", OutputFormat: "webp"})
if err != nil {
t.Fatal(err)
}
if requestBody["output_format"] != "psd" {
t.Fatalf("expected output_format=psd, got %#v", requestBody["output_format"])
if requestBody["output_format"] != "webp" {
t.Fatalf("expected output_format=webp, got %#v", requestBody["output_format"])
}
if requestBody["stream"] != nil {
t.Fatalf("expected psd output to avoid streaming, got %#v", requestBody["stream"])
t.Fatalf("expected explicit output format to avoid streaming, got %#v", requestBody["stream"])
}
if image.URL != "data:image/vnd.adobe.photoshop;base64,cHNkLWJ5dGVz" {
t.Fatalf("expected psd data url, got %#v", image)
}
}
func TestImageClientGenerateLayeredDocumentUsesPSDOutput(t *testing.T) {
var requestBody map[string]any
var requestPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/layers.psd"}]}`))
}))
defer server.Close()
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key", InputImageTransport: "url"})
document, err := client.GenerateLayeredDocument(context.Background(), design.LayeredDocumentRequest{
ImageURL: "https://example.com/source.png",
Prompt: "separate layers",
})
if err != nil {
t.Fatal(err)
}
if requestPath != "/v1/images/edits" {
t.Fatalf("expected image edit endpoint, got %s", requestPath)
}
if requestBody["output_format"] != "psd" || requestBody["size"] != "auto" {
t.Fatalf("expected psd auto request, got %#v", requestBody)
}
if document.Content != "https://example.com/layers.psd" || document.ContentType != "image/vnd.adobe.photoshop" {
t.Fatalf("unexpected layered document: %#v", document)
if image.URL != "data:image/webp;base64,d2VicC1ieXRlcw==" {
t.Fatalf("expected WebP data URL, got %#v", image)
}
}