fix: recover stalled 3d world tasks
This commit is contained in:
parent
d72ca0b344
commit
06edd31b87
@ -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(
|
||||
{
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user