feat: improve realtime canvas sketch fidelity for runware
This commit is contained in:
parent
2c1b33bb6b
commit
24275d9a3a
@ -7,6 +7,7 @@ type RealtimeCanvasConfig = {
|
||||
runwareModel?: string;
|
||||
falApiKey?: string;
|
||||
apiKey?: string;
|
||||
strictSketch?: boolean;
|
||||
throttleInterval?: number;
|
||||
maxBuffering?: number;
|
||||
maxInputResolution?: number;
|
||||
@ -37,13 +38,13 @@ const defaultConfig = {
|
||||
id: "runware:101@1",
|
||||
defaults: {
|
||||
steps: 8,
|
||||
cfgScale: 6,
|
||||
strength: 0.4,
|
||||
cfgScale: 7,
|
||||
strength: 0.32,
|
||||
imageSize: "square" as const,
|
||||
},
|
||||
limits: {
|
||||
steps: { min: 1, max: 8 },
|
||||
cfgScale: { min: 1, max: 12 },
|
||||
steps: { min: 1, max: 12 },
|
||||
cfgScale: { min: 1, max: 14 },
|
||||
strength: { min: 0.05, max: 1.0 },
|
||||
},
|
||||
},
|
||||
@ -160,5 +161,8 @@ export async function GET() {
|
||||
imageSize,
|
||||
},
|
||||
},
|
||||
behavior: {
|
||||
strictSketch: config.strictSketch !== false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ type RealtimeCanvasConfig = {
|
||||
runwareModel?: string;
|
||||
falApiKey?: string;
|
||||
apiKey?: string;
|
||||
negativePrompt?: string;
|
||||
strictSketch?: boolean;
|
||||
maxInputResolution?: number;
|
||||
defaults?: {
|
||||
steps?: number;
|
||||
@ -19,6 +21,8 @@ type GeneratePayload = {
|
||||
requestId?: string;
|
||||
imageUrl?: string;
|
||||
prompt?: string;
|
||||
seed?: number;
|
||||
strictSketch?: boolean;
|
||||
imageSize?: "square" | "square_hd";
|
||||
steps?: number;
|
||||
cfgScale?: number;
|
||||
@ -62,6 +66,14 @@ function parseRunwareError(payload: any) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveSeed(seed: unknown) {
|
||||
const parsed = Number(seed);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
const normalized = Math.floor(parsed);
|
||||
if (normalized < 0 || normalized > 2147483647) return undefined;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
@ -117,40 +129,50 @@ export async function POST(request: Request) {
|
||||
const steps = clamp(
|
||||
Number(body.steps || config.defaults?.steps || 8),
|
||||
1,
|
||||
8
|
||||
);
|
||||
const cfgScale = clamp(
|
||||
Number(body.cfgScale || config.defaults?.cfgScale || 6),
|
||||
1,
|
||||
12
|
||||
);
|
||||
const cfgScale = clamp(
|
||||
Number(body.cfgScale || config.defaults?.cfgScale || 7),
|
||||
1,
|
||||
14
|
||||
);
|
||||
const strength = clamp(
|
||||
Number(body.strength || config.defaults?.strength || 0.4),
|
||||
Number(body.strength || config.defaults?.strength || 0.32),
|
||||
0.05,
|
||||
1
|
||||
);
|
||||
const strictSketch = Boolean(body.strictSketch ?? config.strictSketch ?? true);
|
||||
const seed = resolveSeed(body.seed);
|
||||
|
||||
const positivePrompt =
|
||||
String(body.prompt || "").trim() ||
|
||||
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
|
||||
const negativePrompt =
|
||||
String(config.negativePrompt || "").trim() ||
|
||||
"改变构图, 改变主体位置, 多余人物, 多余肢体, 重复物体, 镜像翻转, 错误透视, 模糊轮廓, 文字水印, 大面积背景噪点";
|
||||
|
||||
const requestInference = async (modelId: string) => {
|
||||
const taskUUID = crypto.randomUUID();
|
||||
const runtimeStrength = strictSketch ? clamp(strength, 0.12, 0.55) : strength;
|
||||
const runtimeCfgScale = strictSketch ? clamp(cfgScale, 6, 14) : cfgScale;
|
||||
const runwareRequest = [
|
||||
{
|
||||
taskType: "imageInference",
|
||||
taskUUID,
|
||||
model: modelId,
|
||||
positivePrompt,
|
||||
negativePrompt,
|
||||
seedImage: imageUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
strength,
|
||||
CFGScale: cfgScale,
|
||||
strength: runtimeStrength,
|
||||
CFGScale: runtimeCfgScale,
|
||||
steps,
|
||||
includeCost: true,
|
||||
numberResults: 1,
|
||||
outputType: "dataURI",
|
||||
outputFormat: "JPG",
|
||||
...(typeof seed === "number" ? { seed } : {}),
|
||||
},
|
||||
];
|
||||
|
||||
@ -171,6 +193,8 @@ export async function POST(request: Request) {
|
||||
return {
|
||||
taskUUID,
|
||||
modelId,
|
||||
runtimeStrength,
|
||||
runtimeCfgScale,
|
||||
upstreamResponse,
|
||||
upstreamJson,
|
||||
upstreamError,
|
||||
@ -229,6 +253,10 @@ export async function POST(request: Request) {
|
||||
requestId: body.requestId || null,
|
||||
taskUUID: requestResult.taskUUID,
|
||||
model: requestResult.modelId,
|
||||
seed: typeof seed === "number" ? seed : null,
|
||||
steps,
|
||||
cfgScale: requestResult.runtimeCfgScale,
|
||||
strength: requestResult.runtimeStrength,
|
||||
cost,
|
||||
});
|
||||
|
||||
|
||||
@ -56,6 +56,9 @@ type RealtimeConfig = {
|
||||
imageSize: "square" | "square_hd";
|
||||
};
|
||||
};
|
||||
behavior?: {
|
||||
strictSketch?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
|
||||
@ -93,17 +96,24 @@ const defaultRealtimeConfig: RealtimeConfig = {
|
||||
id: "runware:101@1",
|
||||
defaults: {
|
||||
steps: 8,
|
||||
cfgScale: 6,
|
||||
strength: 0.4,
|
||||
cfgScale: 7,
|
||||
strength: 0.32,
|
||||
imageSize: "square",
|
||||
},
|
||||
},
|
||||
behavior: {
|
||||
strictSketch: true,
|
||||
},
|
||||
};
|
||||
|
||||
function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle" | "line") {
|
||||
return mode === "rect" || mode === "circle" || mode === "line";
|
||||
}
|
||||
|
||||
function createSessionSeed() {
|
||||
return Math.floor(Math.random() * 2147483647);
|
||||
}
|
||||
|
||||
async function canvasToJpegDataUri(
|
||||
canvas: HTMLCanvasElement,
|
||||
maxInputResolution: number,
|
||||
@ -113,6 +123,14 @@ async function canvasToJpegDataUri(
|
||||
autoCrop?: boolean;
|
||||
}
|
||||
) {
|
||||
type CropBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
foregroundRatio: number;
|
||||
};
|
||||
|
||||
const parseColor = (rawColor: string) => {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = 1;
|
||||
@ -154,26 +172,39 @@ async function canvasToJpegDataUri(
|
||||
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;
|
||||
const findForegroundBox = (): CropBox | null => {
|
||||
const sourceWidth = canvas.width;
|
||||
const sourceHeight = canvas.height;
|
||||
const longestSide = Math.max(sourceWidth, sourceHeight);
|
||||
const analysisScale = longestSide > 320 ? 320 / longestSide : 1;
|
||||
const analysisWidth = Math.max(48, Math.round(sourceWidth * analysisScale));
|
||||
const analysisHeight = Math.max(48, Math.round(sourceHeight * analysisScale));
|
||||
const analysisCanvas = document.createElement("canvas");
|
||||
analysisCanvas.width = analysisWidth;
|
||||
analysisCanvas.height = analysisHeight;
|
||||
const analysisCtx = analysisCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!analysisCtx) return null;
|
||||
analysisCtx.fillStyle = backgroundColor;
|
||||
analysisCtx.fillRect(0, 0, analysisWidth, analysisHeight);
|
||||
analysisCtx.drawImage(canvas, 0, 0, analysisWidth, analysisHeight);
|
||||
|
||||
let minX = width;
|
||||
let minY = height;
|
||||
const imageData = analysisCtx.getImageData(0, 0, analysisWidth, analysisHeight);
|
||||
const data = imageData.data;
|
||||
const totalPixels = analysisWidth * analysisHeight;
|
||||
const mask = new Uint8Array(totalPixels);
|
||||
const bg = parseColor(backgroundColor);
|
||||
const tolerance = 18;
|
||||
|
||||
let minX = analysisWidth;
|
||||
let minY = analysisHeight;
|
||||
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;
|
||||
for (let y = 0; y < analysisHeight; y += 1) {
|
||||
for (let x = 0; x < analysisWidth; x += 1) {
|
||||
const pixelIndex = y * analysisWidth + x;
|
||||
const offset = pixelIndex * 4;
|
||||
const alpha = data[offset + 3];
|
||||
if (alpha < 16) continue;
|
||||
const dr = Math.abs(data[offset] - bg.r);
|
||||
@ -182,9 +213,8 @@ async function canvasToJpegDataUri(
|
||||
const isForeground = dr > tolerance || dg > tolerance || db > tolerance;
|
||||
if (!isForeground) continue;
|
||||
|
||||
mask[pixelIndex] = 1;
|
||||
foregroundPixels += 1;
|
||||
rowCounts[y] += 1;
|
||||
colCounts[x] += 1;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
@ -196,7 +226,85 @@ async function canvasToJpegDataUri(
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimPixels = Math.max(0, Math.floor(foregroundPixels * 0.01));
|
||||
const visited = new Uint8Array(totalPixels);
|
||||
const queue = new Uint32Array(totalPixels);
|
||||
const minComponentArea = Math.max(9, Math.floor(foregroundPixels * 0.0015));
|
||||
let bestArea = 0;
|
||||
let bestMinX = minX;
|
||||
let bestMinY = minY;
|
||||
let bestMaxX = maxX;
|
||||
let bestMaxY = maxY;
|
||||
|
||||
for (let idx = 0; idx < totalPixels; idx += 1) {
|
||||
if (!mask[idx] || visited[idx]) continue;
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
queue[tail] = idx;
|
||||
tail += 1;
|
||||
visited[idx] = 1;
|
||||
let area = 0;
|
||||
let componentMinX = analysisWidth;
|
||||
let componentMinY = analysisHeight;
|
||||
let componentMaxX = -1;
|
||||
let componentMaxY = -1;
|
||||
|
||||
while (head < tail) {
|
||||
const current = queue[head];
|
||||
head += 1;
|
||||
const x = current % analysisWidth;
|
||||
const y = Math.floor(current / analysisWidth);
|
||||
area += 1;
|
||||
if (x < componentMinX) componentMinX = x;
|
||||
if (x > componentMaxX) componentMaxX = x;
|
||||
if (y < componentMinY) componentMinY = y;
|
||||
if (y > componentMaxY) componentMaxY = y;
|
||||
|
||||
const startY = Math.max(0, y - 1);
|
||||
const endY = Math.min(analysisHeight - 1, y + 1);
|
||||
const startX = Math.max(0, x - 1);
|
||||
const endX = Math.min(analysisWidth - 1, x + 1);
|
||||
|
||||
for (let ny = startY; ny <= endY; ny += 1) {
|
||||
for (let nx = startX; nx <= endX; nx += 1) {
|
||||
const neighbor = ny * analysisWidth + nx;
|
||||
if (!mask[neighbor] || visited[neighbor]) continue;
|
||||
visited[neighbor] = 1;
|
||||
queue[tail] = neighbor;
|
||||
tail += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (area >= minComponentArea && area > bestArea) {
|
||||
bestArea = area;
|
||||
bestMinX = componentMinX;
|
||||
bestMinY = componentMinY;
|
||||
bestMaxX = componentMaxX;
|
||||
bestMaxY = componentMaxY;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestArea > 0) {
|
||||
minX = bestMinX;
|
||||
minY = bestMinY;
|
||||
maxX = bestMaxX;
|
||||
maxY = bestMaxY;
|
||||
} else {
|
||||
bestArea = foregroundPixels;
|
||||
}
|
||||
|
||||
const rowCounts = new Uint32Array(analysisHeight);
|
||||
const colCounts = new Uint32Array(analysisWidth);
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
const idx = y * analysisWidth + x;
|
||||
if (!mask[idx]) continue;
|
||||
rowCounts[y] += 1;
|
||||
colCounts[x] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const trimPixels = Math.max(0, Math.floor(bestArea * 0.008));
|
||||
if (trimPixels > 0) {
|
||||
let cumulative = 0;
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
@ -235,19 +343,29 @@ async function canvasToJpegDataUri(
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const scaleX = sourceWidth / analysisWidth;
|
||||
const scaleY = sourceHeight / analysisHeight;
|
||||
const boxX = Math.max(0, Math.floor(minX * scaleX));
|
||||
const boxY = Math.max(0, Math.floor(minY * scaleY));
|
||||
const boxRight = Math.min(sourceWidth, Math.ceil((maxX + 1) * scaleX));
|
||||
const boxBottom = Math.min(sourceHeight, Math.ceil((maxY + 1) * scaleY));
|
||||
|
||||
const boxWidth = Math.max(1, boxRight - boxX);
|
||||
const boxHeight = Math.max(1, boxBottom - boxY);
|
||||
const foregroundRatio = Math.min(1, bestArea / totalPixels);
|
||||
const paddingRatio = foregroundRatio < 0.08 ? 0.34 : foregroundRatio < 0.2 ? 0.28 : 0.22;
|
||||
const padding = Math.max(16, Math.floor(Math.max(boxWidth, boxHeight) * paddingRatio));
|
||||
const x = Math.max(0, boxX - padding);
|
||||
const y = Math.max(0, boxY - padding);
|
||||
const right = Math.min(sourceWidth, boxRight + padding);
|
||||
const bottom = Math.min(sourceHeight, boxBottom + padding);
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, right - x),
|
||||
height: Math.max(1, bottom - y),
|
||||
foregroundRatio,
|
||||
};
|
||||
};
|
||||
|
||||
@ -281,11 +399,14 @@ async function canvasToJpegDataUri(
|
||||
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
|
||||
|
||||
if (autoCropEnabled) {
|
||||
const fitScale = Math.min((exportWidth * 0.88) / drawSourceWidth, (exportHeight * 0.88) / drawSourceHeight);
|
||||
const fitRatio = (cropBox?.foregroundRatio || 0) < 0.12 ? 0.9 : 0.94;
|
||||
const fitScale = Math.min((exportWidth * fitRatio) / drawSourceWidth, (exportHeight * fitRatio) / 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.imageSmoothingEnabled = true;
|
||||
exportCtx.imageSmoothingQuality = "high";
|
||||
exportCtx.drawImage(
|
||||
canvas,
|
||||
drawSourceX,
|
||||
@ -297,6 +418,22 @@ async function canvasToJpegDataUri(
|
||||
targetWidth,
|
||||
targetHeight
|
||||
);
|
||||
if ((cropBox?.foregroundRatio || 0) < 0.28) {
|
||||
exportCtx.save();
|
||||
exportCtx.globalAlpha = 0.22;
|
||||
exportCtx.drawImage(
|
||||
canvas,
|
||||
drawSourceX,
|
||||
drawSourceY,
|
||||
drawSourceWidth,
|
||||
drawSourceHeight,
|
||||
targetX,
|
||||
targetY,
|
||||
targetWidth,
|
||||
targetHeight
|
||||
);
|
||||
exportCtx.restore();
|
||||
}
|
||||
} else {
|
||||
exportCtx.drawImage(
|
||||
canvas,
|
||||
@ -348,6 +485,7 @@ export function RealtimeCanvasDialog({
|
||||
const lastRenderedSeqRef = useRef(0);
|
||||
const metricsQueueRef = useRef<any[]>([]);
|
||||
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
const seedRef = useRef<number>(createSessionSeed());
|
||||
const hasLoadedInitialImageRef = useRef(false);
|
||||
const canvasBackgroundRef = useRef(CANVAS_BACKGROUND);
|
||||
|
||||
@ -479,6 +617,8 @@ export function RealtimeCanvasDialog({
|
||||
requestId,
|
||||
imageUrl: imageDataUri,
|
||||
prompt: prompt.trim() || FALLBACK_PROMPT,
|
||||
seed: seedRef.current,
|
||||
strictSketch: config.behavior?.strictSketch !== false,
|
||||
imageSize: config.model.defaults.imageSize,
|
||||
steps: config.model.defaults.steps,
|
||||
cfgScale: config.model.defaults.cfgScale,
|
||||
@ -897,6 +1037,17 @@ export function RealtimeCanvasDialog({
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, undo, redo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
seedRef.current = createSessionSeed();
|
||||
sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
seqRef.current = 0;
|
||||
lastRenderedSeqRef.current = 0;
|
||||
requestSeqMapRef.current.clear();
|
||||
requestStartMapRef.current.clear();
|
||||
metricsQueueRef.current = [];
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
initializeCanvas();
|
||||
@ -975,6 +1126,9 @@ export function RealtimeCanvasDialog({
|
||||
defaultRealtimeConfig.model.defaults.imageSize,
|
||||
},
|
||||
},
|
||||
behavior: {
|
||||
strictSketch: configJson.behavior?.strictSketch !== false,
|
||||
},
|
||||
};
|
||||
|
||||
setConfig(resolvedConfig);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user