diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 0a9b92da7..84224739d 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -295,6 +295,9 @@ interface RouteParams { } export async function GET(request: Request, { params }: RouteParams) { + const startedAt = Date.now(); + let normalizedTaskIdForLog: string | null = null; + let providerForLog: string | null = null; try { const session = await auth(); if (!session?.user?.id) { @@ -303,12 +306,14 @@ export async function GET(request: Request, { params }: RouteParams) { const { taskId } = await params; const normalizedTaskId = String(taskId || "").trim(); + normalizedTaskIdForLog = normalizedTaskId; if (!normalizedTaskId) { return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } const providerId = new URL(request.url).searchParams.get("providerId"); const config = await getWorldLabsProviderConfig(providerId); + providerForLog = config.providerId || null; const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const operation = await worldLabsRequest(config, { method: "GET", @@ -334,6 +339,12 @@ export async function GET(request: Request, { params }: RouteParams) { } if (operation.error?.message) { + console.warn("[WorldLabsTask] failed status", { + elapsedMs: Date.now() - startedAt, + taskId: normalizedTaskId, + providerId: config.providerId || null, + error: operation.error.message, + }); return NextResponse.json({ success: true, data: { @@ -368,6 +379,12 @@ export async function GET(request: Request, { params }: RouteParams) { } if (!hasUsableWorldAssets(world)) { + console.info("[WorldLabsTask] assets pending", { + elapsedMs: Date.now() - startedAt, + taskId: normalizedTaskId, + providerId: config.providerId || null, + worldId: getStringValue(world.world_id) || worldId || null, + }); return NextResponse.json({ success: true, data: { @@ -458,6 +475,22 @@ export async function GET(request: Request, { params }: RouteParams) { ? new Date().toISOString() : null; + console.info("[WorldLabsTask] success status", { + elapsedMs: Date.now() - startedAt, + taskId: normalizedTaskId, + providerId: config.providerId || null, + worldId: assetPayload.worldId, + hasMesh: Boolean(stableModelUrl), + hasPano: Boolean(stablePanoUrl), + hasThumbnail: Boolean(stableThumbnailUrl), + hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), + storageArchivedAt, + assetsArchivingPending: Boolean( + (rawMeshUrl && !stableModelUrl) || + (assetPayload.spzUrls && !stableSpzUrls) + ), + }); + return NextResponse.json({ success: true, data: { @@ -505,6 +538,12 @@ export async function GET(request: Request, { params }: RouteParams) { }, }); } catch (error) { + console.error("[WorldLabsTask] status failed", { + elapsedMs: Date.now() - startedAt, + taskId: normalizedTaskIdForLog, + providerId: providerForLog, + error: error instanceof Error ? error.message : String(error), + }); if (error instanceof WorldLabsApiError) { return NextResponse.json( { diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts index e4d0cebfb..1e6cff05e 100644 --- a/app/api/canvas/world/tasks/route.ts +++ b/app/api/canvas/world/tasks/route.ts @@ -147,6 +147,10 @@ function isPermanentProviderError(error: unknown) { } export async function POST(request: Request) { + const startedAt = Date.now(); + let modeForLog: string | null = null; + let modelForLog: string | null = null; + let providerForLog: string | null = null; try { const session = await auth(); if (!session?.user?.id) { @@ -155,6 +159,7 @@ export async function POST(request: Request) { const body = (await request.json()) as CreateWorldTaskRequest; const mode = String(body.mode || "").trim() as WorldTaskMode; + modeForLog = mode; if (!["text", "image", "multi-image", "video"].includes(mode)) { return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 }); } @@ -168,6 +173,7 @@ export async function POST(request: Request) { modelAliasConfig.worldModels, resolvedWorldModel || "marble-1.1", ); + modelForLog = resolvedWorldModel || "marble-1.1"; const pointsConfig = await getPointsSystemConfig(); const shouldChargePoints = shouldCharge3DPoints(mode); @@ -237,6 +243,7 @@ export async function POST(request: Request) { }, }); usedProvider = candidate; + providerForLog = candidate.providerId || null; break; } catch (error) { lastError = error; @@ -284,6 +291,16 @@ export async function POST(request: Request) { } } + console.info("[WorldLabsTask] created", { + elapsedMs: Date.now() - startedAt, + taskId, + mode, + model: resolvedWorldModel || "marble-1.1", + providerId: usedProvider.providerId || null, + status: result.done ? "success" : "running", + unitCost, + }); + return NextResponse.json({ success: true, data: { @@ -297,6 +314,13 @@ export async function POST(request: Request) { }, }); } catch (error) { + console.error("[WorldLabsTask] create failed", { + elapsedMs: Date.now() - startedAt, + mode: modeForLog, + model: modelForLog, + providerId: providerForLog, + error: error instanceof Error ? error.message : String(error), + }); if (error instanceof WorldLabsApiError) { return NextResponse.json( { diff --git a/app/api/canvas/world/upload/route.ts b/app/api/canvas/world/upload/route.ts index d607962f0..66385e137 100644 --- a/app/api/canvas/world/upload/route.ts +++ b/app/api/canvas/world/upload/route.ts @@ -117,6 +117,10 @@ function isPermanentProviderError(error: unknown) { } export async function POST(request: Request) { + const startedAt = Date.now(); + let providerForLog: string | null = null; + let kindForLog: string | null = null; + let sizeForLog: number | null = null; try { const session = await auth(); if (!session?.user?.id) { @@ -138,6 +142,8 @@ export async function POST(request: Request) { const isVideo = requestedKind ? requestedKind === "video" : ALLOWED_VIDEO_TYPES.has(file.type); + kindForLog = isImage ? "image" : isVideo ? "video" : requestedKind || null; + sizeForLog = file.size; if (!isImage && !isVideo) { return NextResponse.json( @@ -173,6 +179,7 @@ export async function POST(request: Request) { let lastError: unknown = null; for (const config of candidates) { + providerForLog = config.providerId || null; try { const prepareUpload = await worldLabsRequest( config, @@ -222,6 +229,14 @@ export async function POST(request: Request) { ); } + console.info("[WorldLabsUpload] success", { + elapsedMs: Date.now() - startedAt, + providerId: config.providerId || null, + kind: isImage ? "image" : "video", + size: file.size, + mediaAssetId, + }); + return NextResponse.json({ success: true, data: { @@ -247,6 +262,13 @@ export async function POST(request: Request) { ? lastError : new Error("上传 World Labs 媒体失败"); } catch (error) { + console.error("[WorldLabsUpload] failed", { + elapsedMs: Date.now() - startedAt, + providerId: providerForLog, + kind: kindForLog, + size: sizeForLog, + error: error instanceof Error ? error.message : String(error), + }); if (error instanceof WorldLabsApiError) { return NextResponse.json( { diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index e94396297..d21bee3cc 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -44,7 +44,11 @@ import { type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import { toast } from "sonner"; -import { imageGenerationService, worldLabsGenerationService } from "@/services/canvas"; +import { + canvasService, + imageGenerationService, + worldLabsGenerationService, +} from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; @@ -68,6 +72,8 @@ const VIEW_META: Array<{ 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; function resolveAvailableViews(data: World3DNodeData) { const views: World3DViewMode[] = []; @@ -137,6 +143,12 @@ function readRecordValue(value: unknown) { : {}; } +function isTerminalWorldFailureStatus(status: string) { + return ["failed", "error", "cancelled", "canceled"].includes( + status.trim().toLowerCase() + ); +} + function clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -155,10 +167,14 @@ export function World3DNode({ const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false); const hydrationAttemptRef = useRef(0); const hydrationTaskRef = useRef(null); + const generationRecoveryAttemptRef = useRef(0); + const generationRecoveryTaskRef = useRef(null); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const addNode = useCanvasStore((state) => state.addNode); const selectNode = useCanvasStore((state) => state.selectNode); + const projectId = useCanvasStore((state) => state.projectId); + const getCanvasState = useCanvasStore((state) => state.getCanvasState); const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const world3dFirstPersonItem = getCanvasToolbarItem( canvasToolbarConfig, @@ -195,6 +211,9 @@ export function World3DNode({ const hydrationProviderId = readStringValue(metadataAssetPayload.providerId) || readStringValue(metadata.providerId); + const hydrationProviderName = + readStringValue(metadataAssetPayload.providerName) || + readStringValue(metadata.providerName); const { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; @@ -284,6 +303,242 @@ export function World3DNode({ const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh"); const showTopLeftControls = data.status === "completed"; + const persistCanvasStateSnapshot = useCallback(() => { + if (!projectId || typeof window === "undefined") return; + window.setTimeout(() => { + void canvasService.saveCanvasState(projectId, getCanvasState()); + }, 0); + }, [getCanvasState, projectId]); + + const persistCanvas3DAsset = useCallback( + async (asset: Record) => { + if (!projectId) return; + try { + const response = await fetch(`/api/canvas/${projectId}/model3d-assets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ asset }), + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload?.error || `HTTP ${response.status}`); + } + } catch (error) { + console.error("[World3DNode] 保存画布 3D 世界资产失败:", error); + } + }, + [projectId] + ); + + useEffect(() => { + if (data.status !== "generating") return; + const provider = + readStringValue(metadata.provider) || + readStringValue(metadataAssetPayload.provider); + if (provider && provider !== "worldlabs-3d") return; + if (!hydrationTaskId) return; + + const taskId = hydrationTaskId; + const providerId = hydrationProviderId; + const providerName = hydrationProviderName; + const sourceImageUrl = + readStringValue(metadata.sourceImageUrl) || + readStringValue(data.imageUrl) || + readStringValue(data.previewImage) || + ""; + const promptText = + readStringValue(data.prompt) || readStringValue(metadata.prompt) || ""; + const modelVersion = + readStringValue(data.modelVersion) || + readStringValue(metadata.modelVersion) || + "world-standard"; + let cancelled = false; + let timer: ReturnType | null = null; + + if (generationRecoveryTaskRef.current !== taskId) { + generationRecoveryTaskRef.current = taskId; + generationRecoveryAttemptRef.current = 0; + } + + const failRecoveredTask = async (message: string, progress: number | null) => { + updateNode(node.id, { + data: { + ...data, + imageUrl: data.imageUrl || sourceImageUrl || undefined, + previewImage: data.previewImage || sourceImageUrl || undefined, + prompt: promptText, + modelVersion, + status: "failed", + activeView: "image", + metadata: { + ...metadata, + taskId, + provider: "worldlabs-3d", + mode: "world-image", + sourceImageUrl, + progress, + error: message, + }, + } as World3DNodeData, + }); + await persistCanvas3DAsset({ + taskId, + provider: "worldlabs-3d", + status: "failed", + mode: "world-image", + prompt: promptText, + modelVersion, + progress, + modelUrl: null, + previewImageUrl: sourceImageUrl || data.previewImage || data.imageUrl || null, + error: message, + assetPayload: { + providerId: providerId || null, + providerName: providerName || null, + sourceImageUrl, + }, + createdAt: Date.now(), + }); + persistCanvasStateSnapshot(); + toast.error(message); + }; + + const scheduleNextPoll = () => { + if (cancelled) return; + if (generationRecoveryAttemptRef.current >= WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS) { + void failRecoveredTask("3D 世界任务同步超时,请稍后重试", null); + return; + } + timer = setTimeout(() => { + void pollWorldGeneration(); + }, WORLD_GENERATION_RECOVERY_INTERVAL_MS); + }; + + async function pollWorldGeneration() { + if (cancelled) return; + generationRecoveryAttemptRef.current += 1; + + const result = await worldLabsGenerationService.getTask(taskId, { + providerId, + }); + if (cancelled) return; + + if (!result.success || !result.data) { + scheduleNextPoll(); + return; + } + + const status = String(result.data.status || "unknown"); + const progress = result.data.progress ?? null; + + if (status === "success") { + const assetPayload = readRecordValue(result.data.assetPayload); + const splatUrl = pickBestArchivedWorldLabsSplatUrl( + assetPayload.spzUrls as Record | null | undefined + ); + const panoUrl = readStringValue(assetPayload.panoUrl); + const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl); + const previewImageFromTask = readStringValue(result.data.previewImageUrl); + const rawMeshUrl = + readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl); + const meshUrl = isArchivedWorldLabsAssetUrl(rawMeshUrl) ? rawMeshUrl : null; + const imageUrl = panoUrl || thumbnailUrl || previewImageFromTask || sourceImageUrl; + const hasRenderableWorldAsset = Boolean( + splatUrl || panoUrl || thumbnailUrl || previewImageFromTask || meshUrl + ); + + if (!hasRenderableWorldAsset) { + scheduleNextPoll(); + return; + } + + const previewImageUrl = previewImageFromTask || imageUrl || sourceImageUrl; + const nextAssetPayload = { + ...assetPayload, + providerId: providerId || assetPayload.providerId || null, + providerName: providerName || assetPayload.providerName || null, + sourceImageUrl, + }; + + updateNode(node.id, { + data: { + ...data, + worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl, + imageUrl: imageUrl || data.imageUrl, + meshUrl: meshUrl || undefined, + splatUrl: splatUrl || undefined, + previewImage: previewImageUrl || data.previewImage, + prompt: promptText, + modelVersion, + status: "completed", + activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : "image", + firstPerson: data.firstPerson ?? false, + hotspots: data.hotspots || [], + semanticsMetadata: + assetPayload.semanticsMetadata && + typeof assetPayload.semanticsMetadata === "object" + ? (assetPayload.semanticsMetadata as Record) + : data.semanticsMetadata, + metadata: { + ...metadata, + taskId, + provider: "worldlabs-3d", + mode: "world-image", + sourceImageUrl, + progress: 100, + assetPayload: nextAssetPayload, + recoveredAt: new Date().toISOString(), + }, + } as World3DNodeData, + }); + await persistCanvas3DAsset({ + taskId, + provider: "worldlabs-3d", + status: "success", + mode: "world-image", + prompt: promptText, + modelVersion, + progress: 100, + modelUrl: meshUrl, + previewImageUrl, + error: null, + assetPayload: nextAssetPayload, + createdAt: Date.now(), + }); + persistCanvasStateSnapshot(); + toast.success("3D 世界已生成到画布"); + return; + } + + if (isTerminalWorldFailureStatus(status)) { + await failRecoveredTask(result.data.errorMessage || "3D 世界生成失败", progress); + return; + } + + scheduleNextPoll(); + } + + void pollWorldGeneration(); + + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [ + data, + hydrationProviderId, + hydrationProviderName, + hydrationTaskId, + metadata, + metadataAssetPayload, + node.id, + persistCanvas3DAsset, + persistCanvasStateSnapshot, + updateNode, + ]); + useEffect(() => { if (data.status !== "completed") return; if (renderableSplatUrl || renderableMeshUrl) return;