feat(realtime-canvas): auto-crop and zoom sketch before i2i
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-25 16:06:11 +08:00
parent f25cfd3fab
commit 2c1b33bb6b

View File

@ -108,14 +108,166 @@ async function canvasToJpegDataUri(
canvas: HTMLCanvasElement,
maxInputResolution: number,
jpegQuality: number,
backgroundColor: string
backgroundColor: string,
options?: {
autoCrop?: boolean;
}
) {
const parseColor = (rawColor: string) => {
const tempCanvas = document.createElement("canvas");
tempCanvas.width = 1;
tempCanvas.height = 1;
const tempCtx = tempCanvas.getContext("2d");
if (!tempCtx) {
return { r: 255, g: 255, b: 255 };
}
tempCtx.fillStyle = rawColor;
const normalized = String(tempCtx.fillStyle || "").trim().toLowerCase();
if (normalized.startsWith("#")) {
const hex = normalized.slice(1);
if (hex.length === 3) {
return {
r: Number.parseInt(hex[0] + hex[0], 16),
g: Number.parseInt(hex[1] + hex[1], 16),
b: Number.parseInt(hex[2] + hex[2], 16),
};
}
if (hex.length >= 6) {
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
}
const rgbMatch = normalized.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
return {
r: Number(rgbMatch[1]),
g: Number(rgbMatch[2]),
b: Number(rgbMatch[3]),
};
}
return { r: 255, g: 255, b: 255 };
};
const findForegroundBox = () => {
const sourceCtx = canvas.getContext("2d", { willReadFrequently: true });
if (!sourceCtx) return null;
const { width, height } = canvas;
const imageData = sourceCtx.getImageData(0, 0, width, height);
const data = imageData.data;
const rowCounts = new Uint32Array(height);
const colCounts = new Uint32Array(width);
const bg = parseColor(backgroundColor);
const tolerance = 16;
let minX = width;
let minY = height;
let maxX = -1;
let maxY = -1;
let foregroundPixels = 0;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = (y * width + x) * 4;
const alpha = data[offset + 3];
if (alpha < 16) continue;
const dr = Math.abs(data[offset] - bg.r);
const dg = Math.abs(data[offset + 1] - bg.g);
const db = Math.abs(data[offset + 2] - bg.b);
const isForeground = dr > tolerance || dg > tolerance || db > tolerance;
if (!isForeground) continue;
foregroundPixels += 1;
rowCounts[y] += 1;
colCounts[x] += 1;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (foregroundPixels <= 0 || maxX < minX || maxY < minY) {
return null;
}
const trimPixels = Math.max(0, Math.floor(foregroundPixels * 0.01));
if (trimPixels > 0) {
let cumulative = 0;
for (let x = minX; x <= maxX; x += 1) {
cumulative += colCounts[x];
if (cumulative > trimPixels) {
minX = x;
break;
}
}
cumulative = 0;
for (let x = maxX; x >= minX; x -= 1) {
cumulative += colCounts[x];
if (cumulative > trimPixels) {
maxX = x;
break;
}
}
cumulative = 0;
for (let y = minY; y <= maxY; y += 1) {
cumulative += rowCounts[y];
if (cumulative > trimPixels) {
minY = y;
break;
}
}
cumulative = 0;
for (let y = maxY; y >= minY; y -= 1) {
cumulative += rowCounts[y];
if (cumulative > trimPixels) {
maxY = y;
break;
}
}
}
const boxWidth = Math.max(1, maxX - minX + 1);
const boxHeight = Math.max(1, maxY - minY + 1);
const padding = Math.max(20, Math.floor(Math.max(boxWidth, boxHeight) * 0.24));
const x = Math.max(0, minX - padding);
const y = Math.max(0, minY - padding);
const right = Math.min(width, maxX + 1 + padding);
const bottom = Math.min(height, maxY + 1 + padding);
return {
x,
y,
width: Math.max(1, right - x),
height: Math.max(1, bottom - y),
};
};
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
const longestSide = Math.max(sourceWidth, sourceHeight);
const cropBox = options?.autoCrop ? findForegroundBox() : null;
const drawSourceX = cropBox?.x || 0;
const drawSourceY = cropBox?.y || 0;
const drawSourceWidth = cropBox?.width || sourceWidth;
const drawSourceHeight = cropBox?.height || sourceHeight;
const autoCropEnabled = Boolean(options?.autoCrop && cropBox);
const longestSide = Math.max(drawSourceWidth, drawSourceHeight);
const scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1;
const exportWidth = Math.max(1, Math.round(sourceWidth * scale));
const exportHeight = Math.max(1, Math.round(sourceHeight * scale));
const exportWidth = autoCropEnabled
? Math.max(512, maxInputResolution)
: Math.max(1, Math.round(drawSourceWidth * scale));
const exportHeight = autoCropEnabled
? Math.max(512, maxInputResolution)
: Math.max(1, Math.round(drawSourceHeight * scale));
const exportCanvas = document.createElement("canvas");
exportCanvas.width = exportWidth;
@ -127,7 +279,37 @@ async function canvasToJpegDataUri(
exportCtx.fillStyle = backgroundColor;
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight);
if (autoCropEnabled) {
const fitScale = Math.min((exportWidth * 0.88) / drawSourceWidth, (exportHeight * 0.88) / drawSourceHeight);
const targetWidth = Math.max(1, Math.round(drawSourceWidth * fitScale));
const targetHeight = Math.max(1, Math.round(drawSourceHeight * fitScale));
const targetX = Math.round((exportWidth - targetWidth) / 2);
const targetY = Math.round((exportHeight - targetHeight) / 2);
exportCtx.drawImage(
canvas,
drawSourceX,
drawSourceY,
drawSourceWidth,
drawSourceHeight,
targetX,
targetY,
targetWidth,
targetHeight
);
} else {
exportCtx.drawImage(
canvas,
drawSourceX,
drawSourceY,
drawSourceWidth,
drawSourceHeight,
0,
0,
exportWidth,
exportHeight
);
}
const blob = await new Promise<Blob | null>((resolve) => {
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
@ -276,7 +458,8 @@ export function RealtimeCanvasDialog({
canvas,
config.image.maxInputResolution,
config.image.jpegQuality,
canvasBackgroundRef.current
canvasBackgroundRef.current,
{ autoCrop: true }
);
const activeConnection = connectionRef.current;