feat: add world sharing workflow
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-04-05 21:23:27 +08:00
parent b3d9b70ed4
commit b6e0a59d5b
9 changed files with 771 additions and 4 deletions

View File

@ -1,4 +1,5 @@
import { and, desc, eq } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
@ -100,6 +101,10 @@ function readProgress(input: unknown): number | null {
return Math.max(0, Math.min(100, Math.round(value)));
}
function createShareToken() {
return randomUUID().replace(/-/g, "");
}
function readDate(input: unknown): Date | null {
if (!input) return null;
if (input instanceof Date && !Number.isNaN(input.getTime())) return input;
@ -290,3 +295,89 @@ export async function DELETE(
return NextResponse.json({ success: false, error: "清理 3D 资产失败" }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const taskId = String((body as any)?.taskId || "").trim();
if (!taskId) {
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
}
const [existingAsset] = await db
.select({
id: model3dAsset.id,
provider: model3dAsset.provider,
status: model3dAsset.status,
shareEnabled: model3dAsset.shareEnabled,
shareToken: model3dAsset.shareToken,
})
.from(model3dAsset)
.where(
and(
eq(model3dAsset.projectId, projectId),
eq(model3dAsset.userId, session.user.id),
eq(model3dAsset.taskId, taskId)
)
)
.limit(1);
if (!existingAsset) {
return NextResponse.json({ success: false, error: "资产不存在" }, { status: 404 });
}
if (existingAsset.provider !== "worldlabs-3d") {
return NextResponse.json(
{ success: false, error: "当前仅支持分享 3D 世界资产" },
{ status: 400 }
);
}
if (existingAsset.status !== "success") {
return NextResponse.json(
{ success: false, error: "仅成功生成的 3D 世界支持分享" },
{ status: 400 }
);
}
const shouldEnable = (body as any)?.shareEnabled !== false;
const nextValues = shouldEnable
? {
shareEnabled: true,
shareToken: existingAsset.shareToken || createShareToken(),
sharedAt: new Date(),
updatedAt: new Date(),
}
: {
shareEnabled: false,
shareToken: null,
sharedAt: null,
updatedAt: new Date(),
};
const [updatedAsset] = await db
.update(model3dAsset)
.set(nextValues)
.where(eq(model3dAsset.id, existingAsset.id))
.returning();
return NextResponse.json({ success: true, data: updatedAsset });
} catch (error) {
console.error("[Model3DAssets] PATCH failed:", error);
return NextResponse.json({ success: false, error: "更新 3D 世界分享状态失败" }, { status: 500 });
}
}

View File

@ -109,6 +109,34 @@ async function archiveRemoteAssetIfNeeded(params: {
return archivedUrl || sourceUrl;
}
async function archiveRemoteAssetMapIfNeeded(params: {
cacheKeyPrefix: string;
sourceMap?: Record<string, string> | null;
filenamePrefix: string;
fallbackExtension: string;
}) {
const entries = Object.entries(params.sourceMap || {}).filter(
(entry): entry is [string, string] =>
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
);
if (entries.length === 0) return null;
const archivedEntries = await Promise.all(
entries.map(async ([key, value]) => {
const archivedUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `${params.cacheKeyPrefix}:${key}`,
sourceUrl: value,
filenamePrefix: `${params.filenamePrefix}-${key}`,
fallbackExtension: params.fallbackExtension,
});
return [key, archivedUrl || value] as const;
})
);
return Object.fromEntries(archivedEntries);
}
function getStringValue(...values: unknown[]) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
@ -253,6 +281,16 @@ export async function GET(_request: Request, { params }: RouteParams) {
fallbackExtension: "jpg",
});
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
sourceMap:
assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object"
? assets.splats.spz_urls
: null,
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
fallbackExtension: "spz",
});
const assetPayload = buildAssetPayload(world, assets);
return NextResponse.json({
@ -274,6 +312,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl || assetPayload.meshUrl,
spzUrls: stableSpzUrls || assetPayload.spzUrls,
},
},
});

View File

@ -42,7 +42,7 @@ import {
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import {
@ -134,6 +134,9 @@ type TaskRecord = {
sourceTaskId?: string | null;
exportFormat?: string | null;
assetPayload?: WorldLabsTaskAssetPayload | null;
shareEnabled?: boolean;
shareToken?: string | null;
sharedAt?: number | null;
};
type TaskRecordApiEntity = {
@ -150,6 +153,9 @@ type TaskRecordApiEntity = {
sourceTaskId?: unknown;
exportFormat?: unknown;
assetPayload?: unknown;
shareEnabled?: unknown;
shareToken?: unknown;
sharedAt?: unknown;
createdAt?: unknown;
};
@ -289,6 +295,13 @@ function extractModelFormat(modelUrl?: string | null): string {
return "3d";
}
function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
if (!task) return null;
if (task.modelUrl) return task.modelUrl;
const meshUrl = task.assetPayload?.meshUrl;
return typeof meshUrl === "string" && meshUrl.trim() ? meshUrl.trim() : null;
}
function createMultiViewSlots(count = 4): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({
id: `slot_${index + 1}`,
@ -382,6 +395,15 @@ function makeStorageKey(projectId: string) {
return `${TASK_HISTORY_STORAGE_PREFIX}:${projectId}`;
}
function buildWorldShareUrl(shareToken?: string | null) {
const normalized = String(shareToken || "").trim();
if (!normalized) return null;
if (typeof window === "undefined") {
return `/worlds/share/${normalized}`;
}
return new URL(`/worlds/share/${normalized}`, window.location.origin).toString();
}
function normalizeTaskProvider(provider: unknown): TaskProvider {
return String(provider || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d";
}
@ -409,6 +431,11 @@ function toTaskTimestamp(value: unknown): number {
return Date.now();
}
function toNullableTaskTimestamp(value: unknown): number | null {
if (value === undefined || value === null || value === "") return null;
return toTaskTimestamp(value);
}
function normalizeTaskRecord(raw: TaskRecord): TaskRecord {
return {
...raw,
@ -433,6 +460,9 @@ function normalizeTaskRecord(raw: TaskRecord): TaskRecord {
sourceTaskId: raw.sourceTaskId ? String(raw.sourceTaskId).trim() : null,
exportFormat: raw.exportFormat ? String(raw.exportFormat).trim() : null,
assetPayload: normalizeAssetPayload(raw.assetPayload),
shareEnabled: raw.shareEnabled === true,
shareToken: raw.shareToken ? String(raw.shareToken).trim() : null,
sharedAt: toNullableTaskTimestamp(raw.sharedAt),
};
}
@ -460,6 +490,9 @@ function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | nul
sourceTaskId: entity?.sourceTaskId ? String(entity.sourceTaskId).trim() : null,
exportFormat: entity?.exportFormat ? String(entity.exportFormat).trim() : null,
assetPayload: normalizeAssetPayload(entity?.assetPayload),
shareEnabled: entity?.shareEnabled === true,
shareToken: entity?.shareToken ? String(entity.shareToken).trim() : null,
sharedAt: toNullableTaskTimestamp(entity?.sharedAt),
});
}
@ -914,6 +947,9 @@ export default function CanvasModel3DPage() {
}
return [normalized, ...prev].slice(0, TASK_HISTORY_LIMIT);
});
setCurrentTask((prev) =>
prev?.taskId === normalized.taskId ? { ...prev, ...normalized } : prev
);
if (options?.persist !== false) {
void persistTaskRecord(normalized);
@ -1662,18 +1698,111 @@ export default function CanvasModel3DPage() {
};
const handleDownloadCurrent = () => {
if (!currentModelUrl) {
const modelUrl = resolveTaskModelUrl(currentTask);
if (!modelUrl) {
toast.error("暂无可下载的 3D 模型");
return;
}
const link = document.createElement("a");
link.href = currentModelUrl;
link.href = modelUrl;
link.download = `${
currentTask?.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
}-${currentTask?.taskId || "result"}.${extractModelFormat(currentModelUrl)}`;
}-${currentTask?.taskId || "result"}.${extractModelFormat(modelUrl)}`;
link.click();
};
const handleDownloadTask = (
task: TaskRecord,
event?: MouseEvent<HTMLButtonElement>
) => {
event?.stopPropagation();
const modelUrl = resolveTaskModelUrl(task);
if (!modelUrl) {
toast.error(task.provider === "worldlabs-3d" ? "该世界暂无可下载的 Mesh" : "该模型暂无可下载文件");
return;
}
const link = document.createElement("a");
link.href = modelUrl;
link.download = `${
task.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
}-${task.taskId}.${extractModelFormat(modelUrl)}`;
link.click();
};
const updateWorldShareState = useCallback(
async (task: TaskRecord, shareEnabled: boolean) => {
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskId: task.taskId,
shareEnabled,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean;
error?: string;
data?: TaskRecordApiEntity;
};
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "更新世界分享状态失败");
}
const updatedTask = payload?.data ? mapApiEntityToTaskRecord(payload.data) : null;
if (!updatedTask) {
throw new Error("分享结果返回无效");
}
upsertTaskHistory(updatedTask, { persist: false });
return updatedTask;
},
[id, upsertTaskHistory]
);
const handleShareTask = useCallback(
async (task?: TaskRecord | null, event?: MouseEvent<HTMLButtonElement>) => {
event?.stopPropagation();
if (!task || task.provider !== "worldlabs-3d" || task.status !== "success") {
toast.error("仅成功生成的 3D 世界支持分享");
return;
}
try {
const sharedTask =
task.shareEnabled && task.shareToken
? task
: await updateWorldShareState(task, true);
const shareUrl = buildWorldShareUrl(sharedTask.shareToken);
if (!shareUrl) {
throw new Error("分享链接生成失败");
}
await navigator.clipboard.writeText(shareUrl);
toast.success(
task.shareEnabled ? "已复制世界分享链接" : "已开启分享并复制链接"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "分享世界失败");
}
},
[updateWorldShareState]
);
const handleDisableCurrentShare = useCallback(async () => {
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
toast.error("当前没有可关闭分享的世界资产");
return;
}
if (!currentTask.shareEnabled) {
toast.error("当前世界尚未开启分享");
return;
}
try {
await updateWorldShareState(currentTask, false);
toast.success("已关闭世界分享");
} catch (error) {
toast.error(error instanceof Error ? error.message : "关闭分享失败");
}
}, [currentTask, updateWorldShareState]);
const handleOpenWorldUrl = () => {
const worldUrl = currentTask?.assetPayload?.worldUrl;
if (!worldUrl) {
@ -1986,6 +2115,10 @@ export default function CanvasModel3DPage() {
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
const currentWorldId =
currentWorldAsset?.worldId || currentTask?.sourceTaskId || null;
const currentWorldShareUrl =
currentTask?.provider === "worldlabs-3d" && currentTask?.shareEnabled
? buildWorldShareUrl(currentTask.shareToken)
: null;
const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {});
const currentPrimarySpzUrl =
currentSpzEntries.find(([, url]) => typeof url === "string" && url.trim())?.[1] || null;
@ -2908,6 +3041,26 @@ export default function CanvasModel3DPage() {
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
disabled={!currentTask || currentTask.status !== "success"}
onClick={() => void handleShareTask(currentTask)}
variant="outline"
>
<Link2 className="mr-2 h-4 w-4" />
{currentWorldShareUrl ? "复制分享链接" : "开启分享"}
</Button>
<Button
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
disabled={!currentWorldShareUrl}
onClick={() => void handleDisableCurrentShare()}
variant="outline"
>
<X className="mr-2 h-4 w-4" /> 关闭分享
</Button>
</div>
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
<CardHeader className="space-y-1 pb-3">
<CardTitle className="text-sm">当前世界资产</CardTitle>
@ -2979,7 +3132,25 @@ export default function CanvasModel3DPage() {
{currentModelUrl ? "可导入" : "缺失"}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
<span className="flex items-center gap-2">
<Link2 className="h-3.5 w-3.5" />
分享页
</span>
<Badge
className="text-[10px]"
variant={currentWorldShareUrl ? "default" : "secondary"}
>
{currentWorldShareUrl ? "已开启" : "未开启"}
</Badge>
</div>
</div>
{currentWorldShareUrl ? (
<div className="rounded-md bg-white/5 px-3 py-2 text-zinc-200">
<div className="text-[11px] text-zinc-500">分享链接</div>
<div className="mt-1 truncate">{currentWorldShareUrl}</div>
</div>
) : null}
</CardContent>
</Card>
</div>
@ -3112,7 +3283,19 @@ export default function CanvasModel3DPage() {
{currentModelUrl ? "已归档" : "暂无"}
</div>
</div>
<div className="rounded-md bg-white/5 px-3 py-2">
<div className="text-[11px] text-zinc-500">分享页</div>
<div className="truncate text-zinc-200">
{currentWorldShareUrl ? "已开启" : "未开启"}
</div>
</div>
</div>
{currentWorldShareUrl ? (
<div className="rounded-md bg-white/5 px-3 py-2 text-zinc-200">
<div className="text-[11px] text-zinc-500">分享链接</div>
<div className="mt-1 truncate">{currentWorldShareUrl}</div>
</div>
) : null}
<div className="space-y-2">
<h4 className="font-medium text-xs text-zinc-500">
SPZ 列表
@ -3346,7 +3529,34 @@ export default function CanvasModel3DPage() {
)}
key={task.taskId}
onClick={() => handleSelectTask(task)}
type="button"
>
{task.provider === "worldlabs-3d" && task.status === "success" ? (
<Button
className={cn(
"absolute left-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
task.shareEnabled &&
"border-amber-400/40 bg-amber-400/20 text-amber-100 opacity-100"
)}
onClick={(event) => void handleShareTask(task, event)}
size="icon"
type="button"
variant="ghost"
>
<Link2 className="h-3.5 w-3.5" />
</Button>
) : null}
{task.status === "success" && resolveTaskModelUrl(task) ? (
<Button
className="absolute right-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
onClick={(event) => handleDownloadTask(task, event)}
size="icon"
type="button"
variant="ghost"
>
<Download className="h-3.5 w-3.5" />
</Button>
) : null}
{task.previewImageUrl ? (
<img
alt="preview"
@ -3384,6 +3594,16 @@ export default function CanvasModel3DPage() {
locale: zhCN,
})}
</div>
{task.status === "success" && resolveTaskModelUrl(task) ? (
<div className="mt-1 text-left text-[10px] text-zinc-200">
点击右上角可下载模型
</div>
) : null}
{task.provider === "worldlabs-3d" && task.shareEnabled ? (
<div className="mt-1 text-left text-[10px] text-amber-200">
已开启分享
</div>
) : null}
</div>
</button>
))}

View File

@ -0,0 +1,100 @@
import { and, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { WorldSharePage } from "@/components/canvas/world3d/WorldSharePage";
import { db } from "@/lib/db";
import { model3dAsset } from "@/lib/db/schema";
export const dynamic = "force-dynamic";
function readAssetPayload(
value: unknown
): {
displayName?: string | null;
model?: string | null;
caption?: string | null;
worldUrl?: string | null;
thumbnailUrl?: string | null;
panoUrl?: string | null;
meshUrl?: string | null;
spzUrls?: Record<string, string> | null;
semanticsMetadata?: Record<string, unknown> | null;
} | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as {
displayName?: string | null;
model?: string | null;
caption?: string | null;
worldUrl?: string | null;
thumbnailUrl?: string | null;
panoUrl?: string | null;
meshUrl?: string | null;
spzUrls?: Record<string, string> | null;
semanticsMetadata?: Record<string, unknown> | null;
})
: null;
}
function pickPrimarySplatUrl(assetPayload: ReturnType<typeof readAssetPayload>) {
if (!assetPayload?.spzUrls || typeof assetPayload.spzUrls !== "object") return null;
for (const value of Object.values(assetPayload.spzUrls)) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
export default async function SharedWorldPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const shareToken = String(token || "").trim();
if (!shareToken) {
notFound();
}
const [asset] = await db
.select({
taskId: model3dAsset.taskId,
prompt: model3dAsset.prompt,
modelVersion: model3dAsset.modelVersion,
previewImageUrl: model3dAsset.previewImageUrl,
modelUrl: model3dAsset.modelUrl,
assetPayload: model3dAsset.assetPayload,
})
.from(model3dAsset)
.where(
and(
eq(model3dAsset.shareEnabled, true),
eq(model3dAsset.shareToken, shareToken),
eq(model3dAsset.provider, "worldlabs-3d"),
eq(model3dAsset.status, "success")
)
)
.limit(1);
if (!asset) {
notFound();
}
const assetPayload = readAssetPayload(asset.assetPayload);
const sharePath = `/worlds/share/${shareToken}`;
return (
<WorldSharePage
caption={assetPayload?.caption || null}
imageUrl={assetPayload?.panoUrl || assetPayload?.thumbnailUrl || asset.previewImageUrl || null}
meshUrl={asset.modelUrl || assetPayload?.meshUrl || null}
model={assetPayload?.model || asset.modelVersion || null}
previewImageUrl={asset.previewImageUrl || assetPayload?.thumbnailUrl || null}
prompt={asset.prompt || null}
semanticsMetadata={assetPayload?.semanticsMetadata || null}
shareUrl={sharePath}
splatUrl={pickPrimarySplatUrl(assetPayload)}
title={assetPayload?.displayName || asset.prompt || `World ${asset.taskId.slice(0, 8)}`}
worldUrl={assetPayload?.worldUrl || null}
/>
);
}

View File

@ -98,6 +98,15 @@ const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
PageDown: "down",
};
function isEditableKeyboardTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']";
if (target.matches(editableSelector)) return true;
return Boolean(target.closest(editableSelector));
}
function getSemanticFacts(metadata?: Record<string, unknown> | null) {
if (!metadata) return [];
@ -261,20 +270,36 @@ function CameraMovementControls({
const handleKeyDown = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditableKeyboardTarget(event.target)) {
pressedKeysRef.current = createMovementState();
return;
}
pressedKeysRef.current[movement] = true;
};
const handleKeyUp = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
if (isEditableKeyboardTarget(event.target)) {
pressedKeysRef.current = createMovementState();
return;
}
pressedKeysRef.current[movement] = false;
};
const handleFocusIn = (event: FocusEvent) => {
if (isEditableKeyboardTarget(event.target)) {
pressedKeysRef.current = createMovementState();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("focusin", handleFocusIn);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("focusin", handleFocusIn);
};
}, [enabled]);

View File

@ -0,0 +1,271 @@
"use client";
import {
Box,
Download,
ExternalLink,
Globe,
ImageIcon,
Link2,
Sparkles,
Waypoints,
} from "lucide-react";
import { useMemo, useState } from "react";
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { World3DViewMode } from "@/types/canvas";
type SharedWorldPageProps = {
title: string;
prompt?: string | null;
caption?: string | null;
model?: string | null;
worldUrl?: string | null;
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
previewImageUrl?: string | null;
semanticsMetadata?: Record<string, unknown> | null;
shareUrl: string;
};
const VIEW_OPTIONS: Array<{
id: World3DViewMode;
label: string;
icon: typeof Sparkles;
}> = [
{ id: "splat", label: "Splat", icon: Sparkles },
{ id: "world", label: "World", icon: Globe },
{ id: "image", label: "图像", icon: ImageIcon },
{ id: "mesh", label: "Mesh", icon: Box },
];
function resolveAvailableViews(params: {
worldUrl?: string | null;
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
}) {
const views: World3DViewMode[] = [];
if (params.splatUrl) views.push("splat");
if (params.worldUrl) views.push("world");
if (params.imageUrl) views.push("image");
if (params.meshUrl) views.push("mesh");
return views;
}
function inferInitialView(views: World3DViewMode[]) {
if (views.includes("splat")) return "splat";
if (views.includes("world")) return "world";
if (views.includes("image")) return "image";
if (views.includes("mesh")) return "mesh";
return "image";
}
export function WorldSharePage({
title,
prompt,
caption,
model,
worldUrl,
imageUrl,
meshUrl,
splatUrl,
previewImageUrl,
semanticsMetadata,
shareUrl,
}: SharedWorldPageProps) {
const availableViews = useMemo(
() => resolveAvailableViews({ worldUrl, imageUrl, meshUrl, splatUrl }),
[imageUrl, meshUrl, splatUrl, worldUrl]
);
const [activeView, setActiveView] = useState<World3DViewMode>(
inferInitialView(availableViews)
);
const [firstPerson, setFirstPerson] = useState(false);
const [copied, setCopied] = useState(false);
const primaryDescription = caption || prompt || "这个 3D 世界已开启只读分享。";
const handleCopyShareUrl = async () => {
try {
const absoluteUrl = new URL(shareUrl, window.location.origin).toString();
await navigator.clipboard.writeText(absoluteUrl);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
};
const handleDownloadMesh = () => {
if (!meshUrl) return;
const link = document.createElement("a");
link.href = meshUrl;
link.download = "shared-world.glb";
link.click();
};
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(24,24,27,0.96),_rgba(2,6,23,1)_68%)] text-white">
<div className="mx-auto flex min-h-screen w-full max-w-[1600px] flex-col gap-4 p-4 md:p-6">
<div className="flex flex-col gap-4 rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur md:flex-row md:items-start md:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge className="border-cyan-500/30 bg-cyan-500/10 text-cyan-200" variant="outline">
3D 世界分享
</Badge>
{model ? (
<Badge className="border-white/10 bg-white/5 text-zinc-200" variant="outline">
{model}
</Badge>
) : null}
{availableViews.map((view) => (
<Badge className="border-white/10 bg-white/5 text-zinc-300" key={view} variant="outline">
{view.toUpperCase()}
</Badge>
))}
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white md:text-3xl">
{title}
</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-zinc-300">
{primaryDescription}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
onClick={handleCopyShareUrl}
variant="outline"
>
<Link2 className="mr-2 h-4 w-4" />
{copied ? "已复制链接" : "复制分享链接"}
</Button>
{worldUrl ? (
<Button
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
onClick={() => window.open(worldUrl, "_blank", "noopener,noreferrer")}
variant="outline"
>
<ExternalLink className="mr-2 h-4 w-4" />
打开原始 World
</Button>
) : null}
{meshUrl ? (
<Button
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
onClick={handleDownloadMesh}
variant="outline"
>
<Download className="mr-2 h-4 w-4" />
下载 Mesh
</Button>
) : null}
</div>
</div>
<div className="grid flex-1 gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="overflow-hidden rounded-3xl border border-white/10 bg-black/35 shadow-2xl backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
{VIEW_OPTIONS.filter((item) => availableViews.includes(item.id)).map((item) => (
<Button
className={cn(
"rounded-full border border-white/10 px-4",
activeView === item.id
? "bg-white text-black hover:bg-zinc-100"
: "bg-white/5 text-zinc-100 hover:bg-white/10"
)}
key={item.id}
onClick={() => {
setActiveView(item.id);
if (item.id !== "splat") {
setFirstPerson(false);
}
}}
size="sm"
variant="ghost"
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</Button>
))}
</div>
{activeView === "splat" && splatUrl ? (
<Button
className={cn(
"rounded-full border border-white/10",
firstPerson
? "bg-amber-400 text-black hover:bg-amber-300"
: "bg-white/5 text-zinc-100 hover:bg-white/10"
)}
onClick={() => setFirstPerson((prev) => !prev)}
size="sm"
variant="ghost"
>
<Waypoints className="mr-2 h-4 w-4" />
{firstPerson ? "退出第一人称" : "第一人称"}
</Button>
) : null}
</div>
<div className="h-[62vh] min-h-[520px]">
<World3DViewer
className="h-full w-full"
firstPerson={firstPerson}
imageUrl={imageUrl}
interactionMode="navigate"
meshUrl={meshUrl}
mode={activeView}
previewImageUrl={previewImageUrl}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}
worldUrl={worldUrl}
/>
</div>
</div>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
<h2 className="text-sm font-medium text-white">查看说明</h2>
<div className="mt-3 space-y-2 text-sm leading-6 text-zinc-300">
<p>`Splat` 视图最接近原生 3D 世界体验,支持方向键或 `WASD` 六向移动。</p>
<p>`World` 视图会直接打开 World Labs 提供的公开页面。</p>
<p>当前页面是只读分享页,不会修改原项目或原画布内容。</p>
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
<h2 className="text-sm font-medium text-white">资源状态</h2>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-zinc-300">
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
<div className="text-zinc-500">Splat</div>
<div className="mt-1">{splatUrl ? "可查看" : "暂无"}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
<div className="text-zinc-500">World</div>
<div className="mt-1">{worldUrl ? "可打开" : "暂无"}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
<div className="text-zinc-500">图像</div>
<div className="mt-1">{imageUrl || previewImageUrl ? "可查看" : "暂无"}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2">
<div className="text-zinc-500">Mesh</div>
<div className="mt-1">{meshUrl ? "可下载" : "暂无"}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
ALTER TABLE "Model3DAsset"
ADD COLUMN IF NOT EXISTS "shareEnabled" boolean NOT NULL DEFAULT false;
ALTER TABLE "Model3DAsset"
ADD COLUMN IF NOT EXISTS "shareToken" varchar(64);
ALTER TABLE "Model3DAsset"
ADD COLUMN IF NOT EXISTS "sharedAt" timestamp;
CREATE UNIQUE INDEX IF NOT EXISTS "Model3DAsset_shareToken_idx"
ON "Model3DAsset" ("shareToken");

View File

@ -351,6 +351,13 @@
"when": 1775289600000,
"tag": "0063_add_worldlabs_3d_assets",
"breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1775342400000,
"tag": "0064_add_model3d_asset_share",
"breakpoints": true
}
]
}

View File

@ -779,6 +779,9 @@ export const model3dAsset = pgTable("Model3DAsset", {
sourceTaskId: varchar("sourceTaskId", { length: 128 }),
exportFormat: varchar("exportFormat", { length: 32 }),
assetPayload: jsonb("assetPayload").$type<Record<string, unknown> | null>(),
shareEnabled: boolean("shareEnabled").notNull().default(false),
shareToken: varchar("shareToken", { length: 64 }),
sharedAt: timestamp("sharedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});