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,
|
canvas: HTMLCanvasElement,
|
||||||
maxInputResolution: number,
|
maxInputResolution: number,
|
||||||
jpegQuality: 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 sourceWidth = canvas.width;
|
||||||
const sourceHeight = canvas.height;
|
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 scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1;
|
||||||
const exportWidth = Math.max(1, Math.round(sourceWidth * scale));
|
const exportWidth = autoCropEnabled
|
||||||
const exportHeight = Math.max(1, Math.round(sourceHeight * scale));
|
? 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");
|
const exportCanvas = document.createElement("canvas");
|
||||||
exportCanvas.width = exportWidth;
|
exportCanvas.width = exportWidth;
|
||||||
@ -127,7 +279,37 @@ async function canvasToJpegDataUri(
|
|||||||
|
|
||||||
exportCtx.fillStyle = backgroundColor;
|
exportCtx.fillStyle = backgroundColor;
|
||||||
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
|
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) => {
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
|
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
|
||||||
@ -276,7 +458,8 @@ export function RealtimeCanvasDialog({
|
|||||||
canvas,
|
canvas,
|
||||||
config.image.maxInputResolution,
|
config.image.maxInputResolution,
|
||||||
config.image.jpegQuality,
|
config.image.jpegQuality,
|
||||||
canvasBackgroundRef.current
|
canvasBackgroundRef.current,
|
||||||
|
{ autoCrop: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeConnection = connectionRef.current;
|
const activeConnection = connectionRef.current;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user