fix: improve scale adjust guidance and scan feedback

This commit is contained in:
134 2026-06-17 21:44:57 +08:00
parent af63ebba9e
commit 74ae88edbb
6 changed files with 110 additions and 39 deletions

View File

@ -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,

View File

@ -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>

View File

@ -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,

View File

@ -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,

View File

@ -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:", {

View File

@ -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,