fix(model3d): restore archived world assets and previews
This commit is contained in:
parent
412fa85a81
commit
c39d7260fb
@ -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
|
||||
|
||||
@ -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<string>();
|
||||
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 (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
||||
{task.status === "queued" || task.status === "running" ? (
|
||||
<Loader2 className="h-7 w-7 animate-spin text-yellow-400/70" />
|
||||
) : (
|
||||
<Box className="h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt="3D 资产预览"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
|
||||
onError={() => 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 (
|
||||
<div
|
||||
className={cn(
|
||||
@ -4236,17 +4303,7 @@ export default function CanvasModel3DPage() {
|
||||
onClick={() => handleSelectTask(task)}
|
||||
type="button"
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
alt="preview"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
|
||||
src={previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
||||
<Box className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
<TaskAssetPreview task={task} />
|
||||
</button>
|
||||
<div className="border-white/10 border-t bg-zinc-950/95 px-1.5 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
|
||||
@ -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<string, boolean>;
|
||||
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<string, unknown> | null;
|
||||
resetViewToken?: number;
|
||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||
@ -1967,7 +1957,6 @@ function WorldSplatViewer({
|
||||
const [prefetchedSplat, setPrefetchedSplat] = useState<PrefetchedSplatAsset | null>(
|
||||
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<MovementState>(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 (
|
||||
<Model3DViewer
|
||||
emptyStateTitle="准备加载 3D Mesh"
|
||||
layerVisibility={fallbackMeshLayerVisibility}
|
||||
modelRotation={WORLDLABS_ASSET_ROTATION}
|
||||
modelUrl={resolvedFallbackMeshUrl}
|
||||
onCaptureReady={onCaptureReady}
|
||||
onLayersChange={onFallbackMeshLayersChange}
|
||||
onStatsChange={onFallbackMeshStatsChange}
|
||||
previewImageUrl={previewImageUrl}
|
||||
resetViewToken={resetViewToken}
|
||||
settings={fallbackMeshSettings || DEFAULT_MESH_SETTINGS}
|
||||
showMovementControls={showMovementControls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [handleSplatLoadStateChange, resolvedSplatUrl]);
|
||||
|
||||
if (!resolvedSplatUrl) {
|
||||
return (
|
||||
@ -2375,13 +2323,8 @@ export function World3DViewer({
|
||||
<WorldViewerInteractionSurface className={className}>
|
||||
<WorldSplatViewer
|
||||
firstPerson={firstPerson}
|
||||
fallbackMeshLayerVisibility={meshLayerVisibility}
|
||||
fallbackMeshSettings={meshSettings}
|
||||
fallbackMeshUrl={meshUrl}
|
||||
hotspots={hotspots}
|
||||
interactionMode={interactionMode}
|
||||
onFallbackMeshLayersChange={onMeshLayersChange}
|
||||
onFallbackMeshStatsChange={onMeshStatsChange}
|
||||
onCaptureReady={onCaptureReady}
|
||||
onHotspotSelect={onHotspotSelect}
|
||||
onPick={onPick}
|
||||
|
||||
@ -282,6 +282,7 @@ export function WorldSharePage({
|
||||
mode={activeView}
|
||||
previewImageUrl={activeWorld?.previewImageUrl || null}
|
||||
semanticsMetadata={activeWorld?.semanticsMetadata || null}
|
||||
showInfoOverlay={false}
|
||||
showMovementControls={activeView === "splat" || activeView === "mesh"}
|
||||
splatUrl={activeWorld?.splatUrl || null}
|
||||
worldUrl={activeWorld?.worldUrl || null}
|
||||
|
||||
@ -55,6 +55,7 @@
|
||||
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
|
||||
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",
|
||||
"storage:configure-upload-lifecycle": "pnpm exec tsx scripts/storage/configure-multipart-lifecycle.ts",
|
||||
"storage:repair-model3d-qiniu": "pnpm exec tsx scripts/storage/repair-model3d-qiniu-assets.ts",
|
||||
"verify:ruxa-models": "pnpm exec tsx scripts/verify-ruxa-image-models.ts",
|
||||
"providers:consolidate": "pnpm exec tsx scripts/consolidate-ai-providers.ts",
|
||||
"providers:test-vip98k": "pnpm exec tsx scripts/test-vip98k-capability.ts"
|
||||
|
||||
414
scripts/storage/repair-model3d-qiniu-assets.ts
Normal file
414
scripts/storage/repair-model3d-qiniu-assets.ts
Normal file
@ -0,0 +1,414 @@
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import Module from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
loadEnv({ path: ".env.local" });
|
||||
loadEnv();
|
||||
|
||||
const moduleLoader = Module as typeof Module & {
|
||||
_load: (request: string, parent: NodeModule | null, isMain: boolean) => 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<string, string>;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<string, string>): 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<string, string>();
|
||||
let nextModelUrl = normalizeUrl(asset.modelUrl) || null;
|
||||
let nextPreviewUrl = normalizeUrl(asset.previewImageUrl) || null;
|
||||
let freshTripoOutput: Record<string, unknown> | 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<string, unknown>;
|
||||
}>(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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user