fix: harden canvas edit and save flows

This commit is contained in:
134 2026-06-24 21:52:45 +08:00
parent 28c5965c52
commit 948bed1737
6 changed files with 376 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 || '保存画布状态失败'),

View File

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