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) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 01:54:32 +08:00
parent bd327e8564
commit 07aa33d30e
4 changed files with 141 additions and 3 deletions
@@ -3,6 +3,8 @@ export async function encodeUploadImage(file: File) {
const bitmap = await createBitmap(file); const bitmap = await createBitmap(file);
try { try {
if (imageMayContainAlpha(file) && bitmapHasAlpha(bitmap)) return file;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = bitmap.width; canvas.width = bitmap.width;
canvas.height = bitmap.height; canvas.height = bitmap.height;
@@ -29,9 +31,39 @@ function shouldEncodeAsWebp(file: File) {
if (type === "image/webp" || name.endsWith(".webp")) return false; if (type === "image/webp" || name.endsWith(".webp")) return false;
if (type === "image/gif" || name.endsWith(".gif")) return false; if (type === "image/gif" || name.endsWith(".gif")) return false;
if (type === "image/svg+xml" || name.endsWith(".svg")) 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; 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) { function webpFileName(fileName: string) {
const name = fileName.trim() || "upload"; const name = fileName.trim() || "upload";
const dotIndex = name.lastIndexOf("."); const dotIndex = name.lastIndexOf(".");
@@ -3095,6 +3095,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const previewUrl = URL.createObjectURL(file); const previewUrl = URL.createObjectURL(file);
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 })); const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
const size = fitCanvasImageSize(dimensions.width, dimensions.height); const size = fitCanvasImageSize(dimensions.width, dimensions.height);
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
preparedUploads.push({ preparedUploads.push({
file, file,
previewUrl, 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); 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") { function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
const candidates = [ const candidates = [
...Array.from(dataTransfer.files), ...Array.from(dataTransfer.files),
+22 -3
View File
@@ -309,6 +309,7 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
drawTextLayer(canvas, node, bounds, scale) drawTextLayer(canvas, node, bounds, scale)
} }
} }
hasTransparency := mergedLayerHasTransparency(canvas)
var output bytes.Buffer var output bytes.Buffer
if err := png.Encode(&output, canvas); err != nil { if err := png.Encode(&output, canvas); err != nil {
@@ -338,6 +339,10 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
if title == "" { if title == "" {
title = "Merged Layer" title = "Merged Layer"
} }
tone := "natural"
if hasTransparency {
tone = "transparent-image"
}
return design.Node{ return design.Node{
ID: newID(), ID: newID(),
Type: design.NodeTypeImage, Type: design.NodeTypeImage,
@@ -347,11 +352,25 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
Width: bounds.Width, Width: bounds.Width,
Height: bounds.Height, Height: bounds.Height,
Content: content, Content: content,
Tone: "natural", Tone: tone,
Status: "success", Status: "success",
}, nil }, 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 { func boundsForLayerMerge(nodes []design.Node) layerMergeBounds {
left := math.Inf(1) left := math.Inf(1)
top := 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) u = clampFloat(u, 0, 1)
v = clampFloat(v, 0, 1) v = clampFloat(v, 0, 1)
fx := u * float64(srcWidth-1) fx := clampFloat(u*float64(srcWidth)-0.5, 0, float64(srcWidth-1))
fy := v * float64(srcHeight-1) fy := clampFloat(v*float64(srcHeight)-0.5, 0, float64(srcHeight-1))
x0 := int(math.Floor(fx)) x0 := int(math.Floor(fx))
y0 := int(math.Floor(fy)) y0 := int(math.Floor(fy))
x1 := clampInt(x0+1, 0, srcWidth-1) x1 := clampInt(x0+1, 0, srcWidth-1)
@@ -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 { func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project {
t.Helper() t.Helper()
deadline := time.After(2 * time.Second) 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) img.SetNRGBA(x, y, fill)
} }
} }
return testPNGDataURLFromImage(img)
}
func testPNGDataURLFromImage(img image.Image) string {
var buf bytes.Buffer var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil { if err := png.Encode(&buf, img); err != nil {
panic(err) panic(err)