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
@@ -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)
}
}