diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 272ee656c..acc9b6a95 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -75,6 +75,93 @@ const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000; const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60; const WORLD_GENERATION_RECOVERY_INTERVAL_MS = 5000; const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360; +const WORLD_SNAPSHOT_REPAINT_POLL_INTERVAL_MS = 1600; +const WORLD_SNAPSHOT_REPAINT_TIMEOUT_MS = 4 * 60 * 1000; +const WORLD_SNAPSHOT_REPAINT_PROMPT = [ + "以参考图中的 3D 世界拍照为严格构图和视角基础,生成一张真实摄影风格的场景图片。", + "保持原始镜头位置、地形布局、道路/草地/树木/灌木/远景层次、光照方向和空间关系,不要改变场景类型。", + "深度修复 3D Gaussian Splat 或 Mesh 截图中的模糊、破面、孔洞、黑边、拉伸网格、重复纹理、噪点、漂浮碎片和不自然阴影。", + "补充真实世界细节:自然草叶、树干纹理、灌木叶片、道路材质、景深过渡、环境光和合理接触阴影。", + "最终输出清晰、连贯、照片级、可信的真实场景图片。", +].join("\n"); +const WORLD_SNAPSHOT_REPAINT_NEGATIVE_PROMPT = [ + "界面按钮", + "UI", + "文字", + "水印", + "logo", + "准星", + "操作提示", + "3D 破面", + "网格纹理", + "孔洞", + "黑边", + "重复纹理", + "扭曲树木", + "漂浮碎片", + "低清晰度", + "卡通", + "插画", +].join(", "); + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function guessNearestAspectRatio(width: number, height: number) { + const ratio = width > 0 && height > 0 ? width / height : 1; + const candidates = [ + { label: "1:1", value: 1 }, + { label: "4:3", value: 4 / 3 }, + { label: "3:4", value: 3 / 4 }, + { label: "16:9", value: 16 / 9 }, + { label: "9:16", value: 9 / 16 }, + { label: "3:2", value: 3 / 2 }, + { label: "2:3", value: 2 / 3 }, + ]; + + return candidates.reduce((best, candidate) => + Math.abs(candidate.value - ratio) < Math.abs(best.value - ratio) + ? candidate + : best + ).label; +} + +async function waitForWorldSnapshotRepaint(taskId: string) { + const deadline = Date.now() + WORLD_SNAPSHOT_REPAINT_TIMEOUT_MS; + let lastError = ""; + + while (Date.now() < deadline) { + await sleep(WORLD_SNAPSHOT_REPAINT_POLL_INTERVAL_MS); + const taskResult = await imageGenerationService.getImageTask(taskId); + const task = taskResult.data?.task; + const status = String(task?.status || "").toLowerCase(); + + if (!taskResult.success || !task) { + lastError = taskResult.error || "查询 AI 实景优化任务失败"; + continue; + } + + if (status === "succeeded" || status === "completed") { + const image = task.result?.images?.[0]; + const imageUrl = image?.originalUrl || image?.thumbnailUrl || ""; + if (!imageUrl) { + throw new Error("AI 实景优化已完成但未返回图片"); + } + return { + imageUrl, + model: task.result?.model || task.result?.modelRef || undefined, + prompt: task.result?.prompt || undefined, + }; + } + + if (status === "failed") { + throw new Error(String(task.error || "AI 实景优化失败")); + } + } + + throw new Error(lastError || "AI 实景优化超时,请稍后重试"); +} function resolveAvailableViews(data: World3DNodeData) { const views: World3DViewMode[] = []; @@ -824,6 +911,7 @@ export function World3DNode({ } setIsCapturingSnapshot(true); + let resultNodeId: string | null = null; try { const blob = await viewerCapture(); if (!blob) { @@ -843,8 +931,15 @@ export function World3DNode({ 220, Math.round(snapshotWidth * (node.size.height / Math.max(node.size.width, 1))) ); + const sourceSnapshotUrl = uploadResult.data.url; + const titleBase = (data.prompt || "3D世界").trim() || "3D世界"; + const aspectRatio = guessNearestAspectRatio(snapshotWidth, snapshotHeight); + const sourceWorldPrompt = String(data.prompt || "").trim(); + const refinePrompt = sourceWorldPrompt + ? `${WORLD_SNAPSHOT_REPAINT_PROMPT}\n原始世界需求:${sourceWorldPrompt}` + : WORLD_SNAPSHOT_REPAINT_PROMPT; - const newNodeId = addNode({ + resultNodeId = addNode({ type: "image", position: { x: node.position.x + node.size.width + 24, @@ -852,25 +947,97 @@ export function World3DNode({ }, size: { width: snapshotWidth, height: snapshotHeight }, data: { - src: uploadResult.data.url, - assetId: uploadResult.data.assetId, - title: `${(data.prompt || "3D世界").trim() || "3D世界"}-拍照`, - prompt: data.prompt || "3D 世界拍照", - status: "completed", - originalSrc: uploadResult.data.url, + src: "", + title: `${titleBase}-AI实景优化`, + prompt: refinePrompt, + status: "generating", metadata: { + aspectRatio, + captureMode: "world3d-ai-refine", + sourceSnapshotAssetId: uploadResult.data.assetId, + sourceSnapshotUrl, sourceWorldNodeId: node.id, + sourceWorldPrompt, sourceWorldView: activeView, - sourceWorldPrompt: data.prompt || "", - captureMode: "world3d", }, }, connections: [node.id], }); - selectNode(newNodeId); - toast.success("已将当前世界视角添加到画布"); + selectNode(resultNodeId); + toast.success("已开始 AI 优化当前世界视角"); + + const generationResult = await imageGenerationService.generateImage({ + prompt: refinePrompt, + negativePrompt: WORLD_SNAPSHOT_REPAINT_NEGATIVE_PROMPT, + referenceImage: sourceSnapshotUrl, + aspectRatio, + imageSize: "2K", + imageCount: 1, + promptExtend: true, + billingFeature: "repaint", + async: true, + }); + + if (!generationResult.success) { + throw new Error(generationResult.error || "AI 实景优化提交失败"); + } + + const immediateImage = generationResult.data?.images?.[0]; + const immediateUrl = + immediateImage?.originalUrl || immediateImage?.thumbnailUrl || ""; + const finalResult = immediateUrl + ? { + imageUrl: immediateUrl, + model: generationResult.data?.model, + prompt: generationResult.data?.prompt, + } + : generationResult.data?.taskId + ? await waitForWorldSnapshotRepaint(generationResult.data.taskId) + : null; + + if (!finalResult?.imageUrl) { + throw new Error("AI 实景优化未返回图片"); + } + + updateNode(resultNodeId, { + data: { + src: finalResult.imageUrl, + title: `${titleBase}-AI实景`, + prompt: finalResult.prompt || refinePrompt, + status: "completed", + originalSrc: finalResult.imageUrl, + model: finalResult.model, + metadata: { + aspectRatio, + captureMode: "world3d-ai-refine", + sourceSnapshotAssetId: uploadResult.data.assetId, + sourceSnapshotUrl, + sourceWorldNodeId: node.id, + sourceWorldPrompt, + sourceWorldView: activeView, + }, + }, + }); + toast.success("AI 实景优化图片已生成"); } catch (error) { const message = error instanceof Error ? error.message : "世界拍照失败"; + if (resultNodeId) { + updateNode(resultNodeId, { + data: { + src: "", + title: `${(data.prompt || "3D世界").trim() || "3D世界"}-AI实景失败`, + prompt: message, + status: "failed", + metadata: { + captureMode: "world3d-ai-refine", + error: message, + sourceWorldNodeId: node.id, + sourceWorldPrompt: data.prompt || "", + sourceWorldView: activeView, + }, + }, + }); + } toast.error(message); } finally { setIsCapturingSnapshot(false); @@ -885,6 +1052,7 @@ export function World3DNode({ node.size.height, node.size.width, selectNode, + updateNode, viewerCapture, ]); @@ -1062,7 +1230,7 @@ export function World3DNode({ disabled={!viewerCapture || isCapturingSnapshot} onClick={() => void handleCaptureSnapshot()} size="sm" - title="拍照" + title="AI 实景拍照" type="button" variant="ghost" > diff --git a/services/canvas/index.ts b/services/canvas/index.ts index b4ec5ead1..e91c97f2b 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -278,12 +278,23 @@ export const imageGenerationService = { negativePrompt?: string; model?: string; aspectRatio?: string; + imageSize?: string; imageCount?: number; promptExtend?: boolean; referenceImage?: string; referenceImages?: string[]; billingFeature?: 'multi-view' | 'repaint'; - }): Promise; model?: string; prompt?: string }>> { + async?: boolean; + }): Promise< + ApiResponse<{ + images: Array<{ thumbnailUrl: string; originalUrl: string }>; + model?: string; + prompt?: string; + queueStatus?: unknown; + status?: string; + taskId?: string; + }> + > { try { const response = await fetch('/api/canvas/generate/image', { method: 'POST', @@ -291,6 +302,17 @@ export const imageGenerationService = { body: JSON.stringify(params), }); const data = await response.json(); + if (data?.success && data?.taskId) { + return { + success: true, + data: { + images: [], + queueStatus: data.queueStatus, + status: data.status, + taskId: data.taskId, + }, + }; + } return data; } catch (error) { return { success: false, error: '生成图片失败' };