From 07aa33d30e8067a46936bfb355c8731ac6b68a85 Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 8 Jul 2026 01:54:32 +0800 Subject: [PATCH] feat(image): preserve transparency for alpha images end to end Skip WebP re-encoding for PNG/AVIF/BMP uploads and any bitmap that actually contains alpha, tag transparency-capable canvas uploads with the transparent-image tone, and detect alpha in merged layers so the composed node keeps a transparent tone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/infrastructure/imageUploadEncoding.ts | 32 +++++++++ .../src/ui/pages/CanvasWorkspace/index.tsx | 18 +++++ server/internal/application/layer_merge.go | 25 ++++++- .../internal/application/layer_merge_test.go | 69 +++++++++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/frontend/src/infrastructure/imageUploadEncoding.ts b/frontend/src/infrastructure/imageUploadEncoding.ts index c3bbf63..1effb2a 100644 --- a/frontend/src/infrastructure/imageUploadEncoding.ts +++ b/frontend/src/infrastructure/imageUploadEncoding.ts @@ -3,6 +3,8 @@ export async function encodeUploadImage(file: File) { const bitmap = await createBitmap(file); try { + if (imageMayContainAlpha(file) && bitmapHasAlpha(bitmap)) return file; + const canvas = document.createElement("canvas"); canvas.width = bitmap.width; canvas.height = bitmap.height; @@ -29,9 +31,39 @@ function shouldEncodeAsWebp(file: File) { if (type === "image/webp" || name.endsWith(".webp")) return false; if (type === "image/gif" || name.endsWith(".gif")) return false; if (type === "image/svg+xml" || name.endsWith(".svg")) return false; + if (type === "image/png" || name.endsWith(".png")) return false; + if (type === "image/avif" || name.endsWith(".avif")) return false; + if (type === "image/bmp" || name.endsWith(".bmp")) return false; return true; } +function imageMayContainAlpha(file: File) { + const type = file.type.toLowerCase(); + const name = file.name.toLowerCase(); + if (type.includes("png") || name.endsWith(".png")) return true; + if (type.includes("avif") || name.endsWith(".avif")) return true; + if (type.includes("bmp") || name.endsWith(".bmp")) return true; + return !type.includes("jpeg") && !type.includes("jpg") && !name.endsWith(".jpg") && !name.endsWith(".jpeg"); +} + +function bitmapHasAlpha(bitmap: ImageBitmap | HTMLImageElement) { + const width = "naturalWidth" in bitmap ? bitmap.naturalWidth || bitmap.width : bitmap.width; + const height = "naturalHeight" in bitmap ? bitmap.naturalHeight || bitmap.height : bitmap.height; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return true; + + context.clearRect(0, 0, width, height); + context.drawImage(bitmap, 0, 0); + const data = context.getImageData(0, 0, width, height).data; + for (let index = 3; index < data.length; index += 4) { + if (data[index] < 255) return true; + } + return false; +} + function webpFileName(fileName: string) { const name = fileName.trim() || "upload"; const dotIndex = name.lastIndexOf("."); diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index b21cd97..bad59f7 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -3095,6 +3095,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { const previewUrl = URL.createObjectURL(file); const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 })); const size = fitCanvasImageSize(dimensions.width, dimensions.height); + const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural"; preparedUploads.push({ file, previewUrl, @@ -5095,6 +5096,23 @@ function isImageFile(file: File) { return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name); } +function imageMaySupportTransparency(file: File) { + const type = file.type.toLowerCase(); + const name = file.name.toLowerCase(); + return ( + type.includes("png") || + type.includes("webp") || + type.includes("avif") || + type.includes("gif") || + type.includes("svg") || + name.endsWith(".png") || + name.endsWith(".webp") || + name.endsWith(".avif") || + name.endsWith(".gif") || + name.endsWith(".svg") + ); +} + function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") { const candidates = [ ...Array.from(dataTransfer.files), diff --git a/server/internal/application/layer_merge.go b/server/internal/application/layer_merge.go index f4c1f77..4ec6b0f 100644 --- a/server/internal/application/layer_merge.go +++ b/server/internal/application/layer_merge.go @@ -309,6 +309,7 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string drawTextLayer(canvas, node, bounds, scale) } } + hasTransparency := mergedLayerHasTransparency(canvas) var output bytes.Buffer if err := png.Encode(&output, canvas); err != nil { @@ -338,6 +339,10 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string if title == "" { title = "Merged Layer" } + tone := "natural" + if hasTransparency { + tone = "transparent-image" + } return design.Node{ ID: newID(), Type: design.NodeTypeImage, @@ -347,11 +352,25 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string Width: bounds.Width, Height: bounds.Height, Content: content, - Tone: "natural", + Tone: tone, Status: "success", }, nil } +func mergedLayerHasTransparency(img *image.NRGBA) bool { + bounds := img.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + offset := img.PixOffset(bounds.Min.X, y) + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if img.Pix[offset+3] < 255 { + return true + } + offset += 4 + } + } + return false +} + func boundsForLayerMerge(nodes []design.Node) layerMergeBounds { left := math.Inf(1) top := math.Inf(1) @@ -446,8 +465,8 @@ func sampleLayerColor(src image.Image, srcBounds image.Rectangle, srcWidth int, } u = clampFloat(u, 0, 1) v = clampFloat(v, 0, 1) - fx := u * float64(srcWidth-1) - fy := v * float64(srcHeight-1) + fx := clampFloat(u*float64(srcWidth)-0.5, 0, float64(srcWidth-1)) + fy := clampFloat(v*float64(srcHeight)-0.5, 0, float64(srcHeight-1)) x0 := int(math.Floor(fx)) y0 := int(math.Floor(fy)) x1 := clampInt(x0+1, 0, srcWidth-1) diff --git a/server/internal/application/layer_merge_test.go b/server/internal/application/layer_merge_test.go index 4f7b6c0..19e448d 100644 --- a/server/internal/application/layer_merge_test.go +++ b/server/internal/application/layer_merge_test.go @@ -129,6 +129,71 @@ func TestMergeLayersFlattensTextOverImage(t *testing.T) { } } +func TestMergeLayersPreservesTransparentPixels(t *testing.T) { + ctx := context.Background() + repo := memory.NewProjectRepository() + service := NewDesignService(repo, nil, nil, nil, nil) + transparentLayer := image.NewNRGBA(image.Rect(0, 0, 3, 2)) + transparentLayer.SetNRGBA(1, 0, color.NRGBA{R: 255, A: 255}) + project := design.Project{ + ID: "project-merge-transparent", + Title: "Merge transparent", + Status: design.StatusReady, + UpdatedAt: time.Now(), + Nodes: []design.Node{ + { + ID: "transparent", + Type: design.NodeTypeImage, + Title: "Transparent", + X: 0, + Y: 0, + Width: 3, + Height: 2, + Content: testPNGDataURLFromImage(transparentLayer), + Status: "success", + }, + { + ID: "blue", + Type: design.NodeTypeImage, + Title: "Blue", + X: 2, + Y: 1, + Width: 1, + Height: 1, + Content: testPNGDataURL(color.NRGBA{B: 255, A: 255}, 1, 1), + Status: "success", + }, + }, + } + if err := repo.Save(ctx, project); err != nil { + t.Fatal(err) + } + + if _, err := service.MergeLayers(ctx, project.ID, []string{"transparent", "blue"}, "Transparent merge"); err != nil { + t.Fatal(err) + } + + merged := waitForMergedProject(t, repo, project.ID) + node := merged.Nodes[0] + if node.Tone != "transparent-image" { + t.Fatalf("expected transparent merged image tone, got %q", node.Tone) + } + imageData, _, _, ok, err := decodeDataImage(node.Content) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatalf("expected merged content data URL, got %s", node.Content) + } + decoded, err := png.Decode(bytes.NewReader(imageData)) + if err != nil { + t.Fatal(err) + } + assertPixel(t, decoded, 0, 0, color.NRGBA{}) + assertPixel(t, decoded, 1, 0, color.NRGBA{R: 255, A: 255}) + assertPixel(t, decoded, 2, 1, color.NRGBA{B: 255, A: 255}) +} + func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project { t.Helper() deadline := time.After(2 * time.Second) @@ -160,6 +225,10 @@ func testPNGDataURL(fill color.NRGBA, width int, height int) string { img.SetNRGBA(x, y, fill) } } + return testPNGDataURLFromImage(img) +} + +func testPNGDataURLFromImage(img image.Image) string { var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { panic(err)