feat: refine world snapshots with image generation
This commit is contained in:
parent
c20dc8d3a1
commit
09800c313f
@ -75,6 +75,93 @@ const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000;
|
|||||||
const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60;
|
const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60;
|
||||||
const WORLD_GENERATION_RECOVERY_INTERVAL_MS = 5000;
|
const WORLD_GENERATION_RECOVERY_INTERVAL_MS = 5000;
|
||||||
const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360;
|
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) {
|
function resolveAvailableViews(data: World3DNodeData) {
|
||||||
const views: World3DViewMode[] = [];
|
const views: World3DViewMode[] = [];
|
||||||
@ -824,6 +911,7 @@ export function World3DNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsCapturingSnapshot(true);
|
setIsCapturingSnapshot(true);
|
||||||
|
let resultNodeId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const blob = await viewerCapture();
|
const blob = await viewerCapture();
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
@ -843,8 +931,15 @@ export function World3DNode({
|
|||||||
220,
|
220,
|
||||||
Math.round(snapshotWidth * (node.size.height / Math.max(node.size.width, 1)))
|
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",
|
type: "image",
|
||||||
position: {
|
position: {
|
||||||
x: node.position.x + node.size.width + 24,
|
x: node.position.x + node.size.width + 24,
|
||||||
@ -852,25 +947,97 @@ export function World3DNode({
|
|||||||
},
|
},
|
||||||
size: { width: snapshotWidth, height: snapshotHeight },
|
size: { width: snapshotWidth, height: snapshotHeight },
|
||||||
data: {
|
data: {
|
||||||
src: uploadResult.data.url,
|
src: "",
|
||||||
assetId: uploadResult.data.assetId,
|
title: `${titleBase}-AI实景优化`,
|
||||||
title: `${(data.prompt || "3D世界").trim() || "3D世界"}-拍照`,
|
prompt: refinePrompt,
|
||||||
prompt: data.prompt || "3D 世界拍照",
|
status: "generating",
|
||||||
status: "completed",
|
|
||||||
originalSrc: uploadResult.data.url,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
|
aspectRatio,
|
||||||
|
captureMode: "world3d-ai-refine",
|
||||||
|
sourceSnapshotAssetId: uploadResult.data.assetId,
|
||||||
|
sourceSnapshotUrl,
|
||||||
sourceWorldNodeId: node.id,
|
sourceWorldNodeId: node.id,
|
||||||
|
sourceWorldPrompt,
|
||||||
sourceWorldView: activeView,
|
sourceWorldView: activeView,
|
||||||
sourceWorldPrompt: data.prompt || "",
|
|
||||||
captureMode: "world3d",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
connections: [node.id],
|
connections: [node.id],
|
||||||
});
|
});
|
||||||
selectNode(newNodeId);
|
selectNode(resultNodeId);
|
||||||
toast.success("已将当前世界视角添加到画布");
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "世界拍照失败";
|
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);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCapturingSnapshot(false);
|
setIsCapturingSnapshot(false);
|
||||||
@ -885,6 +1052,7 @@ export function World3DNode({
|
|||||||
node.size.height,
|
node.size.height,
|
||||||
node.size.width,
|
node.size.width,
|
||||||
selectNode,
|
selectNode,
|
||||||
|
updateNode,
|
||||||
viewerCapture,
|
viewerCapture,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -1062,7 +1230,7 @@ export function World3DNode({
|
|||||||
disabled={!viewerCapture || isCapturingSnapshot}
|
disabled={!viewerCapture || isCapturingSnapshot}
|
||||||
onClick={() => void handleCaptureSnapshot()}
|
onClick={() => void handleCaptureSnapshot()}
|
||||||
size="sm"
|
size="sm"
|
||||||
title="拍照"
|
title="AI 实景拍照"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -278,12 +278,23 @@ export const imageGenerationService = {
|
|||||||
negativePrompt?: string;
|
negativePrompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
|
imageSize?: string;
|
||||||
imageCount?: number;
|
imageCount?: number;
|
||||||
promptExtend?: boolean;
|
promptExtend?: boolean;
|
||||||
referenceImage?: string;
|
referenceImage?: string;
|
||||||
referenceImages?: string[];
|
referenceImages?: string[];
|
||||||
billingFeature?: 'multi-view' | 'repaint';
|
billingFeature?: 'multi-view' | 'repaint';
|
||||||
}): Promise<ApiResponse<{ images: Array<{ thumbnailUrl: string; originalUrl: string }>; 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 {
|
try {
|
||||||
const response = await fetch('/api/canvas/generate/image', {
|
const response = await fetch('/api/canvas/generate/image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -291,6 +302,17 @@ export const imageGenerationService = {
|
|||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
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;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: '生成图片失败' };
|
return { success: false, error: '生成图片失败' };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user