fix: harden canvas edit and save flows
This commit is contained in:
parent
28c5965c52
commit
948bed1737
@ -747,14 +747,12 @@ function buildScaleAdjustAnnotationSvg(params: {
|
||||
}) {
|
||||
const { width, height, regions, scalePercent } = params;
|
||||
const minSide = Math.max(1, Math.min(width, height));
|
||||
const strokeWidth = Math.max(3, Math.round(minSide * 0.0048));
|
||||
const targetColor = "#00d4ff";
|
||||
const sourceColor = "#ff3b30";
|
||||
const connectorColor = "#14c8ff";
|
||||
const dash = Math.max(12, Math.round(strokeWidth * 2.8));
|
||||
const gap = Math.max(8, Math.round(strokeWidth * 1.8));
|
||||
const connectorWidth = Math.max(2, Math.round(strokeWidth * 0.74));
|
||||
const markerSize = Math.max(8, Math.round(strokeWidth * 3.1));
|
||||
const strokeWidth = Math.max(2, Math.round(minSide * 0.0034));
|
||||
const targetColor = "#008cff";
|
||||
const sourceColor = "#e03131";
|
||||
const connectorColor = "#667085";
|
||||
const connectorWidth = Math.max(2, Math.round(strokeWidth * 0.72));
|
||||
const markerSize = Math.max(7, Math.round(strokeWidth * 2.6));
|
||||
|
||||
const regionSvg = regions
|
||||
.map((region) => {
|
||||
@ -775,59 +773,26 @@ function buildScaleAdjustAnnotationSvg(params: {
|
||||
height - badgeSize / 2 - 4
|
||||
);
|
||||
const fontSize = Math.max(13, Math.round(badgeSize * 0.52));
|
||||
const sourceRight = source.left + source.width;
|
||||
const sourceBottom = source.top + source.height;
|
||||
const targetRight = target.left + target.width;
|
||||
const targetBottom = target.top + target.height;
|
||||
const sourceCorners = [
|
||||
{ x: source.left, y: source.top },
|
||||
{ x: sourceRight, y: source.top },
|
||||
{ x: sourceRight, y: sourceBottom },
|
||||
{ x: source.left, y: sourceBottom },
|
||||
];
|
||||
const targetCorners = [
|
||||
{ x: target.left, y: target.top },
|
||||
{ x: targetRight, y: target.top },
|
||||
{ x: targetRight, y: targetBottom },
|
||||
{ x: target.left, y: targetBottom },
|
||||
];
|
||||
const connectorSvg = sourceCorners
|
||||
.map((corner, cornerIndex) => {
|
||||
const targetCorner = targetCorners[cornerIndex];
|
||||
const distance = Math.hypot(targetCorner.x - corner.x, targetCorner.y - corner.y);
|
||||
if (distance < Math.max(8, strokeWidth * 2.2)) return "";
|
||||
const dx = (targetCorner.x - corner.x) / distance;
|
||||
const dy = (targetCorner.y - corner.y) / distance;
|
||||
const startInset = Math.min(distance * 0.16, Math.max(5, strokeWidth * 1.8));
|
||||
const endInset = Math.min(distance * 0.18, Math.max(8, strokeWidth * 2.8));
|
||||
const x1 = corner.x + dx * startInset;
|
||||
const y1 = corner.y + dy * startInset;
|
||||
const x2 = targetCorner.x - dx * endInset;
|
||||
const y2 = targetCorner.y - dy * endInset;
|
||||
return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${connectorColor}" stroke-width="${connectorWidth}" stroke-linecap="round" opacity="0.86" marker-end="url(#scale-adjust-arrow)" />`;
|
||||
})
|
||||
.join("");
|
||||
const cornerHandleSize = Math.max(7, Math.round(strokeWidth * 2.2));
|
||||
const sourceCornerSvg = sourceCorners
|
||||
.map(
|
||||
(corner) =>
|
||||
`<circle cx="${corner.x}" cy="${corner.y}" r="${cornerHandleSize * 0.48}" fill="#ffffff" stroke="${sourceColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.7))}" />`
|
||||
)
|
||||
.join("");
|
||||
const targetCornerSvg = targetCorners
|
||||
.map(
|
||||
(corner) =>
|
||||
`<rect x="${corner.x - cornerHandleSize / 2}" y="${corner.y - cornerHandleSize / 2}" width="${cornerHandleSize}" height="${cornerHandleSize}" rx="${Math.max(1, Math.round(cornerHandleSize * 0.18))}" fill="#ffffff" stroke="${targetColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.7))}" />`
|
||||
)
|
||||
.join("");
|
||||
const sourceCenterX = source.left + source.width / 2;
|
||||
const sourceCenterY = source.top + source.height / 2;
|
||||
const targetCenterX = target.left + target.width / 2;
|
||||
const targetCenterY = target.top + target.height / 2;
|
||||
const centerDistance = Math.hypot(
|
||||
targetCenterX - sourceCenterX,
|
||||
targetCenterY - sourceCenterY
|
||||
);
|
||||
const connectorSvg =
|
||||
centerDistance > Math.max(8, strokeWidth * 2.4)
|
||||
? `<line x1="${sourceCenterX.toFixed(1)}" y1="${sourceCenterY.toFixed(1)}" x2="${targetCenterX.toFixed(1)}" y2="${targetCenterY.toFixed(1)}" stroke="${connectorColor}" stroke-width="${connectorWidth}" stroke-linecap="round" opacity="0.78" marker-end="url(#scale-adjust-arrow)" />`
|
||||
: "";
|
||||
|
||||
return `
|
||||
${connectorSvg}
|
||||
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${radius}" fill="${sourceColor}" opacity="0.025" stroke="${sourceColor}" stroke-width="${strokeWidth}" />
|
||||
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${radius}" fill="${targetColor}" opacity="0.035" stroke="${targetColor}" stroke-width="${Math.max(3, Math.round(strokeWidth * 1.05))}" stroke-dasharray="${dash} ${gap}" />
|
||||
${sourceCornerSvg}
|
||||
${targetCornerSvg}
|
||||
<circle cx="${badgeX}" cy="${badgeY}" r="${badgeSize / 2}" fill="${sourceColor}" opacity="0.92" stroke="#ffffff" stroke-width="${Math.max(1.5, Math.round(strokeWidth * 0.36))}" />
|
||||
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${radius}" fill="${sourceColor}" opacity="0.04" stroke="${sourceColor}" stroke-width="${strokeWidth}" />
|
||||
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${radius}" fill="${targetColor}" opacity="0.055" stroke="${targetColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 1.05))}" />
|
||||
<circle cx="${sourceCenterX}" cy="${sourceCenterY}" r="${Math.max(3, strokeWidth * 0.9)}" fill="${sourceColor}" opacity="0.88" />
|
||||
<circle cx="${targetCenterX}" cy="${targetCenterY}" r="${Math.max(3, strokeWidth * 0.9)}" fill="${targetColor}" opacity="0.88" />
|
||||
<circle cx="${badgeX}" cy="${badgeY}" r="${badgeSize / 2}" fill="${sourceColor}" opacity="0.9" stroke="#ffffff" stroke-width="${Math.max(1.5, Math.round(strokeWidth * 0.36))}" />
|
||||
<text x="${badgeX}" y="${badgeY + fontSize * 0.36}" text-anchor="middle" fill="#ffffff" font-size="${fontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">${region.index}</text>
|
||||
`;
|
||||
})
|
||||
@ -835,9 +800,11 @@ function buildScaleAdjustAnnotationSvg(params: {
|
||||
|
||||
return Buffer.from(`
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="#f5f7fb" />
|
||||
<rect x="${Math.max(1, strokeWidth / 2)}" y="${Math.max(1, strokeWidth / 2)}" width="${Math.max(1, width - strokeWidth)}" height="${Math.max(1, height - strokeWidth)}" fill="none" stroke="#d9dee8" stroke-width="${Math.max(1, Math.round(strokeWidth * 0.55))}" />
|
||||
<defs>
|
||||
<marker id="scale-adjust-arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="${markerSize}" markerHeight="${markerSize}" orient="auto">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="${connectorColor}" opacity="0.92" />
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="${connectorColor}" opacity="0.86" />
|
||||
</marker>
|
||||
</defs>
|
||||
${regionSvg}
|
||||
@ -910,14 +877,28 @@ function buildScaleAdjustPrompt(
|
||||
const moved =
|
||||
Math.hypot(targetCenterX - sourceCenterX, targetCenterY - sourceCenterY) >
|
||||
0.01;
|
||||
const targetPixelRect =
|
||||
region.targetPixelRect ||
|
||||
(sourceSize
|
||||
? rectToPixels(targetRect, sourceSize.width, sourceSize.height)
|
||||
: null);
|
||||
const widthFactor =
|
||||
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||||
const heightFactor =
|
||||
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||||
const sourcePixelLine = `源框像素坐标:x=${region.pixelRect.left}, y=${region.pixelRect.top}, w=${region.pixelRect.width}, h=${region.pixelRect.height}`;
|
||||
const targetPixelLine = targetPixelRect
|
||||
? `目标框像素坐标:x=${targetPixelRect.left}, y=${targetPixelRect.top}, w=${targetPixelRect.width}, h=${targetPixelRect.height}`
|
||||
: `目标框相对坐标:x=${Math.round(targetRect.x * 1000) / 10}%, y=${Math.round(targetRect.y * 1000) / 10}%, w=${Math.round(targetRect.width * 1000) / 10}%, h=${Math.round(targetRect.height * 1000) / 10}%`;
|
||||
const scaleLine = `目标视觉占位约为原主体宽度的 ${widthFactor.toFixed(2)} 倍、高度的 ${heightFactor.toFixed(2)} 倍;执行时必须以等比主体缩放为准,不要拉伸主体去硬贴目标框。`;
|
||||
const targetPositionNote = moved
|
||||
? "青色虚线框同时指定用户希望的最终目标大小和目标位置,目标主体调整后的视觉中心应移动到青色虚线框中心附近。"
|
||||
: "青色虚线框指定用户希望的最终目标大小,目标主体调整后应围绕当前主体位置自然放置。";
|
||||
? "目标框同时指定用户希望的最终目标大小和目标位置,目标主体调整后的视觉中心应移动到目标框中心附近。"
|
||||
: "目标框指定用户希望的最终目标大小,目标主体调整后应围绕当前主体位置自然放置。";
|
||||
const cropNote =
|
||||
cropIndex >= 0
|
||||
? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例`
|
||||
: "";
|
||||
return `区域 ${region.index}${cropNote}:请在输入图片1中找到输入图片2红色实线框所标记范围内最主要、可独立调整的商品/物体主体。红色实线框只是“寻找主体”的范围,不是最终边界,也不是要缩放或贴回的矩形补丁。${targetPositionNote}请直接读取输入图片2中青色虚线框相对红色实线框的真实大小和位置关系,并按这个框线关系调整主体;四条蓝色箭头从红框四角指向青框四角,表示主体外接轮廓从当前位置迁移到目标大小和目标位置的方向。不要把框线换算成文字百分比来执行。目标虚线框不是要求主体填满矩形或变成矩形比例,请以主体自身轮廓、原始宽高比和视觉中心为准,整体等比调整主体大小和位置。严禁宽度变窄但高度变高,严禁横向挤压、纵向拉伸或改变主体透视形体。如果红框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||||
return `区域 ${region.index}${cropNote}:请在输入图片1中找到源框范围内最主要、可独立调整的商品/物体主体。${sourcePixelLine};${targetPixelLine};${scaleLine}${targetPositionNote}源框只是“寻找主体”的范围,不是最终边界,也不是要缩放或贴回的矩形补丁。目标框只是主体调整后的视觉占位参考,不要求主体填满矩形,更不能改变主体自身宽高比、透视或形体。请像真实图片编辑一样先保留锁定的原始画布,再只把这个主体整体等比变大/变小/移动到目标视觉占位;主体缩放后露出或遮挡的极小背景区域需要自然重建。严禁把输入图片1整张图、输入图片2坐标图、任意裁切图、源框矩形区域或目标框矩形区域当成图片块整体缩放、平移、复制、裁切或贴回。如果源框中只覆盖到主体的一部分,请根据局部裁切参考和可见结构推断该主体的完整可见轮廓,只调整主体,不调整矩形框内背景。`;
|
||||
})
|
||||
.join("\n");
|
||||
const cropLines = cropReferences
|
||||
@ -931,28 +912,28 @@ function buildScaleAdjustPrompt(
|
||||
"这是一项局部主体比例 inpaint/edit 任务,不是整图缩放、整图裁切或重新构图任务。",
|
||||
sourceSizeLine,
|
||||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。除目标主体尺寸变化与必要背景补全外,输入图片1中的文字、Logo、装饰、排版、背景、光线、构图边界和所有未选区域必须保持不变。",
|
||||
"输入图片2是输入图片1的标注副本,直接在原始图上叠加了红色实线框、青色目标虚线框和四角箭头,只用于让你读取目标主体的当前位置、目标大小和目标位置;输入图片2不是输出底图,不得学习或复刻它的框线、箭头、编号、角点、半透明遮罩或 UI 标记。",
|
||||
"输入图片2是一张与输入图片1尺寸完全相同的坐标指令图,不是照片,也不是输出底图。它用灰色背景承载红色源框、蓝色目标框和中心移动箭头,只用于说明坐标关系;不得学习、复刻或输出其中的框线、箭头、编号、灰底或 UI 标记。",
|
||||
cropLines
|
||||
? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}`
|
||||
: "",
|
||||
"输入图片2中的红色实线框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制调整后的空间。青色虚线框表示用户手画的比例调整后目标大小和目标位置。四条蓝色箭头从红框四角指向青框四角:箭头向外表示放大,箭头向内表示缩小,箭头整体偏移表示移动。最终画面不得出现这些框线、箭头或角点,主体也不需要填满虚线矩形;必须保持主体自身原始宽高比和透视形体。",
|
||||
"输入图片2中的红色源框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制调整后的空间。蓝色目标框表示用户希望主体调整后的视觉占位和中心位置,中心箭头只表示主体中心的移动方向。最终画面不得出现这些框线、箭头、编号或灰底;主体也不需要填满目标矩形,必须保持主体自身原始宽高比和透视形体。",
|
||||
"如果系统或模型只能读取一张参考图,必须优先以输入图片1为输出基底,不得清理、改写或删除输入图片1中框选区域外的任何文字和图形。",
|
||||
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片、蒙版边界,也不是放大后的空间上限,更不是要整体缩放的矩形图层。",
|
||||
"最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度;严禁整张图整体缩放、裁切、平移、重新排版或重新生成。",
|
||||
"Highest priority rule: do NOT zoom, crop, resize, reframe, pan, or regenerate the full canvas. Treat image 1 as a locked background plate; edit only the selected subject and the smallest necessary background around its old and new footprint.",
|
||||
"请不要生成任何彩色框、矩形边框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
||||
"不要把用户手画的目标框换算成百分比文字执行;必须直接以输入图片2里青色目标框线本身作为大小和位置参考。",
|
||||
"请同时使用下方每个区域的源框/目标框像素坐标和输入图片2的坐标图来理解目标占位。坐标只用于指导主体位置和大小,不是要求把某个矩形图块缩放后贴回。",
|
||||
"",
|
||||
"任务:先在输入图片1中识别红色实线框范围内最主要、可独立调整的主体物体,再只对这个“主体物体”做局部比例调整。调整后的主体大小和位置必须以输入图片2中对应青色目标框线为准;如果输出看起来和原图一样,就是失败。",
|
||||
"任务:先在输入图片1中识别源框范围内最主要、可独立调整的主体物体,再只对这个“主体物体”做局部比例调整。调整后的主体大小和位置必须接近目标框表示的视觉占位;如果输出看起来和原图一样,就是失败。",
|
||||
regionLines,
|
||||
"",
|
||||
"关键要求:",
|
||||
"1. 只调整目标主体物体本身的整体视觉比例,不得保留原尺寸;主体轮廓宽度和高度必须同步等比变化,宽高比必须与原主体一致,不能为了贴合虚线框而拉宽、压窄、拉高或压扁。",
|
||||
"1. 只调整目标主体物体本身的整体视觉比例,不得保留原尺寸;主体轮廓宽度和高度必须同步等比变化,宽高比必须与原主体一致,不能为了贴合目标框而拉宽、压窄、拉高或压扁。",
|
||||
"2. 严禁把整个矩形框选区域、输入图片1整张图或任意裁切图当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
|
||||
"2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。",
|
||||
"3. 如果输入图片2中的青色目标框明显小于红色主体框,目标主体应自然变小,主体缩小后露出的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。",
|
||||
"4. 如果输入图片2中的青色目标框明显大于红色主体框,目标主体应自然变大;允许主体自然超出用户手画的原始红框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。",
|
||||
"5. 如果青色目标框中心与红色主体框中心不同,允许主体同步移动到青色目标框中心附近;如果主体靠近画布边界导致目标空间不足,请用能完整放入画面的最大统一倍率整体调整,绝不能为了塞进画面而只压缩宽度或只拉伸高度。",
|
||||
"2.1 严禁把输入图片1整张图整体放大、缩小、裁切或重新构图;如果画面中的标题、Logo、页面文字、边框、窗框、背景家具或非目标主体发生整体位移/缩放/消失,必须视为失败并重新生成。请把这些元素当作像素锚点,它们的位置和比例不能跟着目标主体变化。",
|
||||
"3. 如果输入图片2中的蓝色目标框明显小于红色源框,目标主体应自然变小,主体缩小后露出的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。",
|
||||
"4. 如果输入图片2中的蓝色目标框明显大于红色源框,目标主体应自然变大;允许主体自然超出用户手画的原始源框,使用周围真实背景完成边缘、阴影和遮挡衔接。宽度和高度都要同步变大,不能出现“高度变高但宽度变窄”的非等比变形;主体边缘与遮挡关系要合理,不能产生突兀裁切。",
|
||||
"5. 如果蓝色目标框中心与红色源框中心不同,允许主体同步移动到蓝色目标框中心附近;如果主体靠近画布边界导致目标空间不足,请用能完整放入画面的最大统一倍率整体调整,绝不能为了塞进画面而只压缩宽度或只拉伸高度。",
|
||||
"6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
|
||||
"6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。",
|
||||
"6.2 框选区域通常会包含主体周围的背景留白,背景留白不是目标主体。请锁定这些非主体背景的窗框、文字、地毯纹理、墙面、桌面边缘和透视线,只重建主体缩放后被遮挡或露出的极小必要区域;不要把框选矩形当作整块背景重新生成。",
|
||||
@ -1104,7 +1085,7 @@ export async function POST(request: NextRequest) {
|
||||
.composite([{ input: annotationSvg, left: 0, top: 0 }])
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
|
||||
const annotatedFilename = `${isScaleAdjustOperation ? "canvas-scale-adjust-guide" : "canvas-local-edit"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
|
||||
const annotatedUpload = await uploadFile(
|
||||
annotatedFilename,
|
||||
toArrayBuffer(annotatedBuffer)
|
||||
@ -1185,7 +1166,7 @@ export async function POST(request: NextRequest) {
|
||||
annotatedReferenceUrl: annotatedUpload.url,
|
||||
annotatedReferenceSentToModel: isScaleAdjustOperation,
|
||||
annotatedReferenceType: isScaleAdjustOperation
|
||||
? "source-target-arrow-overlay"
|
||||
? "scale-adjust-coordinate-guide-map"
|
||||
: "source-overlay",
|
||||
cleanSourceReferenceIndex: 1,
|
||||
configuredDetailModelId: configuredDetailModel || null,
|
||||
@ -1209,7 +1190,7 @@ export async function POST(request: NextRequest) {
|
||||
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
||||
.length,
|
||||
scaleAdjustPromptStrategy: isScaleAdjustOperation
|
||||
? "locked-plate-local-subject-target-box-arrows-v6"
|
||||
? "locked-plate-coordinate-guide-map-pixel-anchors-v7"
|
||||
: undefined,
|
||||
scaleAdjustPostCompositeApplied: false,
|
||||
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,
|
||||
|
||||
@ -2072,9 +2072,15 @@ async function processSceneWithDiversity(
|
||||
modelName: candidate.model.displayName,
|
||||
});
|
||||
if (reviewResult && !reviewResult.passed) {
|
||||
throw new Error(
|
||||
`生成结果未通过替换质检(score=${reviewResult.score.toFixed(2)},${reviewResult.reason})`
|
||||
);
|
||||
logger.warn("candidate_review_warning", {
|
||||
requestId: params.requestId,
|
||||
sceneIndex: params.sceneIndex,
|
||||
attempt: attemptIndex + 1,
|
||||
model: candidate.model.modelId,
|
||||
provider: candidate.provider.type,
|
||||
score: Number(reviewResult.score.toFixed(2)),
|
||||
reason: reviewResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
markSceneFusionCandidateHealthy(candidate, params.modelCooldown);
|
||||
|
||||
@ -4697,7 +4697,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
if (!result.success) {
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
if (result.status === 409) {
|
||||
toast.warning("画布已被 Agent 更新,请刷新后再保存当前修改");
|
||||
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||
reason,
|
||||
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||
});
|
||||
}
|
||||
} else if (result.data?.updatedAt) {
|
||||
useCanvasStore
|
||||
|
||||
@ -135,6 +135,9 @@ const SCALE_ADJUST_OUTSIDE_PIXEL_DIFF_THRESHOLD = 16;
|
||||
const SCALE_ADJUST_OUTSIDE_PIXEL_CHANGED_RATIO_THRESHOLD = 0.16;
|
||||
const SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD = 14;
|
||||
const SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN = 0.015;
|
||||
const SCALE_ADJUST_MARKER_CLEANUP_MARGIN = 0.035;
|
||||
const SCALE_ADJUST_MARKER_DILATE_RADIUS = 3;
|
||||
const SCALE_ADJUST_MARKER_MIN_PIXEL_COUNT = 18;
|
||||
const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112;
|
||||
|
||||
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
||||
@ -776,6 +779,180 @@ async function loadScaleAdjustSourceImage(
|
||||
};
|
||||
}
|
||||
|
||||
function isScaleAdjustAnnotationMarkerColor(r: number, g: number, b: number) {
|
||||
const redMarker = r >= 175 && g <= 120 && b <= 120 && r >= g + 55 && r >= b + 55;
|
||||
const cyanMarker =
|
||||
g >= 145 && b >= 145 && r <= 135 && Math.min(g, b) >= r + 45;
|
||||
const blueMarker =
|
||||
b >= 165 && r <= 125 && b >= r + 55 && b >= g + 20;
|
||||
|
||||
return redMarker || cyanMarker || blueMarker;
|
||||
}
|
||||
|
||||
function isPointInCanvasRegions(
|
||||
normalizedX: number,
|
||||
normalizedY: number,
|
||||
regions: CanvasLocalEditCompositeRegion[]
|
||||
) {
|
||||
return regions.some(
|
||||
(region) =>
|
||||
normalizedX >= region.x &&
|
||||
normalizedX <= region.x + region.width &&
|
||||
normalizedY >= region.y &&
|
||||
normalizedY <= region.y + region.height
|
||||
);
|
||||
}
|
||||
|
||||
function dilateBinaryMask(params: {
|
||||
height: number;
|
||||
mask: Uint8Array;
|
||||
radius: number;
|
||||
width: number;
|
||||
}) {
|
||||
const { height, mask, radius, width } = params;
|
||||
if (radius <= 0) return mask;
|
||||
|
||||
const dilated = new Uint8Array(mask.length);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (mask[y * width + x] === 0) continue;
|
||||
const left = Math.max(0, x - radius);
|
||||
const right = Math.min(width - 1, x + radius);
|
||||
const top = Math.max(0, y - radius);
|
||||
const bottom = Math.min(height - 1, y + radius);
|
||||
for (let yy = top; yy <= bottom; yy += 1) {
|
||||
for (let xx = left; xx <= right; xx += 1) {
|
||||
dilated[yy * width + xx] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dilated;
|
||||
}
|
||||
|
||||
async function removeScaleAdjustAnnotationArtifacts(params: {
|
||||
debugLabel: string;
|
||||
generatedBuffer: Buffer;
|
||||
imageIndex: number;
|
||||
sourceBuffer: Buffer;
|
||||
sourceHeight: number;
|
||||
sourceWidth: number;
|
||||
verification: CanvasScaleAdjustVerificationConfig;
|
||||
}) {
|
||||
const {
|
||||
debugLabel,
|
||||
generatedBuffer,
|
||||
imageIndex,
|
||||
sourceBuffer,
|
||||
sourceHeight,
|
||||
sourceWidth,
|
||||
verification,
|
||||
} = params;
|
||||
const cleanupRegions = getScaleAdjustProtectedRegions(verification).map((region) =>
|
||||
expandCanvasRegion(region, SCALE_ADJUST_MARKER_CLEANUP_MARGIN)
|
||||
);
|
||||
if (cleanupRegions.length === 0) return null;
|
||||
|
||||
const normalizeToRaw = (buffer: Buffer) =>
|
||||
sharp(buffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(sourceWidth, sourceHeight, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.removeAlpha()
|
||||
.toColorspace("srgb")
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const [sourceRaw, generatedRaw] = await Promise.all([
|
||||
normalizeToRaw(sourceBuffer),
|
||||
normalizeToRaw(generatedBuffer),
|
||||
]);
|
||||
const markerMask = new Uint8Array(sourceWidth * sourceHeight);
|
||||
let markerPixelCount = 0;
|
||||
|
||||
for (let y = 0; y < sourceHeight; y += 1) {
|
||||
const normalizedY = y / sourceHeight;
|
||||
for (let x = 0; x < sourceWidth; x += 1) {
|
||||
const normalizedX = x / sourceWidth;
|
||||
if (!isPointInCanvasRegions(normalizedX, normalizedY, cleanupRegions)) {
|
||||
continue;
|
||||
}
|
||||
const maskIndex = y * sourceWidth + x;
|
||||
const rawIndex = maskIndex * 3;
|
||||
if (
|
||||
isScaleAdjustAnnotationMarkerColor(
|
||||
generatedRaw[rawIndex],
|
||||
generatedRaw[rawIndex + 1],
|
||||
generatedRaw[rawIndex + 2]
|
||||
)
|
||||
) {
|
||||
markerMask[maskIndex] = 255;
|
||||
markerPixelCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (markerPixelCount < SCALE_ADJUST_MARKER_MIN_PIXEL_COUNT) return null;
|
||||
|
||||
const cleanupMask = dilateBinaryMask({
|
||||
height: sourceHeight,
|
||||
mask: markerMask,
|
||||
radius: SCALE_ADJUST_MARKER_DILATE_RADIUS,
|
||||
width: sourceWidth,
|
||||
});
|
||||
const cleanedRaw = Buffer.from(generatedRaw);
|
||||
let cleanedPixelCount = 0;
|
||||
|
||||
for (let index = 0; index < cleanupMask.length; index += 1) {
|
||||
if (cleanupMask[index] === 0) continue;
|
||||
const rawIndex = index * 3;
|
||||
cleanedRaw[rawIndex] = sourceRaw[rawIndex];
|
||||
cleanedRaw[rawIndex + 1] = sourceRaw[rawIndex + 1];
|
||||
cleanedRaw[rawIndex + 2] = sourceRaw[rawIndex + 2];
|
||||
cleanedPixelCount += 1;
|
||||
}
|
||||
|
||||
const cleanedBuffer = await sharp(cleanedRaw, {
|
||||
raw: { channels: 3, height: sourceHeight, width: sourceWidth },
|
||||
})
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
const thumbnailBuffer = await sharp(cleanedBuffer, { failOn: "none" })
|
||||
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
const suffix = `${Date.now()}-${imageIndex + 1}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
const [originalUpload, thumbnailUpload] = await Promise.all([
|
||||
uploadFile(
|
||||
`canvas-scale-adjust-marker-cleaned-${suffix}.png`,
|
||||
bufferToArrayBuffer(cleanedBuffer)
|
||||
),
|
||||
uploadFile(
|
||||
`canvas-scale-adjust-marker-cleaned-thumb-${suffix}.png`,
|
||||
bufferToArrayBuffer(thumbnailBuffer)
|
||||
),
|
||||
]);
|
||||
|
||||
console.info("[Canvas Generate Image] Scale adjust annotation artifacts cleaned:", {
|
||||
cleanupRegionCount: cleanupRegions.length,
|
||||
cleanedPixelCount,
|
||||
debugLabel,
|
||||
imageIndex,
|
||||
markerPixelCount,
|
||||
sourceHeight,
|
||||
sourceWidth,
|
||||
});
|
||||
|
||||
return {
|
||||
originalUrl: originalUpload.url,
|
||||
thumbnailUrl: thumbnailUpload.url,
|
||||
} satisfies ParsedImage;
|
||||
}
|
||||
|
||||
async function applyScaleAdjustVerificationToImages(params: {
|
||||
images: ParsedImage[];
|
||||
verification: CanvasScaleAdjustVerificationConfig | null;
|
||||
@ -796,6 +973,7 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
return images;
|
||||
}
|
||||
|
||||
const processedImages = [...images];
|
||||
for (const [index, image] of images.entries()) {
|
||||
const generatedUrl = image.originalUrl || image.thumbnailUrl;
|
||||
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
|
||||
@ -849,24 +1027,54 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
targetRegionCount: verification.targetRegions?.length || 0,
|
||||
});
|
||||
|
||||
if (outsideChanged) {
|
||||
throw new ScaleAdjustOutputRejectedError({
|
||||
diffScores,
|
||||
outsideDiffScores,
|
||||
reason: "outside-frame-changed",
|
||||
const guardWarnings = [
|
||||
...(outsideChanged ? ["outside-frame-changed"] : []),
|
||||
...(!changedInside ? ["no-effective-scale-change"] : []),
|
||||
];
|
||||
if (guardWarnings.length > 0) {
|
||||
console.warn("[Canvas Generate Image] Scale adjust result guard warning; keeping result for preview:", {
|
||||
debugLabel,
|
||||
imageIndex: index,
|
||||
reasons: guardWarnings,
|
||||
changedInside,
|
||||
outsideChanged,
|
||||
outsideDiffScores: outsideDiffScores.map((score) => Number(score.toFixed(2))),
|
||||
outsidePixelStats: {
|
||||
changedPixelCount: outsidePixelStats.changedPixelCount,
|
||||
changedRatio: Number(outsidePixelStats.changedRatio.toFixed(4)),
|
||||
meanDiff: Number(outsidePixelStats.meanDiff.toFixed(2)),
|
||||
sampledPixelCount: outsidePixelStats.sampledPixelCount,
|
||||
step: outsidePixelStats.step,
|
||||
},
|
||||
regionDiffScores: diffScores.map((score) => Number(score.toFixed(2))),
|
||||
scalePercent: verification.scalePercent ?? null,
|
||||
targetRegionCount: verification.targetRegions?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (!changedInside) {
|
||||
throw new ScaleAdjustOutputRejectedError({
|
||||
diffScores,
|
||||
outsideDiffScores,
|
||||
reason: "no-effective-scale-change",
|
||||
try {
|
||||
const cleanedImage = await removeScaleAdjustAnnotationArtifacts({
|
||||
debugLabel,
|
||||
generatedBuffer,
|
||||
imageIndex: index,
|
||||
sourceBuffer: sourceImage.sourceBuffer,
|
||||
sourceHeight: sourceImage.sourceHeight,
|
||||
sourceWidth: sourceImage.sourceWidth,
|
||||
verification,
|
||||
});
|
||||
if (cleanedImage) {
|
||||
processedImages[index] = cleanedImage;
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn("[Canvas Generate Image] Failed to clean scale adjust annotation artifacts:", {
|
||||
debugLabel,
|
||||
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||
imageIndex: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
return processedImages;
|
||||
}
|
||||
|
||||
function buildLocalEditCompositeMaskSvg(params: {
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
buildCanvasSaveRequestBody,
|
||||
type CanvasSaveRevisionOptions,
|
||||
} from '@/lib/canvas/canvas-save-request';
|
||||
import { STALE_CANVAS_REVISION_CODE } from '@/lib/canvas/canvas-save-conflict';
|
||||
import type {
|
||||
EnhanceProKreaModel,
|
||||
EnhanceProOutputFormat,
|
||||
@ -237,32 +238,69 @@ export const canvasService = {
|
||||
options?: CanvasSaveRevisionOptions
|
||||
): Promise<ApiResponse> {
|
||||
try {
|
||||
const requestBody = JSON.stringify(
|
||||
buildCanvasSaveRequestBody({
|
||||
canvasData,
|
||||
workspaceType,
|
||||
options,
|
||||
fallbackOptions: getCanvasSaveRevisionFallback(),
|
||||
})
|
||||
);
|
||||
const doSave = async () =>
|
||||
const fallbackOptions = getCanvasSaveRevisionFallback();
|
||||
const buildRequestBody = (
|
||||
revisionOptions?: CanvasSaveRevisionOptions,
|
||||
shouldUseFallback = true
|
||||
) =>
|
||||
JSON.stringify(
|
||||
buildCanvasSaveRequestBody({
|
||||
canvasData,
|
||||
workspaceType,
|
||||
options: revisionOptions,
|
||||
fallbackOptions: shouldUseFallback ? fallbackOptions : undefined,
|
||||
})
|
||||
);
|
||||
const doSave = async (requestBody: string) =>
|
||||
fetch(`/api/canvas/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: requestBody,
|
||||
});
|
||||
const doSaveWithRateLimitRetry = async (requestBody: string) => {
|
||||
let response = await doSave(requestBody);
|
||||
if (response.status === 429) {
|
||||
const retryPayload = await response.json().catch(() => ({}));
|
||||
const retryAfter = Number(retryPayload?.retryAfter || 0);
|
||||
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 800;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
response = await doSave(requestBody);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
let response = await doSave();
|
||||
if (response.status === 429) {
|
||||
const retryPayload = await response.json().catch(() => ({}));
|
||||
const retryAfter = Number(retryPayload?.retryAfter || 0);
|
||||
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 800;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
response = await doSave();
|
||||
let response = await doSaveWithRateLimitRetry(buildRequestBody(options));
|
||||
let data = await response.json().catch(() => ({}));
|
||||
|
||||
if (
|
||||
!response.ok &&
|
||||
response.status === 409 &&
|
||||
data?.code === STALE_CANVAS_REVISION_CODE &&
|
||||
typeof data?.canvasUpdatedAt === "string" &&
|
||||
data.canvasUpdatedAt.trim()
|
||||
) {
|
||||
const latestRevision = data.canvasUpdatedAt.trim();
|
||||
syncCanvasStoreRevisionFromUpdatedAt(latestRevision);
|
||||
response = await doSaveWithRateLimitRetry(
|
||||
buildRequestBody(
|
||||
{
|
||||
baseCanvasRevision: latestRevision,
|
||||
baseCanvasUpdatedAt: latestRevision,
|
||||
},
|
||||
false
|
||||
)
|
||||
);
|
||||
data = await response.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
if (
|
||||
response.status === 409 &&
|
||||
data?.code === STALE_CANVAS_REVISION_CODE &&
|
||||
typeof data?.canvasUpdatedAt === "string"
|
||||
) {
|
||||
syncCanvasStoreRevisionFromUpdatedAt(data.canvasUpdatedAt);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: String(data?.error || data?.message || '保存画布状态失败'),
|
||||
|
||||
@ -650,19 +650,14 @@ test.describe("canvas chat pure helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("canvas service exposes stale save conflict details", async () => {
|
||||
test("canvas service automatically retries stale save conflicts", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
|
||||
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
|
||||
.window;
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
writable: true,
|
||||
});
|
||||
globalThis.fetch = (async () =>
|
||||
({
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
const responses = [
|
||||
{
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
@ -671,7 +666,26 @@ test.describe("canvas chat pure helpers", () => {
|
||||
code: "STALE_CANVAS_REVISION",
|
||||
canvasUpdatedAt: "2026-06-22T00:02:00.000Z",
|
||||
}),
|
||||
}) as Response) as typeof fetch;
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { updatedAt: "2026-06-22T00:02:01.000Z" },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
writable: true,
|
||||
});
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init?.body || "{}")));
|
||||
return responses.shift() as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await canvasService.saveCanvasState(
|
||||
@ -686,11 +700,17 @@ test.describe("canvas chat pure helpers", () => {
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: 409,
|
||||
code: "STALE_CANVAS_REVISION",
|
||||
canvasUpdatedAt: "2026-06-22T00:02:00.000Z",
|
||||
success: true,
|
||||
data: { updatedAt: "2026-06-22T00:02:01.000Z" },
|
||||
});
|
||||
expect(bodies).toHaveLength(2);
|
||||
expect(bodies[1]).toMatchObject({
|
||||
baseCanvasRevision: "2026-06-22T00:02:00.000Z",
|
||||
baseCanvasUpdatedAt: "2026-06-22T00:02:00.000Z",
|
||||
});
|
||||
expect(useCanvasStore.getState().canvasRevision).toBe(
|
||||
"2026-06-22T00:02:01.000Z"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (hadWindow) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user