From 948bed17377745e475c17ece55facaaa48e4b5d6 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 24 Jun 2026 21:52:45 +0800 Subject: [PATCH] fix: harden canvas edit and save flows --- app/api/canvas/local-edit/route.ts | 129 +++++----- app/api/scene-fusion/route.ts | 12 +- .../[id]/CanvasEditorLegacyPageClient.tsx | 5 +- lib/canvas/generate-image.ts | 230 +++++++++++++++++- services/canvas/index.ts | 72 ++++-- tests/routes/canvas-chat-helpers.test.ts | 48 ++-- 6 files changed, 376 insertions(+), 120 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index fdf9c7ec5..273bb85c9 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -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 ``; - }) - .join(""); - const cornerHandleSize = Math.max(7, Math.round(strokeWidth * 2.2)); - const sourceCornerSvg = sourceCorners - .map( - (corner) => - `` - ) - .join(""); - const targetCornerSvg = targetCorners - .map( - (corner) => - `` - ) - .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) + ? `` + : ""; return ` ${connectorSvg} - - - ${sourceCornerSvg} - ${targetCornerSvg} - + + + + + ${region.index} `; }) @@ -835,9 +800,11 @@ function buildScaleAdjustAnnotationSvg(params: { return Buffer.from(` + + - + ${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, diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index a60837ddc..9c0240f2b 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -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); diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index bdd4c331f..ffbf4a524 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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 diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index ea98c504d..11513fd90 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -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: { diff --git a/services/canvas/index.ts b/services/canvas/index.ts index feff71ada..d549fab5c 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -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 { 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 || '保存画布状态失败'), diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 291c27296..d51b0caec 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -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> = []; + 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) {