fix: improve scale adjust guidance and scan feedback
This commit is contained in:
parent
af63ebba9e
commit
74ae88edbb
@ -79,6 +79,7 @@ const MAX_LOCAL_EDIT_REGIONS = 8;
|
||||
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
|
||||
const MIN_REGION_EDGE_PX = 24;
|
||||
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
||||
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
|
||||
const SCALE_ADJUST_PERCENT_OPTIONS = [-30, -20, -10, 10, 20, 30] as const;
|
||||
type ScaleAdjustPercent = (typeof SCALE_ADJUST_PERCENT_OPTIONS)[number];
|
||||
const DEFAULT_REGION_COLORS = [
|
||||
@ -224,6 +225,20 @@ function getScaleAdjustLabel(scalePercent: ScaleAdjustPercent) {
|
||||
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
|
||||
}
|
||||
|
||||
function getScaleAdjustTargetLabel(scalePercent: ScaleAdjustPercent) {
|
||||
return `${Math.round((1 + scalePercent / 100) * 100)}%`;
|
||||
}
|
||||
|
||||
function buildLocalEditDisplayPrompt(params: {
|
||||
operation: LocalEditOperation;
|
||||
scalePercent: ScaleAdjustPercent | null;
|
||||
}) {
|
||||
if (params.operation === SCALE_ADJUST_OPERATION && params.scalePercent !== null) {
|
||||
return `比例调整:${getScaleAdjustLabel(params.scalePercent)}`;
|
||||
}
|
||||
return "局部编辑处理";
|
||||
}
|
||||
|
||||
function getColorName(hex: string) {
|
||||
const normalized = hex.toLowerCase();
|
||||
const names: Record<string, string> = {
|
||||
@ -340,6 +355,7 @@ function buildAnnotationSvg(params: {
|
||||
width: number;
|
||||
height: number;
|
||||
regions: NormalizedLocalEditRegion[];
|
||||
hidePromptText?: boolean;
|
||||
}) {
|
||||
const { width, height, regions } = params;
|
||||
const minSide = Math.max(1, Math.min(width, height));
|
||||
@ -354,7 +370,9 @@ function buildAnnotationSvg(params: {
|
||||
const regionSvg = regions
|
||||
.map((region) => {
|
||||
const { left, top, width: rectWidth, height: rectHeight } = region.pixelRect;
|
||||
const labelText = `区域${region.index} ${region.prompt}`;
|
||||
const labelText = params.hidePromptText
|
||||
? `区域${region.index} 目标主体`
|
||||
: `区域${region.index} ${region.prompt}`;
|
||||
const maxChars = Math.max(8, Math.min(24, Math.floor((width * 0.42) / Math.max(1, fontSize * 0.58))));
|
||||
const lines = wrapText(labelText, maxChars, 3);
|
||||
const labelWidth = Math.min(
|
||||
@ -443,29 +461,35 @@ function buildScaleAdjustPrompt(
|
||||
regions: NormalizedLocalEditRegion[],
|
||||
scalePercent: ScaleAdjustPercent
|
||||
) {
|
||||
const scaleFactor = Math.round((1 + scalePercent / 100) * 100) / 100;
|
||||
const actionLabel = getScaleAdjustLabel(scalePercent);
|
||||
const targetLabel = getScaleAdjustTargetLabel(scalePercent);
|
||||
const regionLines = regions
|
||||
.map((region) => {
|
||||
const left = Math.round(region.rect.x * 1000) / 10;
|
||||
const top = Math.round(region.rect.y * 1000) / 10;
|
||||
const width = Math.round(region.rect.width * 1000) / 10;
|
||||
const height = Math.round(region.rect.height * 1000) / 10;
|
||||
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸为原主体的 ${Math.round(scaleFactor * 100)}%。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||||
const pixel = region.pixelRect;
|
||||
const right = pixel.left + pixel.width;
|
||||
const bottom = pixel.top + pixel.height;
|
||||
const centerX = pixel.left + Math.round(pixel.width / 2);
|
||||
const centerY = pixel.top + Math.round(pixel.height / 2);
|
||||
return `区域 ${region.index}(相对位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%;像素范围 left=${pixel.left}, top=${pixel.top}, right=${right}, bottom=${bottom}, center=(${centerX}, ${centerY})):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸必须约为原主体的 ${targetLabel}。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
||||
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
|
||||
"输入图片2是定位参考图:它只用于告诉你用户框选的目标主体位置。输入图片2里的彩色框、编号、标签、半透明遮罩、文字和 UI 只是定位标记,绝不能出现在最终画面中。",
|
||||
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
|
||||
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
|
||||
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
||||
"",
|
||||
`任务:对用户框选的主体物体做“比例调整”,统一执行${actionLabel}。`,
|
||||
`任务:对用户框选的主体物体做“比例调整”,统一执行${actionLabel}。目标主体调整后的视觉尺寸必须约为原主体的 ${targetLabel},普通用户肉眼应能清楚看出主体尺寸已经变化;如果输出看起来和原图一样,就是失败。`,
|
||||
regionLines,
|
||||
"",
|
||||
"关键要求:",
|
||||
"1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
|
||||
`1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。`,
|
||||
"2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
|
||||
"3. 缩小时,目标主体应围绕原位置视觉中心自然变小;主体缩小后露出的区域必须根据周围真实背景、阴影、遮挡、纹理和透视进行无缝补全。",
|
||||
"4. 放大时,目标主体应围绕原位置视觉中心自然变大;边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。",
|
||||
@ -556,8 +580,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const isScaleAdjustOperation =
|
||||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
|
||||
const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
|
||||
|
||||
const annotationSvg = buildAnnotationSvg({
|
||||
height: sourceHeight,
|
||||
hidePromptText: isScaleAdjustOperation,
|
||||
regions,
|
||||
width: sourceWidth,
|
||||
});
|
||||
@ -571,14 +600,15 @@ export async function POST(request: NextRequest) {
|
||||
toArrayBuffer(annotatedBuffer)
|
||||
);
|
||||
const prompt =
|
||||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
|
||||
? buildScaleAdjustPrompt(regions, scalePercent)
|
||||
isScaleAdjustOperation
|
||||
? buildScaleAdjustPrompt(regions, scalePercent as ScaleAdjustPercent)
|
||||
: buildLocalEditPrompt(regions);
|
||||
const isScaleAdjustOperation =
|
||||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
|
||||
const regionReferenceImageUrls = regions.flatMap((region) =>
|
||||
region.referenceImages.map((reference) => reference.url)
|
||||
);
|
||||
const referenceImages = isScaleAdjustOperation
|
||||
? [sourceImageUrl, annotatedUpload.url, ...regionReferenceImageUrls]
|
||||
: [sourceImageUrl, ...regionReferenceImageUrls];
|
||||
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
||||
body.aspectRatio,
|
||||
sourceWidth,
|
||||
@ -592,7 +622,8 @@ export async function POST(request: NextRequest) {
|
||||
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
|
||||
debugMeta: {
|
||||
annotatedReferenceUrl: annotatedUpload.url,
|
||||
annotatedReferenceSentToModel: false,
|
||||
annotatedReferenceSentToModel: isScaleAdjustOperation,
|
||||
displayPrompt,
|
||||
operation,
|
||||
resolvedAspectRatio,
|
||||
regionReferenceImageCount: regionReferenceImageUrls.length,
|
||||
@ -603,6 +634,7 @@ export async function POST(request: NextRequest) {
|
||||
sourceNodeId: body.sourceNodeId || null,
|
||||
sourceWidth,
|
||||
},
|
||||
displayPrompt,
|
||||
imageCount: 1,
|
||||
imageSize:
|
||||
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||
@ -618,10 +650,7 @@ export async function POST(request: NextRequest) {
|
||||
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImages: [
|
||||
sourceImageUrl,
|
||||
...regionReferenceImageUrls,
|
||||
],
|
||||
referenceImages,
|
||||
localEditComposite: isScaleAdjustOperation
|
||||
? undefined
|
||||
: {
|
||||
@ -658,7 +687,8 @@ export async function POST(request: NextRequest) {
|
||||
success: true,
|
||||
annotatedReferenceUrl: annotatedUpload.url,
|
||||
aspectRatio: resolvedAspectRatio,
|
||||
prompt,
|
||||
displayPrompt,
|
||||
prompt: displayPrompt,
|
||||
regions,
|
||||
sourceHeight,
|
||||
sourceWidth,
|
||||
|
||||
@ -1931,38 +1931,63 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
||||
|
||||
function DetailResultProcessingScanOverlay({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-zinc-950/34">
|
||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#030605]/48">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-50"
|
||||
className="absolute inset-0 opacity-[0.36]"
|
||||
animate={{
|
||||
backgroundPosition: ["0px 0px", "0px 20px"],
|
||||
backgroundPosition: ["0px 0px, 0px 0px", "0px 24px, 24px 0px"],
|
||||
}}
|
||||
transition={{ duration: 1.45, repeat: Infinity, ease: "linear" }}
|
||||
transition={{ duration: 2.6, repeat: Infinity, ease: "linear" }}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255,255,255,0.11) 0px, rgba(255,255,255,0.11) 1px, transparent 1px, transparent 20px)",
|
||||
backgroundSize: "100% 20px",
|
||||
"linear-gradient(rgba(255,255,255,0.13) 0px, rgba(255,255,255,0.13) 1px, transparent 1px, transparent 24px), linear-gradient(90deg, rgba(16,185,129,0.1) 0px, rgba(16,185,129,0.1) 1px, transparent 1px, transparent 24px)",
|
||||
backgroundSize: "100% 24px, 24px 100%",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-80"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 50% 50%, rgba(16,185,129,0.12), transparent 44%), linear-gradient(180deg, rgba(255,255,255,0.08), transparent 18%, transparent 82%, rgba(255,255,255,0.08))",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="absolute inset-x-[-8%] h-[26%] min-h-[110px]"
|
||||
animate={{ top: ["-30%", "104%"], opacity: [0.38, 0.95, 0.48] }}
|
||||
transition={{ duration: 3.2, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute inset-x-[-6%] h-[34%] min-h-[150px]"
|
||||
animate={{ top: ["-36%", "108%"], opacity: [0.3, 1, 0.46] }}
|
||||
transition={{ duration: 3.8, repeat: Infinity, ease: [0.33, 0, 0.67, 1] }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent_0%,rgba(34,211,238,0.06)_18%,rgba(52,211,153,0.22)_34%,rgba(255,255,255,0.92)_50%,rgba(52,211,153,0.26)_58%,rgba(34,211,238,0.08)_74%,transparent_100%)]" />
|
||||
<div className="absolute inset-x-[3%] top-1/2 h-px -translate-y-1/2 bg-white/95 shadow-[0_0_18px_rgba(255,255,255,0.92),0_0_34px_rgba(52,211,153,0.55)]" />
|
||||
<div className="absolute inset-x-[6%] top-[calc(50%-10px)] h-px bg-emerald-200/48" />
|
||||
<div className="absolute inset-x-[6%] top-[calc(50%+10px)] h-px bg-cyan-200/38" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, transparent 0%, rgba(16,185,129,0.04) 18%, rgba(34,211,238,0.1) 38%, rgba(255,255,255,0.72) 50%, rgba(34,211,238,0.1) 62%, rgba(16,185,129,0.04) 82%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-x-[3%] top-1/2 h-[2px] -translate-y-1/2 bg-white shadow-[0_0_10px_rgba(255,255,255,0.86),0_0_18px_rgba(45,212,191,0.58)]" />
|
||||
<div className="absolute inset-x-[4%] top-[calc(50%-18px)] h-px bg-emerald-100/55" />
|
||||
<div className="absolute inset-x-[4%] top-[calc(50%+18px)] h-px bg-cyan-100/42" />
|
||||
<div
|
||||
className="absolute inset-x-[5%] top-[calc(50%-44px)] h-[88px] opacity-55"
|
||||
style={{
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, rgba(255,255,255,0.38) 0 1px, transparent 1px 12px)",
|
||||
maskImage:
|
||||
"linear-gradient(90deg, transparent 0%, black 16%, black 84%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(90deg, transparent 0%, black 16%, black 84%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.12),transparent_62%)]"
|
||||
animate={{ opacity: [0.16, 0.28, 0.16] }}
|
||||
transition={{ duration: 2.6, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute inset-y-0 w-px bg-emerald-100/36"
|
||||
animate={{ left: ["8%", "92%", "8%"], opacity: [0.08, 0.35, 0.08] }}
|
||||
transition={{ duration: 5.4, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<div className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-emerald-100/65 bg-zinc-950/86 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_36px_rgba(0,0,0,0.48),0_0_22px_rgba(16,185,129,0.24)] backdrop-blur-md">
|
||||
<div className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-emerald-100/70 bg-zinc-950/88 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_36px_rgba(0,0,0,0.5),0_0_18px_rgba(16,185,129,0.2)] backdrop-blur-md">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-200" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
@ -4350,6 +4350,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
return;
|
||||
}
|
||||
|
||||
const localEditDisplayPrompt =
|
||||
response.data.displayPrompt || response.data.prompt || "局部编辑处理";
|
||||
const sourceWidth = Number(response.data.sourceWidth || 0);
|
||||
const sourceHeight = Number(response.data.sourceHeight || 0);
|
||||
const sourceRatio =
|
||||
@ -4364,7 +4366,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
height: resultHeight,
|
||||
mediaType: "image",
|
||||
mediaUrl: "",
|
||||
prompt: response.data.prompt,
|
||||
prompt: localEditDisplayPrompt,
|
||||
title: "局部编辑",
|
||||
width: resultWidth,
|
||||
x: Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP),
|
||||
@ -4391,7 +4393,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
|
||||
aspectRatio: response.data.aspectRatio,
|
||||
mode: "local-edit",
|
||||
prompt: response.data.prompt,
|
||||
prompt: localEditDisplayPrompt,
|
||||
regions: validRegions,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceHeight: response.data.sourceHeight,
|
||||
@ -4400,7 +4402,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
taskId: response.data.taskId,
|
||||
},
|
||||
pendingLabel: "局部编辑中",
|
||||
prompt: response.data.prompt,
|
||||
prompt: localEditDisplayPrompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceLocalEditRegions: validRegions,
|
||||
sourceNaturalHeight: response.data.sourceHeight,
|
||||
|
||||
@ -3905,6 +3905,8 @@ export function ImageNode({
|
||||
}
|
||||
|
||||
const taskId = submitResult.data.taskId;
|
||||
const localEditDisplayPrompt =
|
||||
submitResult.data.displayPrompt || submitResult.data.prompt || `${actionName}处理中...`;
|
||||
const localEditSourceWidth = Number(submitResult.data.sourceWidth || 0);
|
||||
const localEditSourceHeight = Number(submitResult.data.sourceHeight || 0);
|
||||
const localEditSourceRatio =
|
||||
@ -3923,7 +3925,7 @@ export function ImageNode({
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: submitResult.data.prompt || `${actionName}处理中...`,
|
||||
prompt: localEditDisplayPrompt,
|
||||
title: `${displayTitle}-${actionName}`,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
@ -3961,7 +3963,11 @@ export function ImageNode({
|
||||
data: {
|
||||
src: generatedUrl,
|
||||
status: "completed",
|
||||
prompt: task.result?.prompt || submitResult.data.prompt || `${actionName}结果`,
|
||||
prompt:
|
||||
task.result?.prompt ||
|
||||
submitResult.data.displayPrompt ||
|
||||
submitResult.data.prompt ||
|
||||
`${actionName}结果`,
|
||||
title: `${displayTitle}-${actionName}`,
|
||||
originalSrc: data.src,
|
||||
model: task.result?.model || task.result?.modelRef || undefined,
|
||||
|
||||
@ -44,6 +44,7 @@ export interface CanvasLocalEditCompositeConfig {
|
||||
|
||||
export interface CanvasGenerateImageRequest {
|
||||
prompt: string;
|
||||
displayPrompt?: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
@ -675,6 +676,11 @@ export async function generateCanvasImage(
|
||||
hasLocalEditComposite: Boolean(localEditComposite),
|
||||
});
|
||||
|
||||
const resultPrompt =
|
||||
typeof request.displayPrompt === "string" && request.displayPrompt.trim()
|
||||
? request.displayPrompt.trim()
|
||||
: prompt;
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
@ -1000,7 +1006,7 @@ export async function generateCanvasImage(
|
||||
images: finalImages,
|
||||
model: selectedModel.model.displayName,
|
||||
modelRef: selectedModel.model.id,
|
||||
prompt,
|
||||
prompt: resultPrompt,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Canvas Generate Image] Failed:", {
|
||||
|
||||
@ -345,6 +345,7 @@ export const imageGenerationService = {
|
||||
ApiResponse<{
|
||||
annotatedReferenceUrl: string;
|
||||
aspectRatio?: string;
|
||||
displayPrompt?: string;
|
||||
prompt: string;
|
||||
queueStatus?: unknown;
|
||||
sourceHeight?: number;
|
||||
@ -366,6 +367,7 @@ export const imageGenerationService = {
|
||||
data: {
|
||||
annotatedReferenceUrl: data.annotatedReferenceUrl,
|
||||
aspectRatio: data.aspectRatio,
|
||||
displayPrompt: data.displayPrompt,
|
||||
prompt: data.prompt,
|
||||
queueStatus: data.queueStatus,
|
||||
sourceHeight: data.sourceHeight,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user