fix: recover stalled 3d world tasks
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-20 15:40:14 +08:00
parent d72ca0b344
commit 06edd31b87
4 changed files with 341 additions and 1 deletions

View File

@ -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<WorldLabsOperation>(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(
{

View File

@ -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(
{

View File

@ -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<WorldLabsPrepareUploadResponse>(
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(
{

View File

@ -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<string | null>(null);
const generationRecoveryAttemptRef = useRef(0);
const generationRecoveryTaskRef = useRef<string | null>(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<string, any>) => {
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<typeof setTimeout> | 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<string, string> | 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<string, any>)
: 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;