diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index f082bbd52..8d295476c 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({ aiWashConfig, setAiWashConfig, aiWashModeIconUploading, + canvasLocalEditConfig, + setCanvasLocalEditConfig, extendConfig, setExtendConfig, watermarkRemoveConfig, @@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({ handleMoveEnhanceProPreset, handleAiWashModeIconUpload, handleSaveAiWashConfig, + handleSaveCanvasLocalEditConfig, handleSaveExtendConfig, handleSaveWatermarkRemoveConfig, handleSaveAngleEditConfig, @@ -1421,6 +1424,112 @@ export function LocalAiImagePipelineSections({ + + 配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。 + + } + expanded={isLocalAiSectionExpanded("canvas-local-edit")} + id="local-ai-section-canvas-local-edit" + onToggle={() => toggleLocalAiSection("canvas-local-edit")} + summary={ + + } + title="局部编辑模型配置" + > +
+
+ + +
+ 适用于手动画框并填写提示词的局部编辑。 +
+
+ +
+ + +
+ 适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。 +
+
+
+ + +
+ diff --git a/app/(admin)/settings/_lib/local-ai-sections.ts b/app/(admin)/settings/_lib/local-ai-sections.ts index 308b081b1..a3de0ada7 100644 --- a/app/(admin)/settings/_lib/local-ai-sections.ts +++ b/app/(admin)/settings/_lib/local-ai-sections.ts @@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record = { rmbg: false, enhance: false, "enhance-pro": false, + "canvas-local-edit": false, "ai-wash": false, extend: false, "watermark-remove": false, diff --git a/app/(admin)/settings/_lib/types.ts b/app/(admin)/settings/_lib/types.ts index 38740da8f..82578cbf6 100644 --- a/app/(admin)/settings/_lib/types.ts +++ b/app/(admin)/settings/_lib/types.ts @@ -1,6 +1,7 @@ import type { LucideIcon } from "lucide-react"; import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react"; import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config"; +import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config"; import type { AiWashConfig } from "@/lib/ai-wash/config"; import type { EnhanceProKreaConfig, @@ -330,6 +331,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & { aiWashConfig: AiWashConfig; setAiWashConfig: Dispatch>; aiWashModeIconUploading: boolean; + canvasLocalEditConfig: CanvasLocalEditConfig; + setCanvasLocalEditConfig: Dispatch>; extendConfig: ExtendConfig; setExtendConfig: Dispatch>; watermarkRemoveConfig: WatermarkRemoveConfig; @@ -372,6 +375,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & { handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void; handleAiWashModeIconUpload: (file: File) => Promise; handleSaveAiWashConfig: AsyncAction; + handleSaveCanvasLocalEditConfig: AsyncAction; handleSaveExtendConfig: AsyncAction; handleSaveWatermarkRemoveConfig: AsyncAction; handleSaveAngleEditConfig: AsyncAction; diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 6802c4fa7..bab8ac724 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -192,6 +192,11 @@ import { MIN_CANVAS_IMAGE_TASK_CONCURRENCY, normalizeCanvasImageTaskQueueConfig, } from "@/lib/canvas/image-task-config"; +import { + CANVAS_LOCAL_EDIT_CONFIG_KEY, + defaultCanvasLocalEditConfig, + normalizeCanvasLocalEditConfig, +} from "@/lib/canvas/local-edit-config"; import { type CanvasSkillTag, DEFAULT_CANVAS_SKILL_TAGS, @@ -1857,6 +1862,13 @@ export function AdminSettingsClient() { const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{ setting?: { value?: unknown }; }>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher); + const { + data: canvasLocalEditConfigData, + mutate: mutateCanvasLocalEditConfig, + } = useSWR<{ setting?: { value?: unknown } }>( + `/api/admin/settings?key=${CANVAS_LOCAL_EDIT_CONFIG_KEY}`, + fetcher + ); const { data: canvasImageTaskQueueConfigData, mutate: mutateCanvasImageTaskQueueConfig, @@ -2563,6 +2575,9 @@ export function AdminSettingsClient() { const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] = useState(""); const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig()); + const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState( + defaultCanvasLocalEditConfig() + ); // 图像拓展API配置状态 const [extendConfig, setExtendConfig] = useState({ @@ -2804,6 +2819,13 @@ export function AdminSettingsClient() { setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value)); }, [aiWashConfigData]); + useEffect(() => { + if (!canvasLocalEditConfigData) return; + setCanvasLocalEditConfig( + normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value) + ); + }, [canvasLocalEditConfigData]); + const enhanceProApiKeysDraftCount = useMemo( () => normalizeEnhanceProKreaConfig({ @@ -5189,6 +5211,34 @@ export function AdminSettingsClient() { } }; + const handleSaveCanvasLocalEditConfig = async () => { + try { + const normalizedConfig = + normalizeCanvasLocalEditConfig(canvasLocalEditConfig); + const response = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: CANVAS_LOCAL_EDIT_CONFIG_KEY, + value: normalizedConfig, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + String(payload?.message || payload?.error || "保存失败") + ); + } + + mutateCanvasLocalEditConfig(); + toast.success("局部编辑模型配置保存成功"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "保存局部编辑模型配置失败" + ); + } + }; + const handleDeleteEnhanceProPoolKey = (index: number) => { setEnhanceProConfig((prev) => ({ ...prev, @@ -18429,6 +18479,7 @@ export function AdminSettingsClient() { angleEditConfig={angleEditConfig} aiWashConfig={aiWashConfig} aiWashModeIconUploading={aiWashModeIconUploading} + canvasLocalEditConfig={canvasLocalEditConfig} draftEnhanceProKeyRows={draftEnhanceProKeyRows} draggingEnhanceProPresetId={draggingEnhanceProPresetId} enhanceConfig={enhanceConfig} @@ -18449,6 +18500,7 @@ export function AdminSettingsClient() { handleResetEnhanceProPresets={handleResetEnhanceProPresets} handleSaveAngleEditConfig={handleSaveAngleEditConfig} handleSaveAiWashConfig={handleSaveAiWashConfig} + handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig} handleSaveEnhanceConfig={handleSaveEnhanceConfig} handleSaveEnhanceProConfig={handleSaveEnhanceProConfig} handleSaveExtendConfig={handleSaveExtendConfig} @@ -18470,6 +18522,7 @@ export function AdminSettingsClient() { selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex} setAngleEditConfig={setAngleEditConfig} setAiWashConfig={setAiWashConfig} + setCanvasLocalEditConfig={setCanvasLocalEditConfig} setEnhanceConfig={setEnhanceConfig} setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft} setEnhanceProConfig={setEnhanceProConfig} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index f25f19ee6..ec0665427 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -51,6 +51,10 @@ import { CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, normalizeCanvasImageTaskQueueConfig, } from "@/lib/canvas/image-task-config"; +import { + CANVAS_LOCAL_EDIT_CONFIG_KEY, + normalizeCanvasLocalEditConfig, +} from "@/lib/canvas/local-edit-config"; import { DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, normalizeDingTalkRobotNotificationConfig, @@ -124,6 +128,12 @@ export async function GET(request: Request) { setting: setting ? { ...setting, value } : { key, value }, }); } + if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) { + const value = normalizeCanvasLocalEditConfig(setting?.value); + return Response.json({ + setting: setting ? { ...setting, value } : { key, value }, + }); + } if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { const value = normalizeDingTalkRobotNotificationConfig(setting?.value); return Response.json({ @@ -359,6 +369,10 @@ export async function POST(request: Request) { nextValue = normalizeCanvasImageTaskQueueConfig(value); } + if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) { + nextValue = normalizeCanvasLocalEditConfig(value); + } + if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { nextValue = normalizeDingTalkRobotNotificationConfig(value); } diff --git a/app/api/ai-wash/generate/route.ts b/app/api/ai-wash/generate/route.ts index e7d6f13e7..eab20dd6a 100644 --- a/app/api/ai-wash/generate/route.ts +++ b/app/api/ai-wash/generate/route.ts @@ -13,6 +13,7 @@ import { findStyleProImageModelByRef, getStyleProImageModelEntries, } from "@/lib/style-pro/model-options"; +import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability"; const requestSchema = z.object({ prompt: z.string().min(1).max(5000), @@ -52,6 +53,16 @@ export async function POST(request: Request) { { status: 400 } ); } + const availability = getAiModelFrontendAvailability(selectedModel.model); + if (availability.selectable === false) { + return NextResponse.json( + { + success: false, + error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用", + }, + { status: 400 } + ); + } const imageSize = body.imageSize?.trim() || "2K"; const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", { diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 868acc9a7..f453c25d0 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -16,7 +16,12 @@ import { getEnabledAiModelsWithProvider, getPointsSystemConfig, getSiteSettings, + getSystemSetting, } from "@/lib/db/queries"; +import { + CANVAS_LOCAL_EDIT_CONFIG_KEY, + normalizeCanvasLocalEditConfig, +} from "@/lib/canvas/local-edit-config"; import { modelLacksReferenceImageInputSupport, } from "@/lib/ai/reference-image-capability"; @@ -4466,10 +4471,17 @@ export async function POST(request: NextRequest) { const shouldUseScaleAdjustDetailModel = operation === SCALE_ADJUST_OPERATION && (hasScaleAdjustTargetRects || scalePercent !== null); - const [pointsConfig, siteSettingsForModel, scaleAdjustModelCandidates] = - await Promise.all([ + const [ + pointsConfig, + siteSettingsForModel, + localEditConfigSetting, + scaleAdjustModelCandidates, + ] = await Promise.all([ getPointsSystemConfig(), getSiteSettings().catch(() => null), + getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch( + () => null + ), shouldUseScaleAdjustDetailModel ? getEnabledAiModelsWithProvider(false, true).catch((error) => { console.warn( @@ -4483,10 +4495,18 @@ export async function POST(request: NextRequest) { const pointsCharge = await prepareFeaturePointsCharge({ userId: session.user.id, unitCost: getImageToolCreditCost(pointsConfig, "repaint"), - reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint", + reason: + operation === SCALE_ADJUST_OPERATION + ? "image_scale_adjust" + : "image_repaint", modelId: - operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit", - note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分", + operation === SCALE_ADJUST_OPERATION + ? "canvas-scale-adjust" + : "canvas-local-edit", + note: + operation === SCALE_ADJUST_OPERATION + ? "比例调整消耗积分" + : "局部编辑消耗积分", pointsConfig, }); if (!pointsCharge.ok) { @@ -4518,13 +4538,19 @@ export async function POST(request: NextRequest) { typeof body.model === "string" && body.model.trim() ? body.model.trim() : undefined; + const localEditConfig = normalizeCanvasLocalEditConfig( + localEditConfigSetting?.value + ); const configuredDetailModel = typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string" ? siteSettingsForModel.canvasDetailDefaultModelId.trim() : ""; + const configuredLocalEditModel = localEditConfig.modelId; + const configuredScaleAdjustModel = + localEditConfig.scaleAdjustModelId || configuredDetailModel; const detailFixedModel = isScaleAdjustOperation ? resolveScaleAdjustPreferredModelId({ - configuredModelId: configuredDetailModel, + configuredModelId: configuredScaleAdjustModel, honorRequestedModelBeforeGpt: hasScaleAdjustEnlarge, preferFalNanoBananaEdit: preferScaleAdjustFalNanoBananaEdit, preferMaskCapableGpt: preferScaleAdjustMaskCapableModel, @@ -4536,15 +4562,17 @@ export async function POST(request: NextRequest) { if ( isScaleAdjustOperation && detailFixedModel && - detailFixedModel !== configuredDetailModel + detailFixedModel !== configuredScaleAdjustModel ) { console.info("[Canvas Local Edit] Override scale adjust model to detail line:", { configuredDetailModel: configuredDetailModel || null, + configuredLocalEditScaleModel: localEditConfig.scaleAdjustModelId || null, requestedModel: requestedModel || null, resolvedModel: detailFixedModel, }); } - const resolvedModel = detailFixedModel || requestedModel; + const resolvedModel = + detailFixedModel || requestedModel || configuredLocalEditModel; const resolvedScaleAdjustModelEntry = isScaleAdjustOperation ? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "") : null; diff --git a/components/canvas/AiWashWorkspace.tsx b/components/canvas/AiWashWorkspace.tsx index 10690b970..07526d58a 100644 --- a/components/canvas/AiWashWorkspace.tsx +++ b/components/canvas/AiWashWorkspace.tsx @@ -610,7 +610,9 @@ export function AiWashWorkspace({ [presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId] ); const selectedPresetHasExecutableModel = Boolean( - selectedExecutionState?.effectiveModelRef + selectedExecutionState?.effectiveModelRef && + selectedExecutionState?.resolvedModel && + selectedExecutionState.resolvedModel.selectable !== false ); const shouldShowUserModelSelector = Boolean( !config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel @@ -757,7 +759,10 @@ export function AiWashWorkspace({ return; } if (!selectedPresetHasExecutableModel) { - toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"); + toast.error( + selectedExecutionState?.resolvedModel?.disabledReason || + "当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型" + ); return; } @@ -818,6 +823,15 @@ export function AiWashWorkspace({ if (!executionState.effectiveModelRef) { throw new Error(`${preset.title} 未配置可用模型`); } + if ( + !executionState.resolvedModel || + executionState.resolvedModel.selectable === false + ) { + throw new Error( + executionState.resolvedModel?.disabledReason || + `${preset.title} 配置的模型不可用` + ); + } const submitResponse = await fetch("/api/ai-wash/generate", { method: "POST", @@ -825,7 +839,9 @@ export function AiWashWorkspace({ body: JSON.stringify({ prompt: executionState.resolvedPrompt, referenceImage: requestReferenceImage, - selectedModelId: executionState.effectiveModelRef, + selectedModelId: + executionState.resolvedModel.id || + executionState.effectiveModelRef, imageSize: config.imageSize, promptExtend: config.promptExtend, title: preset.title, diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index 73fb8960a..bb5465065 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -278,6 +278,10 @@ function configureMaterialTextures( geometry.setAttribute("uv2", geometry.attributes.uv); } + if (geometry?.attributes.color && "vertexColors" in materialRecord) { + materialRecord.vertexColors = true; + } + for (const key of MATERIAL_TEXTURE_KEYS) { const texture = materialRecord[key]; if (!texture?.isTexture) continue; @@ -287,6 +291,7 @@ function configureMaterialTextures( ? THREE.SRGBColorSpace : THREE.NoColorSpace; } + texture.anisotropy = Math.max(Number(texture.anisotropy || 1), 8); texture.needsUpdate = true; } @@ -652,7 +657,7 @@ export function Model3DViewer({ const resolvedModelUrl = useMemo(() => { return buildThreeAssetUrl(modelUrl); }, [modelUrl]); - const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01; + const gridY = Number.isFinite(groundY) ? groundY - 0.08 : -0.08; const handleGroundYChange = React.useCallback((nextGroundY: number) => { setGroundY((previousGroundY) => Math.abs(previousGroundY - nextGroundY) < 0.001 diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index f1340ec0b..be122410e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1801,7 +1801,8 @@ export function ImageNode({ : "", [aiWashQuickLevels, selectedAiWashQuickPreset] ); - const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef; + const shouldShowAiWashQuickModelSelector = + !aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef; const effectiveAiWashQuickModelRef = aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim(); const selectedAiWashQuickModel = useMemo( @@ -1811,6 +1812,11 @@ export function ImageNode({ null, [aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef] ); + const aiWashQuickHasExecutableModel = Boolean( + effectiveAiWashQuickModelRef && + selectedAiWashQuickModel && + selectedAiWashQuickModel.selectable !== false + ); const aiWashQuickPrompt = useMemo( () => selectedAiWashQuickPreset @@ -2271,7 +2277,7 @@ export function ImageNode({ pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true, }); const aiWashQuickCostShortLabel = - pointsEnabled && selectedAiWashQuickModel + pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel ? formatImageNodeFeatureCostShortLabel({ cost: selectedAiWashQuickModel.unitCost, isHighVip, @@ -6471,8 +6477,11 @@ export function ImageNode({ toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置"); return; } - if (!effectiveAiWashQuickModelRef) { - toast.error("请先选择一个可用的生图模型"); + if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) { + toast.error( + selectedAiWashQuickModel?.disabledReason || + "当前 AI洗图模型不可用,请在后台重新配置模型" + ); return; } if (isProcessing || isSubmittingAiWashQuick) { @@ -6528,7 +6537,7 @@ export function ImageNode({ body: JSON.stringify({ prompt: aiWashQuickPrompt, referenceImage: data.src, - selectedModelId: effectiveAiWashQuickModelRef, + selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef, imageSize: aiWashQuickConfig.imageSize, promptExtend: aiWashQuickConfig.promptExtend, title: selectedAiWashQuickPreset.title, @@ -6625,6 +6634,7 @@ export function ImageNode({ }, [ addNode, aiWashQuickConfig.enabled, + aiWashQuickConfig.hideModelSelector, aiWashQuickConfig.imageSize, aiWashQuickConfig.promptExtend, aiWashQuickLevels, @@ -6632,6 +6642,7 @@ export function ImageNode({ aiWashQuickPrompt, data.src, displayTitle, + aiWashQuickHasExecutableModel, effectiveAiWashQuickModelRef, isProcessing, isSubmittingAiWashQuick, @@ -6639,7 +6650,9 @@ export function ImageNode({ resetBoxCutoutState, resetLocalEditState, selectNode, + selectedAiWashQuickModel?.disabledReason, selectedAiWashQuickModel?.displayName, + selectedAiWashQuickModel?.id, selectedAiWashQuickModel?.modelId, selectedAiWashQuickPreset, updateNode, @@ -10096,13 +10109,6 @@ export function ImageNode({ : "没有可选模型"} - ) : selectedAiWashQuickModel ? ( -
- {selectedAiWashQuickModel.displayName} - {selectedAiWashQuickModel.unitCost > 0 - ? ` · ${selectedAiWashQuickModel.unitCost} 积分/张` - : ""} -
) : null}
@@ -10154,7 +10160,7 @@ export function ImageNode({ disabled={ isSubmittingAiWashQuick || !selectedAiWashQuickPreset || - !effectiveAiWashQuickModelRef + !aiWashQuickHasExecutableModel } onClick={() => void handleSubmitAiWashQuick()} type="button" @@ -10167,9 +10173,10 @@ export function ImageNode({ {isSubmittingAiWashQuick ? "生成中" : `${aiWashQuickModeLabel}${ + !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel?.unitCost ? ` · ${selectedAiWashQuickModel.unitCost} 积分` - : "" + : "" }`}
diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index d099421fc..062794e6c 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -117,7 +117,7 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0]; const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1); const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08; -const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22; +const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.45; const EMPTY_MOVEMENT_STATE: MovementState = { forward: false, @@ -1494,6 +1494,7 @@ function WorldSplatScene({ semanticsMetadata?.ground_plane_offset ); const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset); + const shouldShowGroundHelper = groundHelperOffset !== null && !firstPerson; const handleOrbitControlsRef = React.useCallback((controls: any | null) => { orbitControlsRef.current = controls; setControlsMountVersion((version) => version + 1); @@ -1779,7 +1780,7 @@ function WorldSplatScene({ <> - {groundHelperOffset !== null ? ( + {shouldShowGroundHelper ? ( ; nextBatchTimers: Map>; awaitingNextBatch: Set; + lastStaleRunningCleanupAt: number; draining: boolean; } 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 STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000; const PENDING_LOOKAHEAD_LIMIT = 100; const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500; const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120; @@ -81,6 +83,7 @@ function getQueueState(): QueueState { nextBatchEligibleAt: new Map(), nextBatchTimers: new Map>(), awaitingNextBatch: new Set(), + lastStaleRunningCleanupAt: 0, draining: false, }; } @@ -393,6 +396,48 @@ async function claimNextPendingTask( return null; } +async function markStaleDatabaseRunningTasksFailed() { + const state = getQueueState(); + const now = Date.now(); + if (now - state.lastStaleRunningCleanupAt < STALE_RUNNING_CLEANUP_INTERVAL_MS) { + return; + } + state.lastStaleRunningCleanupAt = now; + + const localRunningIds = Array.from(state.running.keys()); + const staleBefore = new Date(now - LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS); + const staleWhere = + localRunningIds.length > 0 + ? and( + eq(canvasImageTask.status, "running"), + lt(canvasImageTask.startedAt, staleBefore), + notInArray(canvasImageTask.id, localRunningIds) + ) + : and( + eq(canvasImageTask.status, "running"), + lt(canvasImageTask.startedAt, staleBefore) + ); + + const completedAt = new Date(now); + const staleTasks = await db + .update(canvasImageTask) + .set({ + status: "failed", + error: "图片生成任务已超时,请重新发起生成", + completedAt, + updatedAt: completedAt, + }) + .where(staleWhere) + .returning({ id: canvasImageTask.id }); + + if (staleTasks.length > 0) { + console.warn("[Canvas Image Task Queue] Marked stale running tasks failed:", { + count: staleTasks.length, + taskIds: staleTasks.slice(0, 10).map((task) => task.id), + }); + } +} + async function runSingleTask(entry: PendingTaskEntry) { try { const [task] = await db @@ -474,6 +519,7 @@ export async function drainCanvasImageTaskQueue() { state.draining = true; try { + await markStaleDatabaseRunningTasksFailed(); const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency(); while (true) { const nextTask = await claimNextPendingTask(maxConcurrentPerModel); diff --git a/lib/canvas/local-edit-config.ts b/lib/canvas/local-edit-config.ts new file mode 100644 index 000000000..bcdf9cb27 --- /dev/null +++ b/lib/canvas/local-edit-config.ts @@ -0,0 +1,32 @@ +export const CANVAS_LOCAL_EDIT_CONFIG_KEY = "canvas_local_edit_config"; + +export type CanvasLocalEditConfig = { + modelId: string; + scaleAdjustModelId: string; +}; + +export function defaultCanvasLocalEditConfig(): CanvasLocalEditConfig { + return { + modelId: "", + scaleAdjustModelId: "", + }; +} + +function normalizeModelRef(value: unknown) { + return String(value || "").trim().slice(0, 128); +} + +export function normalizeCanvasLocalEditConfig( + value: unknown +): CanvasLocalEditConfig { + const defaults = defaultCanvasLocalEditConfig(); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return defaults; + } + + const raw = value as Record; + return { + modelId: normalizeModelRef(raw.modelId), + scaleAdjustModelId: normalizeModelRef(raw.scaleAdjustModelId), + }; +}