diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 2dd419f70..0eb22276e 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -3710,6 +3710,9 @@ function buildScaleAdjustPatchPrompt( "Clean local image edit for a cropped workspace.", `Return one clean realistic crop with the same framing and aspect as input image 1. This crop maps to full-photo pixels left=${patchReference.workspacePixelRect.left}, top=${patchReference.workspacePixelRect.top}, width=${patchReference.workspacePixelRect.width}, height=${patchReference.workspacePixelRect.height}.`, "Input image 1 is the rough target draft. The selected subject is already placed at the requested final size and position; keep that final footprint.", + structureLockedDraft + ? "Structure lock: treat the selected subject pixels in input image 1 as the locked product/person layer. AI work is limited to background completion, boundary blending, occlusion, lighting, contact shadows, and removing rough draft artifacts. Do not repaint the product/person interior." + : "", includeSourceReference ? "Input image 2 is the original clean crop for background texture, lighting, perspective, and non-target objects only. Do not restore the selected subject to its old size." : "", @@ -3732,6 +3735,9 @@ function buildScaleAdjustPatchPrompt( firstInputRole, secondInputRole, cropLines, + structureLockedDraft + ? "Structure lock: treat the selected subject pixels in input image 1 as the locked product/person layer. AI work is limited to background completion, boundary blending, occlusion, lighting, contact shadows, and removing rough draft artifacts. Do not repaint or redesign the target subject interior." + : "", "Only edit the selected target subject inside the search area. The target subject can be a person/model/child/body part, furniture, product, or another movable object. Keep the crop borders, wall, window frame, floor, rug, sofa, lamps, decor, lighting, camera perspective, and all non-target subjects stable.", "If the selected target is a person, scale the whole visible person/body coherently to the final footprint while preserving identity, face, pose, anatomy, hand shape, hair, clothing, and natural human proportions. If a person appears outside the selected target, preserve that non-target person without resizing or redrawing them.", "When the final footprint is smaller, the selected subject must clearly shrink; erase the old larger subject footprint outside the final footprint and replace it with natural background/floor/rug/wall/shadow that matches the original crop.", @@ -4170,9 +4176,7 @@ export async function POST(request: NextRequest) { : []; const scaleAdjustProtectedRegions = isScaleAdjustOperation && scaleAdjustPatchReference - ? scaleAdjustPatchReference.structureLockedDraft - ? [scaleAdjustPatchReference.workspaceRect] - : scaleAdjustProtectedChangeRegions + ? scaleAdjustProtectedChangeRegions : scaleAdjustCompositeRegions; const scaleAdjustVerificationConfig = isScaleAdjustOperation ? { @@ -4386,12 +4390,8 @@ export async function POST(request: NextRequest) { featherMaxPx: 42, featherMinPx: 10, featherRatio: 0.018, - maskMode: scaleAdjustPatchReference.structureLockedDraft - ? "workspace" - : "diff", - regions: scaleAdjustPatchReference.structureLockedDraft - ? undefined - : scaleAdjustPatchCompositeRegions, + maskMode: "diff", + regions: scaleAdjustPatchCompositeRegions, sourceImageUrl, workspaceRegion: scaleAdjustPatchReference.workspaceRect, } diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 6561e0bde..a21973f1d 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -764,7 +764,7 @@ type DirectDetailReviewResult = { }; const AGENT_IMAGE_TASK_POLL_INTERVAL_MS = 1600; -const AGENT_IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000; +const AGENT_IMAGE_TASK_TIMEOUT_MS = 15 * 60 * 1000; const AGENT_DETAIL_IMAGE_PARALLEL_WORKER_COUNT = 4; function sleep(ms: number) { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index a482b60f6..2e48a2353 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -796,7 +796,7 @@ const CANVAS_AGENT_SECTION_TITLES = new Set([ "输出设置", ]); const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600; -const CANVAS_IMAGE_TASK_TIMEOUT_MS = 10 * 60 * 1000; +const CANVAS_IMAGE_TASK_TIMEOUT_MS = 15 * 60 * 1000; const CANVAS_GENERATION_NODE_GAP = 56; const CANVAS_GENERATION_STACK_GAP = 28; const CANVAS_GENERATION_COLLISION_GAP = 32; diff --git a/lib/canvas/image-task-queue.ts b/lib/canvas/image-task-queue.ts index 600d830af..d8bf5d916 100644 --- a/lib/canvas/image-task-queue.ts +++ b/lib/canvas/image-task-queue.ts @@ -58,7 +58,9 @@ interface QueueState { draining: boolean; } -const STALE_RUNNING_MS = 5 * 60 * 1000; +const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000; +const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 15 * 60 * 1000; +const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000; const PENDING_LOOKAHEAD_LIMIT = 100; const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500; const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120; @@ -98,6 +100,29 @@ function normalizeTaskModelKey(request: unknown) { return normalizeCanvasImageModelKey(rawModel); } +function getRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getCanvasImageRunningTimeoutMs(request: unknown) { + const record = getRecord(request); + const debugMeta = getRecord(record?.debugMeta); + const debugLabel = String(record?.debugLabel || ""); + const operation = String(debugMeta?.operation || ""); + const isScaleAdjust = + debugLabel === "canvas-scale-adjust" || + operation === "scale-adjust" || + Boolean(debugMeta?.scaleAdjustPatchWorkflowApplied) || + Boolean(debugMeta?.scaleAdjustPatchForcedForShrink) || + Boolean(record?.scaleAdjustPatchComposite); + + return isScaleAdjust + ? SCALE_ADJUST_RUNNING_TIMEOUT_MS + : DEFAULT_RUNNING_TIMEOUT_MS; +} + function isStyleProTaskRequest(request: unknown): request is StyleProTaskRequest { return Boolean( request && @@ -599,8 +624,16 @@ export async function getCanvasImageTaskForUser(params: { if (!task) return null; if (task.status === "running" && task.startedAt) { - const staleBefore = new Date(Date.now() - STALE_RUNNING_MS); - if (task.startedAt < staleBefore) { + const now = Date.now(); + const runningAgeMs = now - task.startedAt.getTime(); + const runningTimeoutMs = getCanvasImageRunningTimeoutMs(task.request); + const locallyRunning = getQueueState().running.has(task.id); + const shouldMarkStale = + runningAgeMs >= runningTimeoutMs && + (!locallyRunning || runningAgeMs >= LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS); + + if (shouldMarkStale) { + const staleBefore = new Date(now - runningTimeoutMs); const timeoutMessage = "图片生成任务已超时,请重新发起生成"; const completedAt = new Date(); const [timedOutTask] = await db