feat(realtime-canvas): auto-crop and zoom sketch before i2i
This commit is contained in:
parent
f25cfd3fab
commit
2c1b33bb6b
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user