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