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);
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(".");
@@ -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),