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":