diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 978603df3..057c03d63 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -190,6 +190,48 @@ function assertArchivedAssetBuffer(params: { } } +async function verifyArchivedAssetUpload(params: { + url: string; + expectedBytes: number; + validateAs?: ArchiveValidationKind; +}) { + const headResponse = await fetch(params.url, { + method: "HEAD", + cache: "no-store", + }); + if (!headResponse.ok) { + throw new Error(`归档文件校验失败(HTTP ${headResponse.status})`); + } + + const archivedLength = Number(headResponse.headers.get("content-length")); + if ( + Number.isFinite(archivedLength) && + archivedLength > 0 && + archivedLength !== params.expectedBytes + ) { + throw new Error( + `归档文件大小不一致(源文件 ${params.expectedBytes},归档文件 ${archivedLength})` + ); + } + + if (!params.validateAs) return; + + const rangeResponse = await fetch(params.url, { + method: "GET", + headers: { Range: "bytes=0-63" }, + cache: "no-store", + }); + if (!rangeResponse.ok) { + throw new Error(`归档文件头校验失败(HTTP ${rangeResponse.status})`); + } + const headerBuffer = await rangeResponse.arrayBuffer(); + assertArchivedAssetBuffer({ + buffer: headerBuffer, + contentType: rangeResponse.headers.get("content-type"), + validateAs: params.validateAs, + }); +} + async function archiveRemoteAssetIfNeeded(params: { cacheKey: string; sourceUrl?: string | null; @@ -249,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: { ); const filename = `${params.filenamePrefix}.${ext}`; const uploaded = await uploadFile(filename, assetBuffer); + await verifyArchivedAssetUpload({ + url: uploaded.url, + expectedBytes: assetBuffer.byteLength, + validateAs: params.validateAs, + }); cache.set(params.cacheKey, { sourceUrl, archivedUrl: uploaded.url, @@ -299,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: { ) ).filter((entry): entry is readonly [string, string] => Boolean(entry)); - return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null; + if (archivedEntries.length !== entries.length) return null; + return Object.fromEntries(archivedEntries); } function getStringValue(...values: unknown[]) { @@ -837,6 +885,19 @@ export async function GET(request: Request, { params }: RouteParams) { fallbackExtension: "spz", validateAs: "spz", }); + const sourceSpzEntryCount = Object.keys( + extractedAssetPayload.spzUrls || {} + ).length; + const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length; + const assetsArchivingPending = Boolean( + (rawMeshUrl && !stableModelUrl) || + (rawThumbnailUrl && !stableThumbnailUrl) || + (rawPanoUrl && !stablePanoUrl) || + (sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount) + ); + const storageArchivedAt = assetsArchivingPending + ? null + : new Date().toISOString(); const assetPayload = { ...externalAssetPayloadBase, worldId: extractedAssetPayload.worldId, @@ -852,7 +913,9 @@ export async function GET(request: Request, { params }: RouteParams) { meshUrl: stableModelUrl, spzUrls: stableSpzUrls, semanticsMetadata: extractedAssetPayload.semanticsMetadata, - storageArchivedAt: new Date().toISOString(), + spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null, + storageArchivedAt, + assetsArchivingPending, sourceWorldUrl: extractedAssetPayload.worldUrl || null, sourceMeshUrl: rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl @@ -891,6 +954,7 @@ export async function GET(request: Request, { params }: RouteParams) { hasPano: Boolean(stablePanoUrl), hasThumbnail: Boolean(stableThumbnailUrl), hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), + assetsArchivingPending, }); return NextResponse.json({ @@ -1171,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) { : 0; const hasMissingSpzArchive = rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length; - const storageArchivedAt = - [ - [rawWorldUrl, stableWorldUrl], - [rawMeshUrl, stableModelUrl], - [rawThumbnailUrl, stableThumbnailUrl], - [rawPanoUrl, stablePanoUrl], - ].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) || - Boolean( - stableSpzUrls && - assetPayload.spzUrls && - Object.entries(stableSpzUrls).some( - ([key, value]) => - typeof assetPayload.spzUrls?.[key] === "string" && - assetPayload.spzUrls[key] !== value - ) - ) - ? new Date().toISOString() - : null; + const assetsArchivingPending = Boolean( + (rawMeshUrl && !stableModelUrl) || + (rawThumbnailUrl && !stableThumbnailUrl) || + (rawPanoUrl && !stablePanoUrl) || + hasMissingSpzArchive + ); + const storageArchivedAt = assetsArchivingPending + ? null + : new Date().toISOString(); logger.info("task success status", { elapsedMs: Date.now() - startedAt, @@ -1200,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) { hasThumbnail: Boolean(stableThumbnailUrl), hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), storageArchivedAt, - assetsArchivingPending: Boolean( - (rawMeshUrl && !stableModelUrl) || - hasMissingSpzArchive - ), + assetsArchivingPending, }); const responseAssetPayload = { @@ -1213,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) { panoUrl: stablePanoUrl || assetPayload.panoUrl, meshUrl: stableModelUrl, spzUrls: stableSpzUrls, - spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null, + spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null, storageArchivedAt, - assetsArchivingPending: Boolean( - (rawMeshUrl && !stableModelUrl) || - hasMissingSpzArchive - ), + assetsArchivingPending, sourceWorldUrl: rawWorldUrl || null, sourceMeshUrl: rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 9d6acf874..0264d4adb 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -365,6 +365,62 @@ function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) { ); } +function getTaskPreviewCandidates(task?: TaskRecord | null) { + if (!task) return []; + const candidates = [ + task.previewImageUrl, + task.assetPayload?.thumbnailUrl, + task.assetPayload?.panoUrl, + task.assetPayload?.sourcePreviewImageUrl, + task.assetPayload?.sourceThumbnailUrl, + task.assetPayload?.sourcePanoUrl, + ]; + const seen = new Set(); + return candidates + .map((value) => String(value || "").trim()) + .filter((value) => { + if (!value || seen.has(value)) return false; + seen.add(value); + return true; + }); +} + +function isPersistableTaskPreviewUrl(value?: string | null) { + return /^https?:\/\//i.test(String(value || "").trim()); +} + +function TaskAssetPreview({ task }: { task: TaskRecord }) { + const candidates = getTaskPreviewCandidates(task); + const signature = candidates.join("|"); + const [candidateIndex, setCandidateIndex] = useState(0); + + useEffect(() => { + setCandidateIndex(0); + }, [signature]); + + const previewUrl = candidates[candidateIndex] || null; + if (!previewUrl) { + return ( +
+ {task.status === "queued" || task.status === "running" ? ( + + ) : ( + + )} +
+ ); + } + + return ( + 3D 资产预览 setCandidateIndex((index) => index + 1)} + src={previewUrl} + /> + ); +} + function canImportTaskToCanvas(task?: TaskRecord | null) { if (!task || task.status !== "success") return false; if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task)); @@ -1126,7 +1182,9 @@ export default function CanvasModel3DPage() { modelVersion: task.modelVersion, progress: task.progress ?? null, modelUrl: task.modelUrl ?? null, - previewImageUrl: task.previewImageUrl ?? null, + previewImageUrl: isPersistableTaskPreviewUrl(task.previewImageUrl) + ? task.previewImageUrl + : null, error: task.error ?? null, sourceTaskId: task.sourceTaskId ?? null, exportFormat: task.exportFormat ?? null, @@ -1693,6 +1751,7 @@ export default function CanvasModel3DPage() { sourceTaskId?: string | null; exportFormat?: string | null; assetPayload?: Model3DTaskAssetPayload | null; + previewImageUrl?: string | null; }) => { const normalizedPayload = params.provider === "tripo-3d" && @@ -1747,6 +1806,7 @@ export default function CanvasModel3DPage() { createdAt: Date.now(), sourceTaskId: params.sourceTaskId || null, exportFormat: params.exportFormat || null, + previewImageUrl: params.previewImageUrl || null, assetPayload: params.provider === "worldlabs-3d" ? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null @@ -1862,6 +1922,7 @@ export default function CanvasModel3DPage() { providerId: uploadResult.data.providerId || null, providerName: uploadResult.data.providerName || null, }, + previewImageUrl: referencePreviewUrl, }); toast.success("已提交图生3D任务"); return; @@ -1896,6 +1957,8 @@ export default function CanvasModel3DPage() { providerId: uploadResult.data.providerId || null, providerName: uploadResult.data.providerName || null, }, + previewImageUrl: + batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null, }); successCount += 1; } @@ -1951,6 +2014,7 @@ export default function CanvasModel3DPage() { providerId: boundTripoProviderId, providerName: boundTripoProviderName, }, + previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null, }); toast.success("已提交多视图3D任务"); } catch (error) { @@ -2033,6 +2097,7 @@ export default function CanvasModel3DPage() { mode: "world-image", promptText: normalizedPrompt || "图生世界", modelLabel: worldModel, + previewImageUrl: sourceUrl || worldImagePreviewUrl, }); toast.success("已提交图生世界任务"); return; @@ -2117,6 +2182,10 @@ export default function CanvasModel3DPage() { mode: "world-multi-image", promptText: normalizedPrompt || "多图世界", modelLabel: worldModel, + previewImageUrl: + mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl || + selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl || + null, }); toast.success("已提交多图世界任务"); } catch (error) { @@ -4218,8 +4287,6 @@ export default function CanvasModel3DPage() { const canImportWorld = task.provider === "worldlabs-3d" && canImportTaskToCanvas(task); const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId); - const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl; - return (
handleSelectTask(task)} type="button" > - {previewUrl ? ( - preview - ) : ( -
- -
- )} +
diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index a8759fbbc..91b1b27d3 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -1917,9 +1917,6 @@ function WorldSplatCaptureBridge({ function WorldSplatViewer({ splatUrl, splatUrls, - fallbackMeshUrl, - fallbackMeshSettings, - fallbackMeshLayerVisibility, worldUrl, previewImageUrl, firstPerson, @@ -1927,8 +1924,6 @@ function WorldSplatViewer({ interactionMode, onPick, onHotspotSelect, - onFallbackMeshLayersChange, - onFallbackMeshStatsChange, semanticsMetadata, resetViewToken, minimalFrame = false, @@ -1938,9 +1933,6 @@ function WorldSplatViewer({ }: { splatUrl?: string | null; splatUrls?: string[] | null; - fallbackMeshUrl?: string | null; - fallbackMeshSettings?: Model3DViewerSettings; - fallbackMeshLayerVisibility?: Record; worldUrl?: string | null; previewImageUrl?: string | null; minimalFrame?: boolean; @@ -1949,8 +1941,6 @@ function WorldSplatViewer({ interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; - onFallbackMeshLayersChange?: (layers: Model3DLayer[]) => void; - onFallbackMeshStatsChange?: (stats: Model3DStats) => void; semanticsMetadata?: Record | null; resetViewToken?: number; onCaptureReady?: (capture: (() => Promise) | null) => void; @@ -1967,7 +1957,6 @@ function WorldSplatViewer({ const [prefetchedSplat, setPrefetchedSplat] = useState( null ); - const [useMeshFallback, setUseMeshFallback] = useState(false); const resolvedSplatUrls = useMemo(() => { const rawUrls = [ splatUrl, @@ -1985,16 +1974,13 @@ function WorldSplatViewer({ }, [splatUrl, splatUrls]); const resolvedSplatSignature = resolvedSplatUrls.join("|"); const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || ""; - const resolvedFallbackMeshUrl = String(fallbackMeshUrl || "").trim() || null; - const hasMeshFallback = Boolean(resolvedFallbackMeshUrl); const loadStartedAtRef = useRef(0); const movementStateRef = useRef(createMovementState()); useEffect(() => { setActiveSplatIndex(0); setFailedSplatLoads([]); - setUseMeshFallback(false); - }, [resolvedFallbackMeshUrl, resolvedSplatSignature]); + }, [resolvedSplatSignature]); useEffect(() => { if (!resolvedSplatUrl) return; @@ -2047,10 +2033,6 @@ function WorldSplatViewer({ const failedUrl = resolvedSplatUrl; const nextIndex = activeSplatIndex + 1; const nextUrl = resolvedSplatUrls[nextIndex] || ""; - const shouldUseMeshFallback = - hasMeshFallback && - (message.includes("SPZ 加载超时") || !nextUrl); - if (failedUrl) { setFailedSplatLoads((items) => [ ...items, @@ -2058,20 +2040,6 @@ function WorldSplatViewer({ ]); } - if (shouldUseMeshFallback) { - console.warn("[WorldSplatViewer] Switching to mesh fallback", { - error: message, - failedUrl, - attemptedUrls: resolvedSplatUrls.slice(0, nextIndex), - fallbackMeshUrl: resolvedFallbackMeshUrl, - }); - setPrefetchedSplat(null); - setLoadProgress(null); - setOverlayPhase("hidden"); - setUseMeshFallback(true); - return; - } - if (nextUrl) { console.warn("[WorldSplatViewer] Falling back to next SPZ asset", { error: message, @@ -2096,15 +2064,13 @@ function WorldSplatViewer({ }, [ activeSplatIndex, - hasMeshFallback, - resolvedFallbackMeshUrl, resolvedSplatUrl, resolvedSplatUrls, ] ); useEffect(() => { - if (!resolvedSplatUrl || useMeshFallback) return; + if (!resolvedSplatUrl) return; const abortController = new AbortController(); let cancelled = false; @@ -2141,25 +2107,7 @@ function WorldSplatViewer({ globalThis.clearTimeout(timeoutId); abortController.abort(); }; - }, [handleSplatLoadStateChange, resolvedSplatUrl, useMeshFallback]); - - if ((useMeshFallback || !resolvedSplatUrl) && resolvedFallbackMeshUrl) { - return ( - - ); - } + }, [handleSplatLoadStateChange, resolvedSplatUrl]); if (!resolvedSplatUrl) { return ( @@ -2375,13 +2323,8 @@ export function World3DViewer({ unknown; +}; +const originalLoad = moduleLoader._load; +moduleLoader._load = function patchedServerOnlyLoad( + this: typeof moduleLoader, + request, + parent, + isMain +) { + if (request === "server-only") return {}; + return originalLoad.call(this, request, parent, isMain); +}; + +type AssetKind = "glb" | "image" | "spz"; +type StringRecord = Record; +type JsonRecord = Record; + +const dryRun = process.argv.includes("--dry-run"); +const worldOnly = process.argv.includes("--world-only"); +const tripoOnly = process.argv.includes("--tripo-only"); +const limitArg = process.argv.find((arg) => arg.startsWith("--limit=")); +const limit = limitArg ? Math.max(1, Number(limitArg.split("=")[1]) || 1) : null; +const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000; + +function normalizeUrl(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function isHttpUrl(value: string) { + return /^https?:\/\//i.test(value); +} + +function hasGlbMagic(bytes: Uint8Array) { + return ( + bytes.length >= 12 && + bytes[0] === 0x67 && + bytes[1] === 0x6c && + bytes[2] === 0x54 && + bytes[3] === 0x46 + ); +} + +function hasSpzMagic(bytes: Uint8Array) { + return ( + (bytes.length >= 4 && + bytes[0] === 0x4e && + bytes[1] === 0x47 && + bytes[2] === 0x53 && + bytes[3] === 0x50) || + (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) + ); +} + +function looksLikeErrorPayload(bytes: Uint8Array) { + const head = new TextDecoder("utf-8", { fatal: false }) + .decode(bytes.slice(0, Math.min(bytes.length, 64))) + .trimStart(); + return head.startsWith("<") || head.startsWith("{") || head.startsWith("["); +} + +function validateBytes(bytes: Uint8Array, kind: AssetKind) { + if (bytes.byteLength <= 0 || looksLikeErrorPayload(bytes)) return false; + if (kind === "glb") return hasGlbMagic(bytes); + if (kind === "spz") return hasSpzMagic(bytes); + return true; +} + +async function probeAsset(url: string, kind: AssetKind) { + if (!isHttpUrl(url)) return false; + try { + const response = await fetch(url, { + method: "GET", + headers: { Range: "bytes=0-63" }, + cache: "no-store", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) return false; + const bytes = new Uint8Array(await response.arrayBuffer()); + return validateBytes(bytes, kind); + } catch { + return false; + } +} + +async function downloadAsset(url: string, kind: AssetKind) { + const response = await fetch(url, { + method: "GET", + headers: { "User-Agent": "AI-Chatbot-3D-Qiniu-Repair/1.0" }, + cache: "no-store", + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`下载失败(HTTP ${response.status})`); + } + const buffer = await response.arrayBuffer(); + if (!validateBytes(new Uint8Array(buffer), kind)) { + throw new Error(`${kind.toUpperCase()} 文件内容无效`); + } + return { + buffer, + contentType: response.headers.get("content-type"), + }; +} + +function resolveExtension(url: string, kind: AssetKind, contentType?: string | null) { + if (kind === "glb") return "glb"; + if (kind === "spz") return "spz"; + const type = String(contentType || "").toLowerCase(); + if (type.includes("webp")) return "webp"; + if (type.includes("png")) return "png"; + if (type.includes("gif")) return "gif"; + try { + const ext = new URL(url).pathname.split(".").pop()?.toLowerCase(); + if (ext && ["jpg", "jpeg", "png", "webp", "gif"].includes(ext)) return ext; + } catch { + // ignore + } + return "jpg"; +} + +function replaceUrlsDeep(value: unknown, replacements: Map): unknown { + if (typeof value === "string") return replacements.get(value) || value; + if (Array.isArray(value)) { + return value.map((item) => replaceUrlsDeep(item, replacements)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as JsonRecord).map(([key, item]) => [ + key, + replaceUrlsDeep(item, replacements), + ]) + ); + } + return value; +} + +function toStringRecord(value: unknown): StringRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value as JsonRecord) + .map(([key, item]) => [key, normalizeUrl(item)] as const) + .filter(([, item]) => isHttpUrl(item)) + ); +} + +async function main() { + const [dbModule, schemaModule, storageModule, tripoClient, tripoServer] = + await Promise.all([ + import("../../lib/db"), + import("../../lib/db/schema"), + import("../../lib/storage/service"), + import("../../lib/tripo/client"), + import("../../lib/tripo/server"), + ]); + const { db } = dbModule; + const { canvasNode, model3dAsset, storageConfig } = schemaModule; + const { toDbS3UploadConfig, uploadFileWithS3Config } = storageModule; + + const qiniuRows = await db + .select() + .from(storageConfig) + .where(eq(storageConfig.type, "qiniu-kodo")) + .orderBy(desc(storageConfig.isDefault), desc(storageConfig.updatedAt)); + const qiniuRow = qiniuRows.find((item) => item.isEnabled); + const qiniuConfig = toDbS3UploadConfig(qiniuRow); + if (!qiniuConfig || qiniuConfig.provider !== "qiniu-kodo") { + throw new Error("未找到已启用的七牛云 Kodo 配置,已拒绝使用其他存储"); + } + + let assets = await db + .select() + .from(model3dAsset) + .where(eq(model3dAsset.status, "success")) + .orderBy(desc(model3dAsset.createdAt)); + if (worldOnly) assets = assets.filter((asset) => asset.provider === "worldlabs-3d"); + if (tripoOnly) assets = assets.filter((asset) => asset.provider === "tripo-3d"); + if (limit) assets = assets.slice(0, limit); + + const backupPath = path.join( + process.cwd(), + "backups", + `model3d-assets-before-qiniu-repair-${new Date() + .toISOString() + .replace(/[:.]/g, "-")}.json` + ); + if (!dryRun) { + await mkdir(path.dirname(backupPath), { recursive: true }); + await writeFile(backupPath, JSON.stringify(assets, null, 2)); + console.log(`[repair-3d] backup=${backupPath}`); + } + + const summary = { + scanned: 0, + repairedAssets: 0, + repairedFiles: 0, + failedFiles: 0, + updatedNodes: 0, + }; + + for (const asset of assets) { + summary.scanned += 1; + const payload = + asset.assetPayload && typeof asset.assetPayload === "object" + ? ({ ...asset.assetPayload } as JsonRecord) + : {}; + const replacements = new Map(); + let nextModelUrl = normalizeUrl(asset.modelUrl) || null; + let nextPreviewUrl = normalizeUrl(asset.previewImageUrl) || null; + let freshTripoOutput: Record | null = null; + + const uploadRecoveredAsset = async (params: { + currentUrl?: string | null; + sourceUrl?: string | null; + kind: AssetKind; + label: string; + }) => { + const currentUrl = normalizeUrl(params.currentUrl); + if (currentUrl && (await probeAsset(currentUrl, params.kind))) return currentUrl; + + let sourceUrl = normalizeUrl(params.sourceUrl); + if ( + asset.provider === "tripo-3d" && + (!sourceUrl || !(await probeAsset(sourceUrl, params.kind))) + ) { + if (!freshTripoOutput) { + const preferredProviderId = normalizeUrl(payload.providerId); + const provider = await tripoServer.resolveTripoProviderConfigForTaskId( + asset.taskId, + preferredProviderId + ); + const task = await tripoClient.tripoRequest<{ + output?: Record; + }>(provider, { + method: "GET", + path: `/task/${encodeURIComponent(asset.taskId)}`, + }); + freshTripoOutput = task.data.output || {}; + } + sourceUrl = + params.kind === "glb" + ? normalizeUrl(tripoClient.pickTaskModelUrl(freshTripoOutput)) + : normalizeUrl(tripoClient.pickTaskPreviewImageUrl(freshTripoOutput)); + } + + if (!sourceUrl || !(await probeAsset(sourceUrl, params.kind))) { + throw new Error(`${params.label} 没有可恢复的源文件`); + } + if (dryRun) return sourceUrl; + + const downloaded = await downloadAsset(sourceUrl, params.kind); + const extension = resolveExtension( + sourceUrl, + params.kind, + downloaded.contentType + ); + const uploaded = await uploadFileWithS3Config( + `repair-${asset.taskId}-${params.label}.${extension}`, + downloaded.buffer, + qiniuConfig + ); + if (!(await probeAsset(uploaded.url, params.kind))) { + throw new Error(`${params.label} 上传七牛云后校验失败`); + } + if (currentUrl) replacements.set(currentUrl, uploaded.url); + if (sourceUrl) replacements.set(sourceUrl, uploaded.url); + summary.repairedFiles += 1; + console.log( + `[repair-3d] repaired task=${asset.taskId} file=${params.label} bytes=${downloaded.buffer.byteLength}` + ); + return uploaded.url; + }; + + try { + if (asset.provider === "worldlabs-3d") { + nextModelUrl = await uploadRecoveredAsset({ + currentUrl: nextModelUrl, + sourceUrl: normalizeUrl(payload.sourceMeshUrl), + kind: "glb", + label: "mesh", + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} mesh: ${String(error)}`); + return nextModelUrl; + }); + + const nextThumbnailUrl = await uploadRecoveredAsset({ + currentUrl: normalizeUrl(payload.thumbnailUrl), + sourceUrl: normalizeUrl(payload.sourceThumbnailUrl), + kind: "image", + label: "thumbnail", + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} thumbnail: ${String(error)}`); + return normalizeUrl(payload.thumbnailUrl) || null; + }); + const nextPanoUrl = await uploadRecoveredAsset({ + currentUrl: normalizeUrl(payload.panoUrl), + sourceUrl: normalizeUrl(payload.sourcePanoUrl), + kind: "image", + label: "pano", + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} pano: ${String(error)}`); + return normalizeUrl(payload.panoUrl) || null; + }); + + const currentSpzUrls = toStringRecord(payload.spzUrls); + const sourceSpzUrls = toStringRecord(payload.sourceSpzUrls); + const nextSpzUrls: StringRecord = {}; + for (const [key, sourceUrl] of Object.entries(sourceSpzUrls)) { + const repairedUrl = await uploadRecoveredAsset({ + currentUrl: currentSpzUrls[key], + sourceUrl, + kind: "spz", + label: `spz-${key}`, + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} spz-${key}: ${String(error)}`); + return currentSpzUrls[key] || null; + }); + if (repairedUrl) nextSpzUrls[key] = repairedUrl; + } + for (const [key, currentUrl] of Object.entries(currentSpzUrls)) { + if (!nextSpzUrls[key] && (await probeAsset(currentUrl, "spz"))) { + nextSpzUrls[key] = currentUrl; + } + } + + payload.meshUrl = nextModelUrl; + payload.thumbnailUrl = nextThumbnailUrl; + payload.panoUrl = nextPanoUrl; + payload.spzUrls = nextSpzUrls; + nextPreviewUrl = nextThumbnailUrl || nextPanoUrl || nextPreviewUrl; + const allSpzRecovered = + Object.keys(sourceSpzUrls).length > 0 && + Object.keys(nextSpzUrls).length === Object.keys(sourceSpzUrls).length; + payload.assetsArchivingPending = !allSpzRecovered; + payload.storageArchivedAt = allSpzRecovered ? new Date().toISOString() : null; + payload.spzArchiveValidatedAt = allSpzRecovered + ? new Date().toISOString() + : null; + } else if (asset.provider === "tripo-3d") { + nextModelUrl = await uploadRecoveredAsset({ + currentUrl: nextModelUrl, + sourceUrl: normalizeUrl(payload.sourceModelUrl), + kind: "glb", + label: "model", + }); + nextPreviewUrl = await uploadRecoveredAsset({ + currentUrl: nextPreviewUrl, + sourceUrl: normalizeUrl(payload.sourcePreviewImageUrl), + kind: "image", + label: "preview", + }); + payload.storageArchivedAt = new Date().toISOString(); + payload.assetsArchivingPending = false; + } + } catch (error) { + summary.failedFiles += 1; + console.warn(`[repair-3d] task=${asset.taskId} skipped: ${String(error)}`); + } + + if (replacements.size === 0 || dryRun) continue; + + await db.transaction(async (tx) => { + await tx + .update(model3dAsset) + .set({ + modelUrl: nextModelUrl, + previewImageUrl: nextPreviewUrl, + assetPayload: replaceUrlsDeep(payload, replacements) as JsonRecord, + updatedAt: new Date(), + }) + .where(eq(model3dAsset.id, asset.id)); + + const nodes = await tx + .select({ id: canvasNode.id, data: canvasNode.data }) + .from(canvasNode) + .where(eq(canvasNode.projectId, asset.projectId)); + for (const node of nodes) { + const serialized = JSON.stringify(node.data || {}); + if (!serialized.includes(asset.taskId) && ![...replacements.keys()].some((url) => serialized.includes(url))) { + continue; + } + await tx + .update(canvasNode) + .set({ + data: replaceUrlsDeep(node.data, replacements), + updatedAt: new Date(), + }) + .where(eq(canvasNode.id, node.id)); + summary.updatedNodes += 1; + } + }); + summary.repairedAssets += 1; + } + + console.log(JSON.stringify({ dryRun, summary }, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});