feat: add world sharing workflow
This commit is contained in:
parent
b3d9b70ed4
commit
b6e0a59d5b
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
100
app/worlds/share/[token]/page.tsx
Normal file
100
app/worlds/share/[token]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
|
||||
271
components/canvas/world3d/WorldSharePage.tsx
Normal file
271
components/canvas/world3d/WorldSharePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
lib/db/migrations/0064_add_model3d_asset_share.sql
Normal file
11
lib/db/migrations/0064_add_model3d_asset_share.sql
Normal 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");
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user