From 2c1b33bb6bb0b6f65c55795ba4fb67751c07b2cb Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 25 Feb 2026 16:06:11 +0800 Subject: [PATCH] feat(realtime-canvas): auto-crop and zoom sketch before i2i --- components/canvas/RealtimeCanvasDialog.tsx | 195 ++++++++++++++++++++- 1 file changed, 189 insertions(+), 6 deletions(-) diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 3a064a408..4c49a6cbd 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -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((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;