4310 lines
161 KiB
TypeScript
4310 lines
161 KiB
TypeScript
"use client";
|
||
|
||
import { formatDistanceToNow } from "date-fns";
|
||
import { zhCN } from "date-fns/locale";
|
||
import dynamic from "next/dynamic";
|
||
import {
|
||
ArrowLeft,
|
||
Bone,
|
||
Box,
|
||
Boxes,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
CircleDashed,
|
||
Crosshair,
|
||
Download,
|
||
Eye,
|
||
EyeOff,
|
||
Grid3X3,
|
||
Home,
|
||
ImagePlus,
|
||
Images,
|
||
Layers3,
|
||
LayoutGrid,
|
||
Loader2,
|
||
Maximize,
|
||
Package,
|
||
Palette,
|
||
PenLine,
|
||
Play,
|
||
PlusCircle,
|
||
RefreshCw,
|
||
RotateCw,
|
||
Scissors,
|
||
Settings2,
|
||
Sparkles,
|
||
Sun,
|
||
Globe,
|
||
ImageIcon,
|
||
Link2,
|
||
PanelTop,
|
||
Video,
|
||
WandSparkles,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useSession } from "next-auth/react";
|
||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import useSWR from "swr";
|
||
import {
|
||
type Model3DLayer,
|
||
type Model3DStats,
|
||
type Model3DViewerSettings,
|
||
} from "@/components/canvas/model3d/Model3DViewer";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import { cn, fetcher } from "@/lib/utils";
|
||
import {
|
||
getSortedWorldLabsSplatEntries,
|
||
pickBestArchivedWorldLabsSplatUrl,
|
||
pickPreferredInlineWorldLabsSplatUrl,
|
||
} from "@/lib/worldlabs/splat-utils";
|
||
import {
|
||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||
getDefaultThreeDModelAliasId,
|
||
resolveThreeDModelBillingId,
|
||
resolveThreeDModelDisplayName,
|
||
type PublicThreeDModelAliasConfig,
|
||
} from "@/lib/model3d/model-alias-config";
|
||
import {
|
||
extractModelAssetFormat,
|
||
resolvePreferredModelAssetUrl,
|
||
} from "@/lib/model3d/model-url";
|
||
import {
|
||
defaultPointsSystemConfig,
|
||
get3DTaskCreditCost,
|
||
get3DWorldCreditCost,
|
||
normalizePointsSystemConfig,
|
||
type PointsSystemConfig,
|
||
} from "@/lib/points/config";
|
||
import {
|
||
canvasService,
|
||
type Model3DTaskStatus,
|
||
model3DGenerationService,
|
||
type WorldLabsTaskAssetPayload,
|
||
worldLabsGenerationService,
|
||
} from "@/services/canvas";
|
||
import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||
|
||
const Model3DViewer = dynamic(
|
||
() =>
|
||
import("@/components/canvas/model3d/Model3DViewer").then(
|
||
(module) => module.Model3DViewer
|
||
),
|
||
{
|
||
loading: () => (
|
||
<div className="flex h-full w-full items-center justify-center text-sm text-zinc-500">
|
||
准备加载 3D 视图...
|
||
</div>
|
||
),
|
||
ssr: false,
|
||
}
|
||
);
|
||
|
||
const World3DViewer = dynamic(
|
||
() =>
|
||
import("@/components/canvas/world3d/World3DViewer").then(
|
||
(module) => module.World3DViewer
|
||
),
|
||
{
|
||
loading: () => (
|
||
<div className="flex h-full w-full items-center justify-center text-sm text-zinc-500">
|
||
准备加载 World 视图...
|
||
</div>
|
||
),
|
||
ssr: false,
|
||
}
|
||
);
|
||
|
||
type GenerationMode = "text" | "image" | "multiview" | "batch";
|
||
type WorkspaceMode = "model" | "world";
|
||
type WorldGenerationMode = "text" | "image" | "multi-image" | "video";
|
||
type WorldViewportMode = "world" | "imagery" | "mesh" | "splat";
|
||
type Model3DTaskAssetPayload = WorldLabsTaskAssetPayload & {
|
||
providerId?: string | null;
|
||
providerName?: string | null;
|
||
sourceModelUrl?: string | null;
|
||
storageArchivedAt?: string | null;
|
||
};
|
||
type TaskProvider = "tripo-3d" | "worldlabs-3d";
|
||
type TaskMode =
|
||
| GenerationMode
|
||
| "world-text"
|
||
| "world-image"
|
||
| "world-multi-image"
|
||
| "world-video"
|
||
| "convert"
|
||
| "refine"
|
||
| "texture"
|
||
| "stylize"
|
||
| "rigcheck"
|
||
| "rig"
|
||
| "retarget"
|
||
| "segment"
|
||
| "complete"
|
||
| "lowpoly";
|
||
|
||
type TaskRecord = {
|
||
taskId: string;
|
||
provider: TaskProvider;
|
||
status: Model3DTaskStatus;
|
||
mode: TaskMode;
|
||
prompt: string;
|
||
modelVersion: string;
|
||
createdAt: number;
|
||
progress?: number | null;
|
||
modelUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
error?: string | null;
|
||
sourceTaskId?: string | null;
|
||
exportFormat?: string | null;
|
||
assetPayload?: Model3DTaskAssetPayload | null;
|
||
shareEnabled?: boolean;
|
||
shareToken?: string | null;
|
||
sharedAt?: number | null;
|
||
};
|
||
|
||
type TaskRecordApiEntity = {
|
||
taskId?: unknown;
|
||
provider?: unknown;
|
||
status?: unknown;
|
||
mode?: unknown;
|
||
prompt?: unknown;
|
||
modelVersion?: unknown;
|
||
progress?: unknown;
|
||
modelUrl?: unknown;
|
||
previewImageUrl?: unknown;
|
||
error?: unknown;
|
||
sourceTaskId?: unknown;
|
||
exportFormat?: unknown;
|
||
assetPayload?: unknown;
|
||
shareEnabled?: unknown;
|
||
shareToken?: unknown;
|
||
sharedAt?: unknown;
|
||
createdAt?: unknown;
|
||
};
|
||
|
||
type MultiViewSlot = {
|
||
id: string;
|
||
file: File | null;
|
||
previewUrl: string | null;
|
||
};
|
||
|
||
type WorldImageSlot = {
|
||
id: string;
|
||
file: File | null;
|
||
previewUrl: string | null;
|
||
azimuth: number;
|
||
};
|
||
|
||
const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [
|
||
"city",
|
||
"sunset",
|
||
"studio",
|
||
"dawn",
|
||
"night",
|
||
"warehouse",
|
||
"forest",
|
||
"apartment",
|
||
"park",
|
||
"lobby",
|
||
];
|
||
|
||
const EXPORT_FORMAT_OPTIONS = [
|
||
"GLTF",
|
||
"USDZ",
|
||
"FBX",
|
||
"OBJ",
|
||
"STL",
|
||
"3MF",
|
||
] as const;
|
||
const STYLE_OPTIONS = ["lego", "voxel", "voronoi", "minecraft"] as const;
|
||
const RIG_TYPE_OPTIONS = [
|
||
"biped",
|
||
"quadruped",
|
||
"hexapod",
|
||
"octopod",
|
||
"avian",
|
||
"serpentine",
|
||
"aquatic",
|
||
"others",
|
||
] as const;
|
||
const RIG_SPEC_OPTIONS = ["tripo", "mixamo"] as const;
|
||
const RIG_FORMAT_OPTIONS = ["glb", "fbx"] as const;
|
||
const ANIMATION_OPTIONS = [
|
||
"preset:idle",
|
||
"preset:walk",
|
||
"preset:run",
|
||
"preset:jump",
|
||
"preset:turn",
|
||
"preset:slash",
|
||
"preset:shoot",
|
||
"preset:hurt",
|
||
"preset:fall",
|
||
] as const;
|
||
const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
|
||
"success",
|
||
"failed",
|
||
"banned",
|
||
"expired",
|
||
"cancelled",
|
||
"unknown",
|
||
]);
|
||
const TASK_MODE_SET = new Set<TaskMode>([
|
||
"text",
|
||
"image",
|
||
"multiview",
|
||
"batch",
|
||
"world-text",
|
||
"world-image",
|
||
"world-multi-image",
|
||
"world-video",
|
||
"convert",
|
||
"refine",
|
||
"texture",
|
||
"stylize",
|
||
"rigcheck",
|
||
"rig",
|
||
"retarget",
|
||
"segment",
|
||
"complete",
|
||
"lowpoly",
|
||
]);
|
||
const TASK_HISTORY_LIMIT = 40;
|
||
const TASK_HISTORY_STORAGE_PREFIX = "canvas-model3d-task-history-v2";
|
||
|
||
const WORLD_MULTI_IMAGE_DEFAULTS = [0, 90, 180, 270];
|
||
|
||
function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
|
||
switch (status) {
|
||
case "queued":
|
||
case "running":
|
||
case "success":
|
||
case "failed":
|
||
case "banned":
|
||
case "expired":
|
||
case "cancelled":
|
||
case "unknown":
|
||
return status;
|
||
default:
|
||
return "unknown";
|
||
}
|
||
}
|
||
|
||
function extractModelFormat(modelUrl?: string | null): string {
|
||
return extractModelAssetFormat(modelUrl);
|
||
}
|
||
|
||
function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
|
||
if (!task) return null;
|
||
const meshUrl = task.assetPayload?.meshUrl;
|
||
return resolvePreferredModelAssetUrl({
|
||
primaryUrl: task.modelUrl,
|
||
sourceUrl: task.assetPayload?.sourceModelUrl,
|
||
fallbackUrl: typeof meshUrl === "string" ? meshUrl.trim() : null,
|
||
});
|
||
}
|
||
|
||
function resolveWorldTaskSplatUrl(task?: TaskRecord | null) {
|
||
if (!task || task.provider !== "worldlabs-3d") return null;
|
||
return pickBestArchivedWorldLabsSplatUrl(task.assetPayload?.spzUrls);
|
||
}
|
||
|
||
function resolveWorldTaskImageUrl(task?: TaskRecord | null) {
|
||
if (!task || task.provider !== "worldlabs-3d") return null;
|
||
return (
|
||
task.assetPayload?.panoUrl ||
|
||
task.assetPayload?.thumbnailUrl ||
|
||
task.previewImageUrl ||
|
||
null
|
||
);
|
||
}
|
||
|
||
function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) {
|
||
if (!task) return null;
|
||
return (
|
||
task.previewImageUrl ||
|
||
task.assetPayload?.thumbnailUrl ||
|
||
task.assetPayload?.panoUrl ||
|
||
null
|
||
);
|
||
}
|
||
|
||
function canImportTaskToCanvas(task?: TaskRecord | null) {
|
||
if (!task || task.status !== "success") return false;
|
||
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
|
||
return Boolean(
|
||
resolveTaskModelUrl(task) ||
|
||
task.assetPayload?.worldUrl ||
|
||
resolveWorldTaskImageUrl(task) ||
|
||
resolveWorldTaskSplatUrl(task)
|
||
);
|
||
}
|
||
|
||
function resolveCanvasImportPosition(params: {
|
||
index: number;
|
||
totalCount: number;
|
||
viewportX: number;
|
||
viewportY: number;
|
||
viewportZoom: number;
|
||
stageWidth: number;
|
||
stageHeight: number;
|
||
targetWidth: number;
|
||
targetHeight: number;
|
||
}) {
|
||
const column = params.index % 3;
|
||
const row = Math.floor(params.index / 3);
|
||
const gap = 36;
|
||
const centerX =
|
||
(params.stageWidth * 0.48 - params.viewportX) / params.viewportZoom;
|
||
const centerY =
|
||
(params.stageHeight * 0.45 - params.viewportY) / params.viewportZoom;
|
||
const totalColumns = Math.min(3, Math.max(1, params.totalCount));
|
||
const xOffset =
|
||
(column - (totalColumns - 1) / 2) * (params.targetWidth + gap);
|
||
const yOffset = row * (params.targetHeight + gap);
|
||
|
||
return {
|
||
x: centerX - params.targetWidth / 2 + xOffset,
|
||
y: centerY - params.targetHeight / 2 + yOffset,
|
||
};
|
||
}
|
||
|
||
function createMultiViewSlots(count = 4): MultiViewSlot[] {
|
||
return Array.from({ length: count }, (_, index) => ({
|
||
id: `slot_${index + 1}`,
|
||
file: null,
|
||
previewUrl: null,
|
||
}));
|
||
}
|
||
|
||
function createBatchImageSlots(count = 10): MultiViewSlot[] {
|
||
return Array.from({ length: count }, (_, index) => ({
|
||
id: `batch_slot_${index + 1}`,
|
||
file: null,
|
||
previewUrl: null,
|
||
}));
|
||
}
|
||
|
||
function createWorldImageSlots(values = WORLD_MULTI_IMAGE_DEFAULTS): WorldImageSlot[] {
|
||
return values.map((azimuth, index) => ({
|
||
id: `world_slot_${index + 1}`,
|
||
file: null,
|
||
previewUrl: null,
|
||
azimuth,
|
||
}));
|
||
}
|
||
|
||
function modeLabel(mode: TaskMode) {
|
||
switch (mode) {
|
||
case "text":
|
||
return "文生3D";
|
||
case "image":
|
||
return "图生3D";
|
||
case "multiview":
|
||
return "多视图3D";
|
||
case "batch":
|
||
return "批量图生3D";
|
||
case "world-text":
|
||
return "文生世界";
|
||
case "world-image":
|
||
return "图生世界";
|
||
case "world-multi-image":
|
||
return "多图世界";
|
||
case "world-video":
|
||
return "视频世界";
|
||
case "convert":
|
||
return "格式转换";
|
||
case "refine":
|
||
return "模型精修";
|
||
case "texture":
|
||
return "纹理重绘";
|
||
case "stylize":
|
||
return "风格化";
|
||
case "rigcheck":
|
||
return "可绑定检测";
|
||
case "rig":
|
||
return "自动绑定";
|
||
case "retarget":
|
||
return "动作重定向";
|
||
case "segment":
|
||
return "网格分割";
|
||
case "complete":
|
||
return "网格补全";
|
||
case "lowpoly":
|
||
return "低模优化";
|
||
default:
|
||
return mode;
|
||
}
|
||
}
|
||
|
||
function statusLabel(status: Model3DTaskStatus) {
|
||
switch (status) {
|
||
case "queued":
|
||
return "排队中";
|
||
case "running":
|
||
return "生成中";
|
||
case "success":
|
||
return "成功";
|
||
case "failed":
|
||
return "失败";
|
||
case "banned":
|
||
return "审核拦截";
|
||
case "expired":
|
||
return "已过期";
|
||
case "cancelled":
|
||
return "已取消";
|
||
default:
|
||
return "未知";
|
||
}
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
function normalizeAssetPayload(
|
||
payload: unknown
|
||
): Model3DTaskAssetPayload | null {
|
||
return payload && typeof payload === "object" && !Array.isArray(payload)
|
||
? (payload as Model3DTaskAssetPayload)
|
||
: null;
|
||
}
|
||
|
||
function normalizeTaskMode(mode: unknown): TaskMode {
|
||
const value = String(mode || "").trim() as TaskMode;
|
||
return TASK_MODE_SET.has(value) ? value : "text";
|
||
}
|
||
|
||
function toTaskTimestamp(value: unknown): number {
|
||
if (value instanceof Date) return value.getTime();
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
if (typeof value === "string" && value.trim()) {
|
||
const parsed = Date.parse(value);
|
||
if (Number.isFinite(parsed)) return parsed;
|
||
}
|
||
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,
|
||
taskId: String(raw.taskId || "").trim(),
|
||
provider: normalizeTaskProvider(raw.provider),
|
||
status: normalizeTaskStatus(raw.status),
|
||
mode: normalizeTaskMode(raw.mode),
|
||
prompt: String(raw.prompt || "").trim(),
|
||
modelVersion: String(raw.modelVersion || "").trim(),
|
||
createdAt: toTaskTimestamp(raw.createdAt),
|
||
progress:
|
||
raw.progress === undefined || raw.progress === null
|
||
? null
|
||
: Number.isFinite(Number(raw.progress))
|
||
? Number(raw.progress)
|
||
: null,
|
||
modelUrl: raw.modelUrl ? String(raw.modelUrl).trim() : null,
|
||
previewImageUrl: raw.previewImageUrl
|
||
? String(raw.previewImageUrl).trim()
|
||
: null,
|
||
error: raw.error ? String(raw.error).trim() : null,
|
||
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),
|
||
};
|
||
}
|
||
|
||
function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | null {
|
||
const taskId = String(entity?.taskId || "").trim();
|
||
if (!taskId) return null;
|
||
|
||
return normalizeTaskRecord({
|
||
taskId,
|
||
provider: normalizeTaskProvider(entity?.provider),
|
||
status: normalizeTaskStatus(String(entity?.status || "")),
|
||
mode: normalizeTaskMode(entity?.mode),
|
||
prompt: String(entity?.prompt || "").trim(),
|
||
modelVersion: String(entity?.modelVersion || "").trim(),
|
||
createdAt: toTaskTimestamp(entity?.createdAt),
|
||
progress:
|
||
entity?.progress === undefined || entity?.progress === null
|
||
? null
|
||
: Number(entity.progress),
|
||
modelUrl: entity?.modelUrl ? String(entity.modelUrl).trim() : null,
|
||
previewImageUrl: entity?.previewImageUrl
|
||
? String(entity.previewImageUrl).trim()
|
||
: null,
|
||
error: entity?.error ? String(entity.error).trim() : null,
|
||
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),
|
||
});
|
||
}
|
||
|
||
function taskRecordChanged(prev: TaskRecord, next: TaskRecord) {
|
||
return (
|
||
prev.status !== next.status ||
|
||
prev.progress !== next.progress ||
|
||
prev.modelUrl !== next.modelUrl ||
|
||
prev.previewImageUrl !== next.previewImageUrl ||
|
||
prev.error !== next.error ||
|
||
prev.prompt !== next.prompt ||
|
||
prev.modelVersion !== next.modelVersion ||
|
||
prev.sourceTaskId !== next.sourceTaskId ||
|
||
prev.exportFormat !== next.exportFormat ||
|
||
JSON.stringify(prev.assetPayload || null) !== JSON.stringify(next.assetPayload || null) ||
|
||
prev.provider !== next.provider
|
||
);
|
||
}
|
||
|
||
function inferWorkspaceModeFromTask(task: TaskRecord | null): WorkspaceMode {
|
||
return task?.provider === "worldlabs-3d" ? "world" : "model";
|
||
}
|
||
|
||
function taskMatchesWorkspace(task: TaskRecord, workspaceMode: WorkspaceMode) {
|
||
return workspaceMode === "world"
|
||
? task.provider === "worldlabs-3d"
|
||
: task.provider !== "worldlabs-3d";
|
||
}
|
||
|
||
function worldModeToTaskMode(mode: WorldGenerationMode): TaskMode {
|
||
switch (mode) {
|
||
case "text":
|
||
return "world-text";
|
||
case "image":
|
||
return "world-image";
|
||
case "multi-image":
|
||
return "world-multi-image";
|
||
case "video":
|
||
return "world-video";
|
||
default:
|
||
return "world-image";
|
||
}
|
||
}
|
||
|
||
function modelModeToTaskType(mode: GenerationMode) {
|
||
if (mode === "text") return "text_to_model";
|
||
if (mode === "multiview") return "multiview_to_model";
|
||
return "image_to_model";
|
||
}
|
||
|
||
function formatPointAmount(value: number) {
|
||
const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
||
return normalized.toLocaleString("en-US");
|
||
}
|
||
|
||
function formatFeatureCostLabel({
|
||
cost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
}: {
|
||
cost: number;
|
||
isHighVip?: boolean;
|
||
pointsEnabled: boolean;
|
||
}) {
|
||
if (!pointsEnabled) return "不扣积分";
|
||
if (isHighVip) return "VIP免费";
|
||
if (cost <= 0) return "免费";
|
||
return `${formatPointAmount(cost)} 积分`;
|
||
}
|
||
|
||
function formatFeatureExtraCostLabel({
|
||
cost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
}: {
|
||
cost: number;
|
||
isHighVip?: boolean;
|
||
pointsEnabled: boolean;
|
||
}) {
|
||
if (!pointsEnabled) return "不扣积分";
|
||
if (isHighVip) return "VIP免费";
|
||
if (cost <= 0) return "免费";
|
||
return `+${formatPointAmount(cost)} 积分`;
|
||
}
|
||
|
||
function CostPill({
|
||
active,
|
||
className,
|
||
label,
|
||
}: {
|
||
active?: boolean;
|
||
className?: string;
|
||
label: string;
|
||
}) {
|
||
return (
|
||
<span
|
||
title={label}
|
||
className={cn(
|
||
"inline-flex max-w-full items-center justify-center overflow-hidden truncate whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] leading-none",
|
||
active
|
||
? "bg-yellow-500/20 text-yellow-100"
|
||
: "bg-white/5 text-zinc-500",
|
||
className
|
||
)}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function CanvasModel3DPage() {
|
||
const router = useRouter();
|
||
const routeParams = useParams();
|
||
const id = String(routeParams?.id || "");
|
||
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
|
||
const { data: session } = useSession();
|
||
const user = session?.user;
|
||
|
||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||
const addNode = useCanvasStore((state) => state.addNode);
|
||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||
|
||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
|
||
const [mode, setMode] = useState<GenerationMode>("text");
|
||
const [prompt, setPrompt] = useState("");
|
||
const [negativePrompt, setNegativePrompt] = useState("");
|
||
const [modelVersion, setModelVersion] = useState(
|
||
getDefaultThreeDModelAliasId(
|
||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels,
|
||
),
|
||
);
|
||
const [faceLimit, setFaceLimit] = useState(120_000);
|
||
const [texture, setTexture] = useState(true);
|
||
const [pbr, setPbr] = useState(true);
|
||
const [autoSize, setAutoSize] = useState(false);
|
||
const [quad, setQuad] = useState(false);
|
||
const [generateParts, setGenerateParts] = useState(false);
|
||
const [smartLowPoly, setSmartLowPoly] = useState(false);
|
||
const [geometryQuality, setGeometryQuality] = useState<
|
||
"standard" | "detailed"
|
||
>("standard");
|
||
const [textureQuality, setTextureQuality] = useState<"standard" | "detailed">(
|
||
"standard"
|
||
);
|
||
const [textureAlignment, setTextureAlignment] = useState<
|
||
"original_image" | "geometry"
|
||
>("original_image");
|
||
const [orientation, setOrientation] = useState<"default" | "align_image">(
|
||
"default"
|
||
);
|
||
const [modelSeed, setModelSeed] = useState("");
|
||
const [textureSeed, setTextureSeed] = useState("");
|
||
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [uploadingImage, setUploadingImage] = useState(false);
|
||
const [referenceImage, setReferenceImage] = useState<File | null>(null);
|
||
const [referencePreviewUrl, setReferencePreviewUrl] = useState<string | null>(
|
||
null
|
||
);
|
||
const [multiViewSlots, setMultiViewSlots] = useState<MultiViewSlot[]>(() =>
|
||
createMultiViewSlots()
|
||
);
|
||
const [batchImageSlots, setBatchImageSlots] = useState<MultiViewSlot[]>(() =>
|
||
createBatchImageSlots()
|
||
);
|
||
const [worldMode, setWorldMode] = useState<WorldGenerationMode>("text");
|
||
const [worldPrompt, setWorldPrompt] = useState("");
|
||
const [worldDisplayName, setWorldDisplayName] = useState("");
|
||
const [worldModel, setWorldModel] = useState(
|
||
getDefaultThreeDModelAliasId(
|
||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels,
|
||
),
|
||
);
|
||
const [worldDisableRecaption, setWorldDisableRecaption] = useState(false);
|
||
const [worldIsPano, setWorldIsPano] = useState(false);
|
||
const [worldMakePublic, setWorldMakePublic] = useState(false);
|
||
const [worldReconstructImages, setWorldReconstructImages] = useState(false);
|
||
const [worldImageFile, setWorldImageFile] = useState<File | null>(null);
|
||
const [worldImagePreviewUrl, setWorldImagePreviewUrl] = useState<string | null>(
|
||
null
|
||
);
|
||
const [worldImageSlots, setWorldImageSlots] = useState<WorldImageSlot[]>(() =>
|
||
createWorldImageSlots()
|
||
);
|
||
const [worldVideoFile, setWorldVideoFile] = useState<File | null>(null);
|
||
const [worldVideoPreviewUrl, setWorldVideoPreviewUrl] = useState<string | null>(
|
||
null
|
||
);
|
||
const [worldViewportMode, setWorldViewportMode] =
|
||
useState<WorldViewportMode>("world");
|
||
const [worldFirstPerson, setWorldFirstPerson] = useState(true);
|
||
const [viewerResetToken, setViewerResetToken] = useState(0);
|
||
|
||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||
const [selectedWorldCanvasTaskIds, setSelectedWorldCanvasTaskIds] = useState<string[]>([]);
|
||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||
null
|
||
);
|
||
|
||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||
|
||
// Viewer Settings
|
||
const [viewerSettings, setViewerSettings] = useState<Model3DViewerSettings>({
|
||
cameraPreset: "iso",
|
||
envPreset: "city",
|
||
lightIntensity: 1,
|
||
autoRotate: false,
|
||
showGrid: true,
|
||
wireframe: false,
|
||
});
|
||
const [viewerStats, setViewerStats] = useState<Model3DStats>({
|
||
triangles: 0,
|
||
vertices: 0,
|
||
meshCount: 0,
|
||
});
|
||
const [viewerLayers, setViewerLayers] = useState<Model3DLayer[]>([]);
|
||
const [layerVisibility, setLayerVisibility] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const [isolatedLayerId, setIsolatedLayerId] = useState<string | null>(null);
|
||
|
||
// Post Processing States
|
||
const [exportFormat, setExportFormat] =
|
||
useState<(typeof EXPORT_FORMAT_OPTIONS)[number]>("GLTF");
|
||
const [stylizeStyle, setStylizeStyle] =
|
||
useState<(typeof STYLE_OPTIONS)[number]>("lego");
|
||
const [stylizeBlockSize, setStylizeBlockSize] = useState(80);
|
||
const [texturePromptText, setTexturePromptText] = useState("");
|
||
const [rigType, setRigType] =
|
||
useState<(typeof RIG_TYPE_OPTIONS)[number]>("biped");
|
||
const [rigSpec, setRigSpec] =
|
||
useState<(typeof RIG_SPEC_OPTIONS)[number]>("tripo");
|
||
const [rigFormat, setRigFormat] =
|
||
useState<(typeof RIG_FORMAT_OPTIONS)[number]>("glb");
|
||
const [retargetAnimation, setRetargetAnimation] =
|
||
useState<(typeof ANIMATION_OPTIONS)[number]>("preset:walk");
|
||
const [retargetBake, setRetargetBake] = useState(true);
|
||
const [retargetExportWithGeometry, setRetargetExportWithGeometry] =
|
||
useState(false);
|
||
const [retargetInPlace, setRetargetInPlace] = useState(false);
|
||
const [completionPartNames, setCompletionPartNames] = useState("");
|
||
const [lowpolyFaceLimit, setLowpolyFaceLimit] = useState(30_000);
|
||
const [lowpolyQuad, setLowpolyQuad] = useState(false);
|
||
const [lowpolyBake, setLowpolyBake] = useState(true);
|
||
|
||
// UI States
|
||
const [activeTab, setActiveTab] = useState<"create" | "edit" | "scene">(
|
||
"create"
|
||
);
|
||
|
||
const pollAttemptsRef = useRef(0);
|
||
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
|
||
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
|
||
|
||
// Fetch site settings for Logo
|
||
const { data: siteSettingsData } = useSWR(
|
||
"/api/site-settings?scope=branding",
|
||
fetcher
|
||
);
|
||
const { data: modelAliasConfigResponse } = useSWR<{
|
||
success?: boolean;
|
||
data?: PublicThreeDModelAliasConfig;
|
||
}>("/api/canvas/model-aliases", fetcher);
|
||
const { data: membershipSummary } = useSWR<{
|
||
pointsSystemConfig?: PointsSystemConfig;
|
||
isHighVip?: boolean;
|
||
summary?: {
|
||
currentCredits?: number;
|
||
};
|
||
} | null>("/api/user/membership-summary", async (url: string) => {
|
||
const response = await fetch(url, { cache: "no-store" });
|
||
if (!response.ok) return null;
|
||
return response.json();
|
||
});
|
||
|
||
const siteLogo = siteSettingsData?.siteLogo;
|
||
const siteLogoDark = siteSettingsData?.siteLogoDark;
|
||
const siteName = siteSettingsData?.siteName || "SparkAI";
|
||
const pointsLabel = useMemo(() => {
|
||
const rawValue = Number(membershipSummary?.summary?.currentCredits ?? 0);
|
||
const normalized = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0;
|
||
return normalized.toLocaleString("en-US");
|
||
}, [membershipSummary?.summary?.currentCredits]);
|
||
const pointsSystemConfig = useMemo(
|
||
() =>
|
||
normalizePointsSystemConfig(
|
||
membershipSummary?.pointsSystemConfig ?? defaultPointsSystemConfig
|
||
),
|
||
[membershipSummary?.pointsSystemConfig]
|
||
);
|
||
const pointsEnabled = pointsSystemConfig.enabled === true;
|
||
const isHighVip = membershipSummary?.isHighVip === true;
|
||
const publicModelAliasConfig =
|
||
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
|
||
const modelFeatureVisibility =
|
||
publicModelAliasConfig.featureVisibility ||
|
||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.featureVisibility;
|
||
const selectedBatchImageCount = useMemo(
|
||
() => batchImageSlots.filter((slot) => Boolean(slot.file)).length,
|
||
[batchImageSlots]
|
||
);
|
||
const currentModelTaskType = modelModeToTaskType(mode);
|
||
const currentModelUnitCost = useMemo(
|
||
() =>
|
||
get3DTaskCreditCost(pointsSystemConfig, {
|
||
taskType: currentModelTaskType,
|
||
pbr: modelFeatureVisibility.pbr !== false && pbr,
|
||
quad: modelFeatureVisibility.quad !== false && quad,
|
||
smartLowPoly:
|
||
modelFeatureVisibility.smartLowPoly !== false && smartLowPoly,
|
||
}),
|
||
[
|
||
currentModelTaskType,
|
||
modelFeatureVisibility.pbr,
|
||
modelFeatureVisibility.quad,
|
||
modelFeatureVisibility.smartLowPoly,
|
||
pbr,
|
||
pointsSystemConfig,
|
||
quad,
|
||
smartLowPoly,
|
||
]
|
||
);
|
||
const currentModelEstimatedCost =
|
||
mode === "batch" && selectedBatchImageCount > 0
|
||
? currentModelUnitCost * selectedBatchImageCount
|
||
: currentModelUnitCost;
|
||
const modelFeatureCostLabel = formatFeatureCostLabel({
|
||
cost: currentModelEstimatedCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const modelFeatureCostHint = (() => {
|
||
if (!pointsEnabled) return "当前未开启积分扣除";
|
||
if (isHighVip) return "VIP 用户本次不扣积分";
|
||
if (
|
||
mode === "batch" &&
|
||
selectedBatchImageCount > 0 &&
|
||
currentModelUnitCost > 0
|
||
) {
|
||
return `${selectedBatchImageCount} 张 × ${formatFeatureCostLabel({
|
||
cost: currentModelUnitCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
})}`;
|
||
}
|
||
if (mode === "batch") {
|
||
return `每张 ${formatFeatureCostLabel({
|
||
cost: currentModelUnitCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
})}`;
|
||
}
|
||
return "提交成功后扣除";
|
||
})();
|
||
const worldBillingModelId = useMemo(
|
||
() =>
|
||
resolveThreeDModelBillingId(
|
||
worldModel,
|
||
publicModelAliasConfig.worldModels
|
||
),
|
||
[publicModelAliasConfig.worldModels, worldModel]
|
||
);
|
||
const worldFeatureCost = useMemo(
|
||
() => get3DWorldCreditCost(pointsSystemConfig, worldBillingModelId),
|
||
[pointsSystemConfig, worldBillingModelId]
|
||
);
|
||
const worldFeatureCostLabel = formatFeatureCostLabel({
|
||
cost: worldFeatureCost,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const worldFeatureCostHint = !pointsEnabled
|
||
? "当前未开启积分扣除"
|
||
: isHighVip
|
||
? "VIP 用户本次不扣积分"
|
||
: "按当前生成质量扣除";
|
||
const refineCostLabel = formatFeatureCostLabel({
|
||
cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "refine_model" }),
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const textureCostLabel = formatFeatureCostLabel({
|
||
cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "texture_model" }),
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const rigCostLabel = formatFeatureCostLabel({
|
||
cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "animate_rig" }),
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const convertCostLabel = formatFeatureCostLabel({
|
||
cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "convert_model" }),
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const pbrExtraCostLabel = formatFeatureExtraCostLabel({
|
||
cost: pointsSystemConfig.creditCostPer3DModelPbr,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const quadExtraCostLabel = formatFeatureExtraCostLabel({
|
||
cost: pointsSystemConfig.creditCostPer3DModelQuad,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const smartLowPolyExtraCostLabel = formatFeatureExtraCostLabel({
|
||
cost: pointsSystemConfig.creditCostPer3DModelSmartLowPoly,
|
||
isHighVip,
|
||
pointsEnabled,
|
||
});
|
||
const userAvatarFallback = useMemo(() => {
|
||
const fromName = String(user?.name || "").trim();
|
||
if (fromName) return fromName.charAt(0).toUpperCase();
|
||
const fromEmail = String(user?.email || "").trim();
|
||
if (fromEmail) return fromEmail.charAt(0).toUpperCase();
|
||
return "U";
|
||
}, [user?.email, user?.name]);
|
||
|
||
// Display logo logic (prefer dark mode logo since we are in dark theme)
|
||
const displayLogo =
|
||
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
|
||
const tripoModelOptions = useMemo(
|
||
() => {
|
||
return publicModelAliasConfig.tripoModels.filter(
|
||
(item) => item.enabled !== false,
|
||
);
|
||
},
|
||
[publicModelAliasConfig.tripoModels],
|
||
);
|
||
const worldModelOptions = useMemo(
|
||
() => {
|
||
return publicModelAliasConfig.worldModels.filter(
|
||
(item) => item.enabled !== false,
|
||
);
|
||
},
|
||
[publicModelAliasConfig.worldModels],
|
||
);
|
||
const getWorldModelDisplayLabel = useCallback(
|
||
(value?: string | null) =>
|
||
resolveThreeDModelDisplayName(
|
||
value,
|
||
publicModelAliasConfig.worldModels,
|
||
"标准质量",
|
||
),
|
||
[publicModelAliasConfig.worldModels],
|
||
);
|
||
|
||
// Effect hooks for data loading and cleanup
|
||
useEffect(() => {
|
||
console.log("CanvasModel3DPage mounted - v3.0 Dark Mode");
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const nextDefaultTripoModelId = getDefaultThreeDModelAliasId(
|
||
publicModelAliasConfig.tripoModels,
|
||
);
|
||
if (!nextDefaultTripoModelId) return;
|
||
if (!tripoModelOptions.some((item) => item.id === modelVersion)) {
|
||
setModelVersion(nextDefaultTripoModelId);
|
||
}
|
||
}, [modelVersion, publicModelAliasConfig.tripoModels, tripoModelOptions]);
|
||
|
||
useEffect(() => {
|
||
const nextDefaultWorldModelId = getDefaultThreeDModelAliasId(
|
||
publicModelAliasConfig.worldModels,
|
||
);
|
||
if (!nextDefaultWorldModelId) return;
|
||
if (!worldModelOptions.some((item) => item.id === worldModel)) {
|
||
setWorldModel(nextDefaultWorldModelId);
|
||
}
|
||
}, [publicModelAliasConfig.worldModels, worldModel, worldModelOptions]);
|
||
|
||
useEffect(() => {
|
||
const html = document.documentElement;
|
||
const body = document.body;
|
||
const prevHtmlOverscrollX = html.style.overscrollBehaviorX;
|
||
const prevBodyOverscrollX = body.style.overscrollBehaviorX;
|
||
|
||
html.style.overscrollBehaviorX = "none";
|
||
body.style.overscrollBehaviorX = "none";
|
||
|
||
const preventHistorySwipe = (event: WheelEvent) => {
|
||
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
|
||
if (event.cancelable) {
|
||
event.preventDefault();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("wheel", preventHistorySwipe, { passive: false });
|
||
|
||
return () => {
|
||
window.removeEventListener("wheel", preventHistorySwipe);
|
||
html.style.overscrollBehaviorX = prevHtmlOverscrollX;
|
||
body.style.overscrollBehaviorX = prevBodyOverscrollX;
|
||
};
|
||
}, []);
|
||
|
||
const persistTaskRecord = useCallback(
|
||
async (task: TaskRecord) => {
|
||
if (!id) return;
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
asset: {
|
||
taskId: task.taskId,
|
||
provider: task.provider,
|
||
status: task.status,
|
||
mode: task.mode,
|
||
prompt: task.prompt,
|
||
modelVersion: task.modelVersion,
|
||
progress: task.progress ?? null,
|
||
modelUrl: task.modelUrl ?? null,
|
||
previewImageUrl: task.previewImageUrl ?? null,
|
||
error: task.error ?? null,
|
||
sourceTaskId: task.sourceTaskId ?? null,
|
||
exportFormat: task.exportFormat ?? null,
|
||
assetPayload: task.assetPayload ?? null,
|
||
createdAt: task.createdAt,
|
||
},
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => ({}));
|
||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||
}
|
||
} catch (error) {
|
||
console.error("[Model3DPage] 持久化 3D 资产失败:", error);
|
||
}
|
||
},
|
||
[id]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
let cancelled = false;
|
||
|
||
const migrateLegacyHistory = async () => {
|
||
if (typeof window === "undefined") return [] as TaskRecord[];
|
||
const raw = window.localStorage.getItem(legacyHistoryStorageKey);
|
||
if (!raw) return [] as TaskRecord[];
|
||
|
||
try {
|
||
const parsed = JSON.parse(raw) as TaskRecord[];
|
||
if (!Array.isArray(parsed)) {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
return [] as TaskRecord[];
|
||
}
|
||
|
||
const normalized = parsed
|
||
.filter((item) => item && typeof item.taskId === "string")
|
||
.map((item) => normalizeTaskRecord(item))
|
||
.slice(0, TASK_HISTORY_LIMIT);
|
||
|
||
for (const item of normalized) {
|
||
await persistTaskRecord(item);
|
||
}
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
return normalized;
|
||
} catch {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
return [] as TaskRecord[];
|
||
}
|
||
};
|
||
|
||
const loadTaskHistory = async () => {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
cache: "no-store",
|
||
});
|
||
const payload = (await response.json().catch(() => ({}))) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
data?: TaskRecordApiEntity[];
|
||
};
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "加载 3D 资产失败");
|
||
}
|
||
const list: TaskRecordApiEntity[] = Array.isArray(payload?.data)
|
||
? payload.data
|
||
: [];
|
||
|
||
let normalized = list
|
||
.map((item) => mapApiEntityToTaskRecord(item))
|
||
.filter((item): item is TaskRecord => Boolean(item))
|
||
.slice(0, TASK_HISTORY_LIMIT);
|
||
|
||
if (normalized.length === 0) {
|
||
normalized = await migrateLegacyHistory();
|
||
} else if (typeof window !== "undefined") {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
}
|
||
|
||
if (cancelled) return;
|
||
setTaskHistory(normalized);
|
||
const pending = normalized.find(
|
||
(item) => !FINAL_TASK_STATUS.has(item.status)
|
||
);
|
||
setCurrentTask(pending || normalized[0] || null);
|
||
} catch (error) {
|
||
console.error("[Model3DPage] 加载 3D 资产库失败:", error);
|
||
const migrated = await migrateLegacyHistory();
|
||
if (cancelled) return;
|
||
setTaskHistory(migrated);
|
||
const pending = migrated.find(
|
||
(item) => !FINAL_TASK_STATUS.has(item.status)
|
||
);
|
||
const initialTask = pending || migrated[0] || null;
|
||
setCurrentTask(initialTask);
|
||
setWorkspaceMode(inferWorkspaceModeFromTask(initialTask));
|
||
}
|
||
};
|
||
|
||
void loadTaskHistory();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [id, legacyHistoryStorageKey, persistTaskRecord]);
|
||
|
||
useEffect(() => {
|
||
if (!referenceImage) {
|
||
if (referencePreviewUrl) {
|
||
URL.revokeObjectURL(referencePreviewUrl);
|
||
}
|
||
setReferencePreviewUrl(null);
|
||
return;
|
||
}
|
||
const objectUrl = URL.createObjectURL(referenceImage);
|
||
setReferencePreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return objectUrl;
|
||
});
|
||
return () => {
|
||
URL.revokeObjectURL(objectUrl);
|
||
};
|
||
}, [referenceImage]);
|
||
|
||
useEffect(() => {
|
||
if (!worldImageFile) {
|
||
if (worldImagePreviewUrl) {
|
||
URL.revokeObjectURL(worldImagePreviewUrl);
|
||
}
|
||
setWorldImagePreviewUrl(null);
|
||
return;
|
||
}
|
||
const objectUrl = URL.createObjectURL(worldImageFile);
|
||
setWorldImagePreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return objectUrl;
|
||
});
|
||
return () => {
|
||
URL.revokeObjectURL(objectUrl);
|
||
};
|
||
}, [worldImageFile]);
|
||
|
||
useEffect(() => {
|
||
if (!worldVideoFile) {
|
||
if (worldVideoPreviewUrl) {
|
||
URL.revokeObjectURL(worldVideoPreviewUrl);
|
||
}
|
||
setWorldVideoPreviewUrl(null);
|
||
return;
|
||
}
|
||
const objectUrl = URL.createObjectURL(worldVideoFile);
|
||
setWorldVideoPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return objectUrl;
|
||
});
|
||
return () => {
|
||
URL.revokeObjectURL(objectUrl);
|
||
};
|
||
}, [worldVideoFile]);
|
||
|
||
useEffect(() => {
|
||
multiViewSlotsRef.current = multiViewSlots;
|
||
}, [multiViewSlots]);
|
||
|
||
useEffect(() => {
|
||
batchImageSlotsRef.current = batchImageSlots;
|
||
}, [batchImageSlots]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const slot of multiViewSlotsRef.current) {
|
||
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
|
||
}
|
||
for (const slot of batchImageSlotsRef.current) {
|
||
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
|
||
}
|
||
for (const slot of worldImageSlots) {
|
||
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
|
||
}
|
||
};
|
||
}, [worldImageSlots]);
|
||
|
||
const upsertTaskHistory = useCallback(
|
||
(next: TaskRecord, options?: { persist?: boolean }) => {
|
||
const normalized = normalizeTaskRecord(next);
|
||
setTaskHistory((prev) => {
|
||
const index = prev.findIndex((item) => item.taskId === normalized.taskId);
|
||
if (index >= 0) {
|
||
const updated = [...prev];
|
||
updated[index] = { ...updated[index], ...normalized };
|
||
return updated.slice(0, TASK_HISTORY_LIMIT);
|
||
}
|
||
return [normalized, ...prev].slice(0, TASK_HISTORY_LIMIT);
|
||
});
|
||
setCurrentTask((prev) =>
|
||
prev?.taskId === normalized.taskId ? { ...prev, ...normalized } : prev
|
||
);
|
||
|
||
if (options?.persist !== false) {
|
||
void persistTaskRecord(normalized);
|
||
}
|
||
},
|
||
[persistTaskRecord]
|
||
);
|
||
|
||
const effectiveLayerVisibility = useMemo(() => {
|
||
if (!isolatedLayerId) return layerVisibility;
|
||
const next: Record<string, boolean> = {};
|
||
for (const layer of viewerLayers) {
|
||
next[layer.id] =
|
||
layer.id === isolatedLayerId && layerVisibility[layer.id] !== false;
|
||
}
|
||
return next;
|
||
}, [isolatedLayerId, layerVisibility, viewerLayers]);
|
||
|
||
const visibleTaskHistory = useMemo(
|
||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||
[taskHistory, workspaceMode]
|
||
);
|
||
const shareableWorldTasks = useMemo(
|
||
() =>
|
||
visibleTaskHistory.filter(
|
||
(task) => task.provider === "worldlabs-3d" && task.status === "success"
|
||
),
|
||
[visibleTaskHistory]
|
||
);
|
||
const importableWorldTasks = useMemo(
|
||
() =>
|
||
visibleTaskHistory.filter(
|
||
(task) => task.provider === "worldlabs-3d" && canImportTaskToCanvas(task)
|
||
),
|
||
[visibleTaskHistory]
|
||
);
|
||
const selectedWorldShareTasks = useMemo(() => {
|
||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
||
const selectedWorldCanvasTasks = useMemo(() => {
|
||
const selectedIds = new Set(selectedWorldCanvasTaskIds);
|
||
return importableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||
}, [selectedWorldCanvasTaskIds, importableWorldTasks]);
|
||
|
||
useEffect(() => {
|
||
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
|
||
setSelectedWorldShareTaskIds((prev) => {
|
||
const next = prev.filter((taskId) => shareableIds.has(taskId));
|
||
return next.length === prev.length ? prev : next;
|
||
});
|
||
}, [shareableWorldTasks]);
|
||
|
||
useEffect(() => {
|
||
const importableIds = new Set(importableWorldTasks.map((task) => task.taskId));
|
||
setSelectedWorldCanvasTaskIds((prev) => {
|
||
const next = prev.filter((taskId) => importableIds.has(taskId));
|
||
return next.length === prev.length ? prev : next;
|
||
});
|
||
}, [importableWorldTasks]);
|
||
|
||
const currentModelUrl = resolveTaskModelUrl(currentTask);
|
||
const currentPreviewImage =
|
||
currentTask?.previewImageUrl ||
|
||
(workspaceMode === "model"
|
||
? mode === "image"
|
||
? referencePreviewUrl
|
||
: mode === "multiview"
|
||
? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl
|
||
: mode === "batch"
|
||
? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl
|
||
: null
|
||
: worldMode === "image"
|
||
? worldImagePreviewUrl
|
||
: worldMode === "multi-image"
|
||
? worldImageSlots.find((slot) => slot.previewUrl)?.previewUrl
|
||
: worldMode === "video"
|
||
? worldVideoPreviewUrl
|
||
: null) ||
|
||
null;
|
||
|
||
useEffect(() => {
|
||
if (currentTask && taskMatchesWorkspace(currentTask, workspaceMode)) {
|
||
return;
|
||
}
|
||
|
||
const fallbackTask = taskHistory.find((task) =>
|
||
taskMatchesWorkspace(task, workspaceMode)
|
||
);
|
||
if (fallbackTask) {
|
||
setCurrentTask(fallbackTask);
|
||
return;
|
||
}
|
||
|
||
if (currentTask) {
|
||
setCurrentTask(null);
|
||
}
|
||
}, [currentTask, taskHistory, workspaceMode]);
|
||
|
||
// Polling logic
|
||
useEffect(() => {
|
||
if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) {
|
||
return;
|
||
}
|
||
|
||
pollAttemptsRef.current = 0;
|
||
let cancelled = false;
|
||
|
||
const runPoll = async () => {
|
||
if (cancelled) return;
|
||
pollAttemptsRef.current += 1;
|
||
|
||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||
const result =
|
||
isWorldTask
|
||
? await worldLabsGenerationService.getTask(currentTask.taskId, {
|
||
providerId: currentTask.assetPayload?.providerId,
|
||
})
|
||
: await model3DGenerationService.getTask(currentTask.taskId, {
|
||
providerId: currentTask.assetPayload?.providerId,
|
||
});
|
||
if (!result.success || !result.data) {
|
||
const failMessage = result.error || "任务查询失败";
|
||
const failedRecord: TaskRecord = {
|
||
...currentTask,
|
||
status: "failed",
|
||
error: failMessage,
|
||
};
|
||
setCurrentTask(failedRecord);
|
||
upsertTaskHistory(failedRecord);
|
||
toast.error(failMessage);
|
||
return;
|
||
}
|
||
|
||
const status = normalizeTaskStatus(result.data.status);
|
||
const worldResultData = isWorldTask
|
||
? (result.data as NonNullable<
|
||
Awaited<ReturnType<typeof worldLabsGenerationService.getTask>>["data"]
|
||
>)
|
||
: null;
|
||
const tripoResultData = !isWorldTask
|
||
? (result.data as NonNullable<
|
||
Awaited<ReturnType<typeof model3DGenerationService.getTask>>["data"]
|
||
>)
|
||
: null;
|
||
const nextRecord: TaskRecord = {
|
||
...currentTask,
|
||
status,
|
||
progress: result.data.progress ?? null,
|
||
modelUrl: result.data.modelUrl ?? currentTask.modelUrl ?? null,
|
||
previewImageUrl:
|
||
result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null,
|
||
sourceTaskId:
|
||
isWorldTask
|
||
? worldResultData?.worldId ?? currentTask.sourceTaskId ?? null
|
||
: currentTask.sourceTaskId ?? null,
|
||
assetPayload:
|
||
isWorldTask
|
||
? worldResultData?.assetPayload ?? currentTask.assetPayload ?? null
|
||
: tripoResultData?.assetPayload
|
||
? {
|
||
...(currentTask.assetPayload || {}),
|
||
...tripoResultData.assetPayload,
|
||
}
|
||
: currentTask.assetPayload ?? null,
|
||
error:
|
||
status === "failed" ? result.data.errorMessage || "任务失败" : null,
|
||
};
|
||
if (taskRecordChanged(currentTask, nextRecord)) {
|
||
setCurrentTask(nextRecord);
|
||
upsertTaskHistory(nextRecord);
|
||
}
|
||
|
||
if (status === "success") {
|
||
toast.success(`${modeLabel(nextRecord.mode)}任务已完成`);
|
||
if (
|
||
autoDownloadTaskId &&
|
||
autoDownloadTaskId === nextRecord.taskId &&
|
||
nextRecord.modelUrl
|
||
) {
|
||
const link = document.createElement("a");
|
||
link.href = nextRecord.modelUrl;
|
||
link.download = `${
|
||
nextRecord.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
|
||
}-${nextRecord.taskId}.${extractModelFormat(nextRecord.modelUrl)}`;
|
||
link.click();
|
||
setAutoDownloadTaskId(null);
|
||
}
|
||
}
|
||
|
||
if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) {
|
||
const timeoutRecord: TaskRecord = {
|
||
...nextRecord,
|
||
status: "failed",
|
||
error: "任务轮询超时,请稍后重试",
|
||
};
|
||
setCurrentTask(timeoutRecord);
|
||
upsertTaskHistory(timeoutRecord);
|
||
toast.error("任务轮询超时,请稍后重试");
|
||
}
|
||
};
|
||
|
||
void runPoll();
|
||
const timer = window.setInterval(() => {
|
||
void runPoll();
|
||
}, 2000);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [autoDownloadTaskId, currentTask, upsertTaskHistory]);
|
||
|
||
// File handling functions
|
||
const handleMultiViewFileChange = (index: number, file: File | null) => {
|
||
setMultiViewSlots((prev) => {
|
||
const next = [...prev];
|
||
const target = next[index];
|
||
if (!target) return prev;
|
||
if (target.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||
next[index] = {
|
||
...target,
|
||
file,
|
||
previewUrl: file ? URL.createObjectURL(file) : null,
|
||
};
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const clearMultiViewSlot = (index: number) => {
|
||
handleMultiViewFileChange(index, null);
|
||
};
|
||
|
||
const setBatchFiles = (files: File[]) => {
|
||
const nextFiles = files.slice(0, 10);
|
||
setBatchImageSlots((prev) => {
|
||
for (const slot of prev) {
|
||
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
|
||
}
|
||
return createBatchImageSlots().map((slot, index) => {
|
||
const file = nextFiles[index] || null;
|
||
return {
|
||
...slot,
|
||
file,
|
||
previewUrl: file ? URL.createObjectURL(file) : null,
|
||
};
|
||
});
|
||
});
|
||
};
|
||
|
||
const handleBatchPickerChange = (files: FileList | null) => {
|
||
const nextFiles = files ? Array.from(files) : [];
|
||
setBatchFiles(nextFiles);
|
||
};
|
||
|
||
const handleBatchFileChange = (index: number, file: File | null) => {
|
||
setBatchImageSlots((prev) => {
|
||
const next = [...prev];
|
||
const target = next[index];
|
||
if (!target) return prev;
|
||
if (target.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||
next[index] = {
|
||
...target,
|
||
file,
|
||
previewUrl: file ? URL.createObjectURL(file) : null,
|
||
};
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const clearBatchSlot = (index: number) => {
|
||
handleBatchFileChange(index, null);
|
||
};
|
||
|
||
const handleWorldImageSlotChange = (index: number, file: File | null) => {
|
||
setWorldImageSlots((prev) => {
|
||
const next = [...prev];
|
||
const target = next[index];
|
||
if (!target) return prev;
|
||
if (target.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||
next[index] = {
|
||
...target,
|
||
file,
|
||
previewUrl: file ? URL.createObjectURL(file) : null,
|
||
};
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleWorldImageAzimuthChange = (index: number, azimuth: number) => {
|
||
setWorldImageSlots((prev) => {
|
||
const next = [...prev];
|
||
const target = next[index];
|
||
if (!target) return prev;
|
||
next[index] = {
|
||
...target,
|
||
azimuth,
|
||
};
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Task submission functions
|
||
const buildGenerationPayload = (
|
||
taskType: "text_to_model" | "image_to_model" | "multiview_to_model"
|
||
) => {
|
||
const payload: Record<string, any> = {
|
||
taskType,
|
||
prompt: prompt.trim(),
|
||
negativePrompt: negativePrompt.trim(),
|
||
modelVersion,
|
||
faceLimit,
|
||
texture,
|
||
pbr: modelFeatureVisibility.pbr !== false && pbr,
|
||
autoSize,
|
||
quad: modelFeatureVisibility.quad !== false && quad,
|
||
generateParts,
|
||
smartLowPoly:
|
||
modelFeatureVisibility.smartLowPoly !== false && smartLowPoly,
|
||
geometryQuality,
|
||
textureQuality,
|
||
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
|
||
textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined,
|
||
};
|
||
|
||
if (taskType === "image_to_model" || taskType === "multiview_to_model") {
|
||
payload.textureAlignment = textureAlignment;
|
||
payload.orientation = orientation;
|
||
}
|
||
return payload;
|
||
};
|
||
|
||
const submitTask = async (params: {
|
||
provider: TaskProvider;
|
||
payload: Record<string, any>;
|
||
mode: TaskMode;
|
||
promptText: string;
|
||
modelLabel: string;
|
||
sourceTaskId?: string | null;
|
||
exportFormat?: string | null;
|
||
assetPayload?: Model3DTaskAssetPayload | null;
|
||
}) => {
|
||
const normalizedPayload =
|
||
params.provider === "tripo-3d" &&
|
||
!String(params.payload.providerId || "").trim() &&
|
||
params.assetPayload?.providerId
|
||
? {
|
||
...params.payload,
|
||
providerId: params.assetPayload.providerId,
|
||
}
|
||
: params.payload;
|
||
const createResult =
|
||
params.provider === "worldlabs-3d"
|
||
? await worldLabsGenerationService.createTask(normalizedPayload)
|
||
: await model3DGenerationService.createTask(normalizedPayload);
|
||
if (!createResult.success || !createResult.data?.taskId) {
|
||
throw new Error(createResult.error || "创建任务失败");
|
||
}
|
||
const worldCreateResultData =
|
||
params.provider === "worldlabs-3d" &&
|
||
"assetPayload" in createResult.data
|
||
? createResult.data
|
||
: null;
|
||
const tripoCreateResultData =
|
||
params.provider === "tripo-3d"
|
||
? (createResult.data as {
|
||
providerId?: string | null;
|
||
providerName?: string | null;
|
||
})
|
||
: null;
|
||
const tripoAssetPayload =
|
||
params.provider === "tripo-3d"
|
||
? {
|
||
...(params.assetPayload || {}),
|
||
providerId:
|
||
tripoCreateResultData?.providerId ||
|
||
params.assetPayload?.providerId ||
|
||
null,
|
||
providerName:
|
||
tripoCreateResultData?.providerName ||
|
||
params.assetPayload?.providerName ||
|
||
null,
|
||
}
|
||
: null;
|
||
|
||
const newTask: TaskRecord = {
|
||
taskId: createResult.data.taskId,
|
||
provider: params.provider,
|
||
status: normalizeTaskStatus(createResult.data.status),
|
||
prompt: params.promptText,
|
||
mode: params.mode,
|
||
modelVersion: params.modelLabel,
|
||
createdAt: Date.now(),
|
||
sourceTaskId: params.sourceTaskId || null,
|
||
exportFormat: params.exportFormat || null,
|
||
assetPayload:
|
||
params.provider === "worldlabs-3d"
|
||
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
|
||
: tripoAssetPayload,
|
||
};
|
||
setCurrentTask(newTask);
|
||
upsertTaskHistory(newTask);
|
||
return newTask;
|
||
};
|
||
|
||
const getReadyTask = () => {
|
||
if (!currentTask || currentTask.status !== "success") {
|
||
toast.error("请先完成一个成功任务");
|
||
return null;
|
||
}
|
||
return currentTask;
|
||
};
|
||
|
||
const submitDerivedTask = async (params: {
|
||
payload: Record<string, any>;
|
||
mode: TaskMode;
|
||
promptText: string;
|
||
sourceTaskId: string;
|
||
successMessage: string;
|
||
assetPayload?: Model3DTaskAssetPayload | null;
|
||
modelLabel?: string;
|
||
}) => {
|
||
setIsSubmitting(true);
|
||
try {
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload: params.payload,
|
||
mode: params.mode,
|
||
promptText: params.promptText,
|
||
modelLabel: params.modelLabel || modelVersion,
|
||
sourceTaskId: params.sourceTaskId,
|
||
assetPayload: params.assetPayload ?? null,
|
||
});
|
||
toast.success(params.successMessage);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "提交任务失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleGenerate = async () => {
|
||
const normalizedPrompt = prompt.trim();
|
||
if (mode === "text" && !normalizedPrompt) {
|
||
toast.error("请输入提示词");
|
||
return;
|
||
}
|
||
if (mode === "image" && !referenceImage) {
|
||
toast.error("图生3D请先上传参考图");
|
||
return;
|
||
}
|
||
const selectedMultiFiles = multiViewSlots
|
||
.map((slot) => slot.file)
|
||
.filter(Boolean) as File[];
|
||
const selectedBatchFiles = batchImageSlots
|
||
.map((slot) => slot.file)
|
||
.filter(Boolean) as File[];
|
||
if (mode === "multiview" && selectedMultiFiles.length < 2) {
|
||
toast.error("多视图生成至少上传 2 张图片");
|
||
return;
|
||
}
|
||
if (mode === "batch" && selectedBatchFiles.length < 1) {
|
||
toast.error("批量图生请至少上传 1 张图片");
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
setUploadingImage(false);
|
||
try {
|
||
if (mode === "text") {
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload: buildGenerationPayload("text_to_model"),
|
||
mode: "text",
|
||
promptText: normalizedPrompt,
|
||
modelLabel: modelVersion,
|
||
});
|
||
toast.success("已提交文生3D任务");
|
||
return;
|
||
}
|
||
|
||
if (mode === "image") {
|
||
setUploadingImage(true);
|
||
const uploadResult = await model3DGenerationService.uploadImage(
|
||
referenceImage as File
|
||
);
|
||
if (!uploadResult.success || !uploadResult.data) {
|
||
throw new Error(uploadResult.error || "上传参考图失败");
|
||
}
|
||
const payload = buildGenerationPayload("image_to_model");
|
||
if (uploadResult.data.imageToken) {
|
||
payload.imageToken = uploadResult.data.imageToken;
|
||
} else if (uploadResult.data.file) {
|
||
payload.file = uploadResult.data.file;
|
||
} else {
|
||
throw new Error("参考图上传未返回可用 token");
|
||
}
|
||
if (uploadResult.data.providerId) {
|
||
payload.providerId = uploadResult.data.providerId;
|
||
}
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "image",
|
||
promptText: normalizedPrompt || "图生3D",
|
||
modelLabel: modelVersion,
|
||
assetPayload: {
|
||
providerId: uploadResult.data.providerId || null,
|
||
providerName: uploadResult.data.providerName || null,
|
||
},
|
||
});
|
||
toast.success("已提交图生3D任务");
|
||
return;
|
||
}
|
||
|
||
if (mode === "batch") {
|
||
setUploadingImage(true);
|
||
let successCount = 0;
|
||
for (const file of selectedBatchFiles.slice(0, 10)) {
|
||
const uploadResult = await model3DGenerationService.uploadImage(file);
|
||
if (!uploadResult.success || !uploadResult.data) {
|
||
throw new Error(uploadResult.error || `上传失败: ${file.name}`);
|
||
}
|
||
const payload = buildGenerationPayload("image_to_model");
|
||
if (uploadResult.data.imageToken) {
|
||
payload.imageToken = uploadResult.data.imageToken;
|
||
} else if (uploadResult.data.file) {
|
||
payload.file = uploadResult.data.file;
|
||
} else {
|
||
throw new Error(`图片未返回可用 token: ${file.name}`);
|
||
}
|
||
if (uploadResult.data.providerId) {
|
||
payload.providerId = uploadResult.data.providerId;
|
||
}
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "batch",
|
||
promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`,
|
||
modelLabel: modelVersion,
|
||
assetPayload: {
|
||
providerId: uploadResult.data.providerId || null,
|
||
providerName: uploadResult.data.providerName || null,
|
||
},
|
||
});
|
||
successCount += 1;
|
||
}
|
||
toast.success(`已提交 ${successCount} 个批量图生3D任务`);
|
||
return;
|
||
}
|
||
|
||
setUploadingImage(true);
|
||
const uploadResults: Array<
|
||
Awaited<ReturnType<typeof model3DGenerationService.uploadImage>>
|
||
> = [];
|
||
let boundTripoProviderId: string | null = null;
|
||
let boundTripoProviderName: string | null = null;
|
||
for (const file of selectedMultiFiles) {
|
||
const result = await model3DGenerationService.uploadImage(file, {
|
||
providerId: boundTripoProviderId,
|
||
});
|
||
if (result.success && result.data?.providerId && !boundTripoProviderId) {
|
||
boundTripoProviderId = result.data.providerId;
|
||
boundTripoProviderName = result.data.providerName || null;
|
||
}
|
||
uploadResults.push(result);
|
||
}
|
||
const filesPayload: Array<Record<string, any>> = [];
|
||
for (const result of uploadResults) {
|
||
if (!result.success || !result.data) {
|
||
throw new Error(result.error || "上传多视图图片失败");
|
||
}
|
||
if (result.data.imageToken) {
|
||
filesPayload.push({
|
||
type: "jpg",
|
||
file_token: result.data.imageToken,
|
||
});
|
||
} else if (result.data.file) {
|
||
filesPayload.push(result.data.file);
|
||
}
|
||
}
|
||
if (filesPayload.length < 2) {
|
||
throw new Error("多视图图片上传失败,请重试");
|
||
}
|
||
const payload = buildGenerationPayload("multiview_to_model");
|
||
payload.files = filesPayload;
|
||
if (boundTripoProviderId) {
|
||
payload.providerId = boundTripoProviderId;
|
||
}
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "multiview",
|
||
promptText: normalizedPrompt || "多视图3D",
|
||
modelLabel: modelVersion,
|
||
assetPayload: {
|
||
providerId: boundTripoProviderId,
|
||
providerName: boundTripoProviderName,
|
||
},
|
||
});
|
||
toast.success("已提交多视图3D任务");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建任务失败");
|
||
} finally {
|
||
setUploadingImage(false);
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleWorldGenerate = async () => {
|
||
const normalizedPrompt = worldPrompt.trim();
|
||
const selectedWorldImages = worldImageSlots.filter((slot) => slot.file);
|
||
|
||
if (worldMode === "text" && !normalizedPrompt) {
|
||
toast.error("请输入世界描述");
|
||
return;
|
||
}
|
||
if (worldMode === "image" && !worldImageFile) {
|
||
toast.error("图生世界请先上传参考图");
|
||
return;
|
||
}
|
||
if (worldMode === "multi-image" && selectedWorldImages.length < 2) {
|
||
toast.error("多图世界至少上传 2 张图片");
|
||
return;
|
||
}
|
||
if (worldMode === "video" && !worldVideoFile) {
|
||
toast.error("视频世界请先上传视频");
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
setUploadingImage(false);
|
||
|
||
try {
|
||
if (worldMode === "text") {
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "text",
|
||
prompt: normalizedPrompt,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
disableRecaption: worldDisableRecaption,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-text",
|
||
promptText: normalizedPrompt || "文生世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交文生世界任务");
|
||
return;
|
||
}
|
||
|
||
if (worldMode === "image") {
|
||
setUploadingImage(true);
|
||
const uploadResult = await worldLabsGenerationService.uploadMedia(
|
||
worldImageFile as File,
|
||
"image"
|
||
);
|
||
const uploadData = uploadResult.data || null;
|
||
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||
const sourceUrl = uploadData?.sourceUrl || null;
|
||
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||
throw new Error(uploadResult.error || "上传世界参考图失败");
|
||
}
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "image",
|
||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||
providerId: uploadData.providerId,
|
||
prompt: normalizedPrompt || undefined,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
disableRecaption: worldDisableRecaption,
|
||
isPano: worldIsPano,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-image",
|
||
promptText: normalizedPrompt || "图生世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交图生世界任务");
|
||
return;
|
||
}
|
||
|
||
if (worldMode === "video") {
|
||
setUploadingImage(true);
|
||
const uploadResult = await worldLabsGenerationService.uploadMedia(
|
||
worldVideoFile as File,
|
||
"video"
|
||
);
|
||
const uploadData = uploadResult.data || null;
|
||
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||
const sourceUrl = uploadData?.sourceUrl || null;
|
||
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||
throw new Error(uploadResult.error || "上传世界视频失败");
|
||
}
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "video",
|
||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||
providerId: uploadData.providerId,
|
||
prompt: normalizedPrompt || undefined,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-video",
|
||
promptText: normalizedPrompt || worldVideoFile?.name || "视频世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交视频世界任务");
|
||
return;
|
||
}
|
||
|
||
setUploadingImage(true);
|
||
let boundWorldProviderId: string | null = null;
|
||
const uploads: Array<{
|
||
slot: WorldImageSlot;
|
||
result: Awaited<ReturnType<typeof worldLabsGenerationService.uploadMedia>>;
|
||
}> = [];
|
||
for (const slot of selectedWorldImages) {
|
||
const result = await worldLabsGenerationService.uploadMedia(
|
||
slot.file as File,
|
||
"image",
|
||
{
|
||
providerId: boundWorldProviderId,
|
||
}
|
||
);
|
||
if (result.success && result.data?.providerId && !boundWorldProviderId) {
|
||
boundWorldProviderId = result.data.providerId;
|
||
}
|
||
uploads.push({ slot, result });
|
||
}
|
||
|
||
const mediaAssets = uploads.map(({ slot, result }) => {
|
||
const mediaAssetId = result.data?.mediaAssetId || null;
|
||
const sourceUrl = result.data?.sourceUrl || null;
|
||
if (!result.success || (!mediaAssetId && !sourceUrl)) {
|
||
throw new Error(result.error || "上传多图世界图片失败");
|
||
}
|
||
return {
|
||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||
azimuth: slot.azimuth,
|
||
};
|
||
});
|
||
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "multi-image",
|
||
providerId: boundWorldProviderId,
|
||
mediaAssets,
|
||
prompt: normalizedPrompt || undefined,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
disableRecaption: worldDisableRecaption,
|
||
reconstructImages: worldReconstructImages,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-multi-image",
|
||
promptText: normalizedPrompt || "多图世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交多图世界任务");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建世界任务失败");
|
||
} finally {
|
||
setUploadingImage(false);
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleImportTasksToCanvas = async (tasks: TaskRecord[]) => {
|
||
const importableTasks = tasks.filter(canImportTaskToCanvas);
|
||
if (importableTasks.length === 0) {
|
||
toast.error("暂无可添加的 3D 资产");
|
||
return;
|
||
}
|
||
const viewportX = viewport.x || 0;
|
||
const viewportY = viewport.y || 0;
|
||
const viewportZoom = viewport.zoom || 1;
|
||
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
|
||
const stageHeight =
|
||
typeof window !== "undefined" ? window.innerHeight : 720;
|
||
const targetWidth = 380;
|
||
const targetHeight = 300;
|
||
const existing3dNodeCount = nodes.filter(
|
||
(node) => node.type === "world3d" || node.type === "model3d"
|
||
).length;
|
||
|
||
importableTasks.forEach((task, index) => {
|
||
const isWorldTask = task.provider === "worldlabs-3d";
|
||
const modelUrl = resolveTaskModelUrl(task);
|
||
const worldAssetPayload = task.assetPayload ?? null;
|
||
const primarySplatUrl = resolveWorldTaskSplatUrl(task);
|
||
const worldImageUrl = resolveWorldTaskImageUrl(task);
|
||
const position = resolveCanvasImportPosition({
|
||
index: existing3dNodeCount + index,
|
||
totalCount: existing3dNodeCount + importableTasks.length,
|
||
viewportX,
|
||
viewportY,
|
||
viewportZoom,
|
||
stageWidth,
|
||
stageHeight,
|
||
targetWidth,
|
||
targetHeight,
|
||
});
|
||
|
||
addNode(
|
||
isWorldTask
|
||
? {
|
||
type: "world3d",
|
||
position,
|
||
size: { width: targetWidth, height: targetHeight },
|
||
data: {
|
||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||
imageUrl: worldImageUrl || undefined,
|
||
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
|
||
splatUrl: primarySplatUrl || undefined,
|
||
previewImage: resolveWorldTaskPreviewUrl(task) || undefined,
|
||
prompt: task.prompt,
|
||
modelVersion: task.modelVersion,
|
||
status: "completed",
|
||
activeView: primarySplatUrl ? "splat" : worldImageUrl ? "image" : "mesh",
|
||
firstPerson: false,
|
||
hotspots: [],
|
||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||
metadata: {
|
||
taskId: task.taskId,
|
||
provider: task.provider,
|
||
mode: task.mode,
|
||
sourceTaskId: task.sourceTaskId,
|
||
progress: task.progress,
|
||
triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
|
||
vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
|
||
meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
|
||
assetPayload: task.assetPayload ?? null,
|
||
},
|
||
},
|
||
connections: [],
|
||
}
|
||
: {
|
||
type: "model3d",
|
||
position,
|
||
size: { width: targetWidth, height: targetHeight },
|
||
data: {
|
||
src: modelUrl!,
|
||
previewImage: task.previewImageUrl || undefined,
|
||
prompt: task.prompt,
|
||
modelVersion: task.modelVersion,
|
||
format: extractModelFormat(modelUrl!),
|
||
status: "completed",
|
||
metadata: {
|
||
taskId: task.taskId,
|
||
provider: task.provider,
|
||
mode: task.mode,
|
||
sourceTaskId: task.sourceTaskId,
|
||
progress: task.progress,
|
||
triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
|
||
vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
|
||
meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
|
||
assetPayload: task.assetPayload ?? null,
|
||
},
|
||
},
|
||
connections: [],
|
||
}
|
||
);
|
||
});
|
||
|
||
try {
|
||
await canvasService.saveCanvasState(id, getCanvasState());
|
||
} catch (error) {
|
||
console.error("[Model3DPage] 保存画布失败:", error);
|
||
}
|
||
toast.success(
|
||
importableTasks.length > 1
|
||
? `已添加 ${importableTasks.length} 个 3D 世界到画布`
|
||
: importableTasks[0]?.provider === "worldlabs-3d"
|
||
? "3D 世界已添加到画布"
|
||
: "3D 模型已添加到画布"
|
||
);
|
||
};
|
||
|
||
const handleImportToCanvas = async () => {
|
||
if (!currentTask || currentTask.status !== "success") {
|
||
toast.error("暂无可添加的 3D 资产");
|
||
return;
|
||
}
|
||
await handleImportTasksToCanvas([currentTask]);
|
||
};
|
||
|
||
const handleDownloadCurrent = () => {
|
||
const modelUrl = resolveTaskModelUrl(currentTask);
|
||
if (!modelUrl) {
|
||
toast.error("暂无可下载的 3D 模型");
|
||
return;
|
||
}
|
||
const link = document.createElement("a");
|
||
link.href = modelUrl;
|
||
link.download = `${
|
||
currentTask?.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
|
||
}-${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 handleToggleWorldShareSelection = useCallback(
|
||
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
|
||
event?.stopPropagation();
|
||
if (task.provider !== "worldlabs-3d" || task.status !== "success") {
|
||
return;
|
||
}
|
||
setSelectedWorldShareTaskIds((prev) =>
|
||
prev.includes(task.taskId)
|
||
? prev.filter((taskId) => taskId !== task.taskId)
|
||
: [...prev, task.taskId]
|
||
);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleSelectAllShareableWorlds = useCallback(() => {
|
||
setSelectedWorldShareTaskIds((prev) =>
|
||
prev.length === shareableWorldTasks.length
|
||
? []
|
||
: shareableWorldTasks.map((task) => task.taskId)
|
||
);
|
||
}, [shareableWorldTasks]);
|
||
|
||
const handleToggleWorldCanvasSelection = useCallback(
|
||
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
|
||
event?.stopPropagation();
|
||
if (task.provider !== "worldlabs-3d" || !canImportTaskToCanvas(task)) {
|
||
return;
|
||
}
|
||
setSelectedWorldCanvasTaskIds((prev) =>
|
||
prev.includes(task.taskId)
|
||
? prev.filter((taskId) => taskId !== task.taskId)
|
||
: [...prev, task.taskId]
|
||
);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleSelectAllImportableWorlds = useCallback(() => {
|
||
setSelectedWorldCanvasTaskIds((prev) =>
|
||
prev.length === importableWorldTasks.length
|
||
? []
|
||
: importableWorldTasks.map((task) => task.taskId)
|
||
);
|
||
}, [importableWorldTasks]);
|
||
|
||
const handleImportSelectedWorldsToCanvas = useCallback(async () => {
|
||
if (selectedWorldCanvasTasks.length === 0) {
|
||
toast.error("请选择要添加到画布的世界");
|
||
return;
|
||
}
|
||
await handleImportTasksToCanvas(selectedWorldCanvasTasks);
|
||
}, [handleImportTasksToCanvas, selectedWorldCanvasTasks]);
|
||
|
||
const handleShareSelectedWorlds = useCallback(async () => {
|
||
if (selectedWorldShareTasks.length < 2) {
|
||
toast.error("请至少选择 2 个成功生成的 3D 世界");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
taskIds: selectedWorldShareTasks.map((task) => task.taskId),
|
||
shareEnabled: true,
|
||
}),
|
||
});
|
||
const payload = (await response.json().catch(() => ({}))) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
items?: TaskRecordApiEntity[];
|
||
shareToken?: string | null;
|
||
};
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "批量分享世界失败");
|
||
}
|
||
|
||
const updatedTasks = Array.isArray(payload.items)
|
||
? payload.items
|
||
.map((item) => mapApiEntityToTaskRecord(item))
|
||
.filter((item): item is TaskRecord => Boolean(item))
|
||
: [];
|
||
for (const updatedTask of updatedTasks) {
|
||
upsertTaskHistory(updatedTask, { persist: false });
|
||
}
|
||
|
||
const shareToken = payload.shareToken || updatedTasks[0]?.shareToken;
|
||
const shareUrl = buildWorldShareUrl(shareToken);
|
||
if (!shareUrl) {
|
||
throw new Error("分享链接生成失败");
|
||
}
|
||
await navigator.clipboard.writeText(shareUrl);
|
||
toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "批量分享世界失败");
|
||
}
|
||
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
|
||
|
||
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 handleOpenPanoUrl = () => {
|
||
const imageUrl =
|
||
currentTask?.assetPayload?.panoUrl ||
|
||
currentTask?.assetPayload?.thumbnailUrl ||
|
||
currentPreviewImage;
|
||
if (!imageUrl) {
|
||
toast.error("当前任务暂无可打开的图像预览");
|
||
return;
|
||
}
|
||
window.open(imageUrl, "_blank", "noopener,noreferrer");
|
||
};
|
||
|
||
const handleConvertAndDownload = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const baseTaskId = readyTask.taskId;
|
||
const payload: Record<string, any> = {
|
||
taskType: "convert_model",
|
||
originalModelTaskId: baseTaskId,
|
||
format: exportFormat,
|
||
textureSize: 4096,
|
||
textureFormat: "JPEG",
|
||
};
|
||
const task = await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "convert",
|
||
promptText: `${modeLabel(readyTask.mode)} -> ${exportFormat}`,
|
||
modelLabel: readyTask.modelVersion,
|
||
sourceTaskId: baseTaskId,
|
||
exportFormat,
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
});
|
||
setAutoDownloadTaskId(task.taskId);
|
||
toast.success(`已提交${exportFormat}转换任务,完成后自动下载`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "格式转换失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleRefineModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const payload: Record<string, any> = {
|
||
taskType: "refine_model",
|
||
draftModelTaskId: readyTask.taskId,
|
||
};
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "refine",
|
||
promptText: `精修任务(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
modelLabel: readyTask.modelVersion,
|
||
sourceTaskId: readyTask.taskId,
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
});
|
||
toast.success("已提交模型精修任务");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "提交精修任务失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleTextureModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "texture_model",
|
||
originalModelTaskId: readyTask.taskId,
|
||
texture,
|
||
pbr,
|
||
textureQuality,
|
||
textureAlignment,
|
||
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
|
||
textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined,
|
||
texturePromptText: texturePromptText.trim() || undefined,
|
||
},
|
||
mode: "texture",
|
||
promptText: `纹理重绘(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交纹理重绘任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleStylizeModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "stylize_model",
|
||
originalModelTaskId: readyTask.taskId,
|
||
style: stylizeStyle,
|
||
blockSize: Number.isFinite(stylizeBlockSize)
|
||
? stylizeBlockSize
|
||
: undefined,
|
||
},
|
||
mode: "stylize",
|
||
promptText: `风格化 ${stylizeStyle}(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交风格化任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleRigCheck = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "animate_prerigcheck",
|
||
originalModelTaskId: readyTask.taskId,
|
||
},
|
||
mode: "rigcheck",
|
||
promptText: `可绑定检测(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交可绑定检测任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleRigModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "animate_rig",
|
||
originalModelTaskId: readyTask.taskId,
|
||
outFormat: rigFormat,
|
||
rigType,
|
||
spec: rigSpec,
|
||
},
|
||
mode: "rig",
|
||
promptText: `自动绑定 ${rigType}(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交自动绑定任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleRetargetAnimation = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "animate_retarget",
|
||
originalModelTaskId: readyTask.taskId,
|
||
animation: retargetAnimation,
|
||
outFormat: rigFormat,
|
||
bakeAnimation: retargetBake,
|
||
exportWithGeometry: retargetExportWithGeometry,
|
||
animateInPlace: retargetInPlace,
|
||
},
|
||
mode: "retarget",
|
||
promptText: `动作 ${retargetAnimation}(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交动作重定向任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleMeshSegmentation = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "mesh_segmentation",
|
||
originalModelTaskId: readyTask.taskId,
|
||
},
|
||
mode: "segment",
|
||
promptText: `网格分割(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交网格分割任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleMeshCompletion = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
const parts = completionPartNames
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "mesh_completion",
|
||
originalModelTaskId: readyTask.taskId,
|
||
partNames: parts.length ? parts : undefined,
|
||
},
|
||
mode: "complete",
|
||
promptText: `网格补全(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交网格补全任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleLowpolyOptimize = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "highpoly_to_lowpoly",
|
||
originalModelTaskId: readyTask.taskId,
|
||
faceLimit: Number.isFinite(lowpolyFaceLimit)
|
||
? lowpolyFaceLimit
|
||
: undefined,
|
||
quad: lowpolyQuad,
|
||
bake: lowpolyBake,
|
||
},
|
||
mode: "lowpoly",
|
||
promptText: `低模优化(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交低模优化任务",
|
||
assetPayload: readyTask.assetPayload ?? null,
|
||
modelLabel: readyTask.modelVersion,
|
||
});
|
||
};
|
||
|
||
const handleSelectTask = (task: TaskRecord) => {
|
||
setWorkspaceMode(inferWorkspaceModeFromTask(task));
|
||
setCurrentTask(task);
|
||
if (task.status === "success" && task.modelUrl) {
|
||
setViewerLayers([]);
|
||
setLayerVisibility({});
|
||
setIsolatedLayerId(null);
|
||
}
|
||
};
|
||
|
||
const handleClearHistory = async () => {
|
||
const prevTaskHistory = taskHistory;
|
||
const prevCurrentTask = currentTask;
|
||
const tasksToDelete = visibleTaskHistory;
|
||
setTaskHistory((prev) =>
|
||
prev.filter((task) => !tasksToDelete.some((candidate) => candidate.taskId === task.taskId))
|
||
);
|
||
setCurrentTask((prev) =>
|
||
prev && tasksToDelete.some((task) => task.taskId === prev.taskId) ? null : prev
|
||
);
|
||
|
||
try {
|
||
if (tasksToDelete.length === taskHistory.length) {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
method: "DELETE",
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "清空资产库失败");
|
||
}
|
||
} else {
|
||
for (const task of tasksToDelete) {
|
||
const response = await fetch(
|
||
`/api/canvas/${id}/model3d-assets?taskId=${encodeURIComponent(task.taskId)}`,
|
||
{
|
||
method: "DELETE",
|
||
}
|
||
);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "清空资产库失败");
|
||
}
|
||
}
|
||
}
|
||
if (typeof window !== "undefined") {
|
||
if (tasksToDelete.length === taskHistory.length) {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
}
|
||
}
|
||
toast.success("资产库已清空");
|
||
} catch (error) {
|
||
setTaskHistory(prevTaskHistory);
|
||
setCurrentTask(prevCurrentTask);
|
||
toast.error(error instanceof Error ? error.message : "清空资产库失败");
|
||
}
|
||
};
|
||
|
||
const handleLayersChange = useCallback((layers: Model3DLayer[]) => {
|
||
setViewerLayers(layers);
|
||
setLayerVisibility((prev) => {
|
||
const next: Record<string, boolean> = {};
|
||
for (const layer of layers) {
|
||
next[layer.id] = prev[layer.id] !== false;
|
||
}
|
||
return next;
|
||
});
|
||
setIsolatedLayerId((prev) =>
|
||
prev && layers.some((layer) => layer.id === prev) ? prev : null
|
||
);
|
||
}, []);
|
||
|
||
const currentWorldAsset =
|
||
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
|
||
const currentWorldShareUrl =
|
||
currentTask?.provider === "worldlabs-3d" && currentTask?.shareEnabled
|
||
? buildWorldShareUrl(currentTask.shareToken)
|
||
: null;
|
||
const currentSpzEntries = getSortedWorldLabsSplatEntries(currentWorldAsset?.spzUrls);
|
||
const currentPrimarySpzUrl =
|
||
pickPreferredInlineWorldLabsSplatUrl(currentWorldAsset?.spzUrls) ||
|
||
currentSpzEntries[0]?.[1] ||
|
||
null;
|
||
const currentWorldImageUrl =
|
||
currentWorldAsset?.panoUrl ||
|
||
currentWorldAsset?.thumbnailUrl ||
|
||
(workspaceMode === "world" ? currentPreviewImage : null) ||
|
||
null;
|
||
const importDisabled =
|
||
!currentTask ||
|
||
currentTask.status !== "success" ||
|
||
(currentTask.provider === "worldlabs-3d"
|
||
? !Boolean(
|
||
currentModelUrl ||
|
||
currentWorldAsset?.worldUrl ||
|
||
currentWorldImageUrl ||
|
||
currentPrimarySpzUrl
|
||
)
|
||
: !currentModelUrl);
|
||
const availableWorldViewportModes = useMemo(() => {
|
||
const modes: WorldViewportMode[] = [];
|
||
if (currentPrimarySpzUrl) modes.push("splat");
|
||
if (currentWorldImageUrl) modes.push("imagery");
|
||
if (currentModelUrl) modes.push("mesh");
|
||
return modes;
|
||
}, [currentModelUrl, currentPrimarySpzUrl, currentWorldImageUrl]);
|
||
const preferredWorldViewportMode: WorldViewportMode =
|
||
currentPrimarySpzUrl
|
||
? "splat"
|
||
: currentWorldImageUrl
|
||
? "imagery"
|
||
: currentModelUrl
|
||
? "mesh"
|
||
: "splat";
|
||
const activeWorkspaceTitle =
|
||
workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台";
|
||
const viewerEmptyStateTitle =
|
||
workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产";
|
||
|
||
useEffect(() => {
|
||
setViewerResetToken((prev) => prev + 1);
|
||
}, [currentTask?.taskId, workspaceMode, worldViewportMode]);
|
||
|
||
useEffect(() => {
|
||
if (workspaceMode !== "world") return;
|
||
setWorldViewportMode(preferredWorldViewportMode);
|
||
}, [workspaceMode, currentTask?.taskId, preferredWorldViewportMode]);
|
||
|
||
useEffect(() => {
|
||
if (workspaceMode !== "world") return;
|
||
if (availableWorldViewportModes.length === 0) return;
|
||
if (!availableWorldViewportModes.includes(worldViewportMode)) {
|
||
setWorldViewportMode(preferredWorldViewportMode);
|
||
}
|
||
}, [
|
||
availableWorldViewportModes,
|
||
preferredWorldViewportMode,
|
||
workspaceMode,
|
||
worldViewportMode,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
|
||
setWorldFirstPerson(false);
|
||
}
|
||
}, [workspaceMode, worldViewportMode]);
|
||
|
||
return (
|
||
<div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100">
|
||
{/* Top Navigation Bar */}
|
||
<header className="flex h-14 shrink-0 items-center justify-between border-white/10 border-b bg-zinc-950 px-4">
|
||
<div className="flex items-center gap-6">
|
||
{/* Logo & Brand */}
|
||
<div
|
||
className="flex cursor-pointer items-center gap-2 transition-opacity hover:opacity-80"
|
||
onClick={() => router.push("/")}
|
||
>
|
||
{displayLogo ? (
|
||
<img
|
||
alt={siteName}
|
||
className="h-6 w-6 object-contain"
|
||
src={displayLogo}
|
||
/>
|
||
) : (
|
||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-yellow-500/10">
|
||
<Sparkles className="h-4 w-4 text-yellow-500" />
|
||
</div>
|
||
)}
|
||
<span className="font-bold text-lg text-white tracking-tight">
|
||
{siteName}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div className="h-4 w-[1px] bg-white/10" />
|
||
|
||
{/* Nav Links */}
|
||
<nav className="flex items-center gap-1">
|
||
<Button
|
||
className="h-8 gap-1 px-2 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||
variant="ghost"
|
||
>
|
||
{activeWorkspaceTitle}
|
||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||
</Button>
|
||
</nav>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-8 items-center rounded-full bg-blue-500/10 px-3 text-blue-400">
|
||
<span className="font-medium text-xs">积分</span>
|
||
<span className="ml-1 font-semibold text-sm">{pointsLabel}</span>
|
||
</div>
|
||
|
||
{/* User Profile */}
|
||
<div className="ml-1 h-8 w-8 overflow-hidden rounded-full border border-white/10 bg-zinc-800">
|
||
{user?.image ? (
|
||
<img
|
||
alt={user.name || "User"}
|
||
className="h-full w-full object-cover"
|
||
src={user.image}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center font-semibold text-xs text-zinc-200">
|
||
{userAvatarFallback}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* 1. Left Nav Sidebar (Icon only) */}
|
||
<aside className="z-30 flex w-16 flex-col items-center border-white/10 border-r bg-zinc-950 py-4">
|
||
<TooltipProvider delayDuration={0}>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="mb-6 h-10 w-10 rounded-xl text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
onClick={() => router.push(`/canvas/${id}`)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ArrowLeft className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">返回画布</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-10 w-10 rounded-xl",
|
||
activeTab === "create"
|
||
? "bg-yellow-500 text-black hover:bg-yellow-400"
|
||
: "text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
onClick={() => setActiveTab("create")}
|
||
size="icon"
|
||
variant={activeTab === "create" ? "default" : "ghost"}
|
||
>
|
||
<Sparkles className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{workspaceMode === "world" ? "生成世界" : "生成模型"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-10 w-10 rounded-xl",
|
||
activeTab === "edit"
|
||
? "bg-yellow-500 text-black hover:bg-yellow-400"
|
||
: "text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
onClick={() => setActiveTab("edit")}
|
||
size="icon"
|
||
variant={activeTab === "edit" ? "default" : "ghost"}
|
||
>
|
||
<WandSparkles className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{workspaceMode === "world" ? "世界资产" : "编辑与优化"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-10 w-10 rounded-xl",
|
||
activeTab === "scene"
|
||
? "bg-yellow-500 text-black hover:bg-yellow-400"
|
||
: "text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
onClick={() => setActiveTab("scene")}
|
||
size="icon"
|
||
variant={activeTab === "scene" ? "default" : "ghost"}
|
||
>
|
||
<Layers3 className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{workspaceMode === "world" ? "世界详情" : "场景层级"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
<div className="mt-auto flex flex-col gap-4">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/5 font-bold text-xs text-yellow-500">
|
||
{pointsLabel}
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">可用积分</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TooltipProvider>
|
||
</aside>
|
||
|
||
{/* 2. Left Tool Panel (Expandable Content) */}
|
||
<div className="z-20 flex w-80 flex-col border-white/10 border-r bg-zinc-900/50 backdrop-blur-sm">
|
||
<div className="space-y-3 border-white/10 border-b px-4 py-4">
|
||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-950 p-1">
|
||
<button
|
||
className={cn(
|
||
"rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||
workspaceMode === "model"
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
onClick={() => setWorkspaceMode("model")}
|
||
type="button"
|
||
>
|
||
3D模型模式
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||
workspaceMode === "world"
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
onClick={() => setWorkspaceMode("world")}
|
||
type="button"
|
||
>
|
||
3D世界模式
|
||
</button>
|
||
</div>
|
||
<h2 className="font-semibold text-sm text-white">
|
||
{activeTab === "create" &&
|
||
(workspaceMode === "world" ? "生成世界" : "生成模型")}
|
||
{activeTab === "edit" &&
|
||
(workspaceMode === "world" ? "世界资产" : "模型编辑")}
|
||
{activeTab === "scene" &&
|
||
(workspaceMode === "world" ? "世界详情" : "场景管理")}
|
||
</h2>
|
||
</div>
|
||
<ScrollArea className="flex-1">
|
||
<div className="space-y-6 p-4">
|
||
{activeTab === "create" && (
|
||
workspaceMode === "model" ? (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||
{[
|
||
{ id: "text", label: "文生", icon: PenLine },
|
||
{ id: "image", label: "图生", icon: ImagePlus },
|
||
{ id: "multiview", label: "多视", icon: Box },
|
||
{ id: "batch", label: "批量", icon: Images },
|
||
].map((m) => (
|
||
<button
|
||
className={cn(
|
||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||
mode === m.id
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
key={m.id}
|
||
onClick={() => setMode(m.id as any)}
|
||
type="button"
|
||
>
|
||
<m.icon className="h-4 w-4" />
|
||
{m.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-3 text-xs">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-zinc-400">预计消耗</span>
|
||
<span className="font-semibold text-yellow-100">
|
||
{modelFeatureCostLabel}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-zinc-500">
|
||
{modelFeatureCostHint}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">提示词</Label>
|
||
<Textarea
|
||
className="min-h-24 resize-none border-white/10 bg-zinc-950 text-sm text-zinc-300 placeholder:text-zinc-700 focus-visible:ring-yellow-500/50"
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
placeholder="描述你想生成的3D模型..."
|
||
value={prompt}
|
||
/>
|
||
</div>
|
||
|
||
{mode === "image" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">参考图</Label>
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-white/10 border-dashed bg-zinc-950 px-3 py-6 text-xs text-zinc-500 transition-colors hover:bg-zinc-900/50">
|
||
{referencePreviewUrl ? (
|
||
<div className="relative w-full">
|
||
<img
|
||
alt="ref"
|
||
className="h-32 w-full rounded-md object-contain"
|
||
src={referencePreviewUrl}
|
||
/>
|
||
<Button
|
||
className="-right-2 -top-2 absolute h-6 w-6 rounded-full"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
setReferenceImage(null);
|
||
}}
|
||
size="icon"
|
||
variant="destructive"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<PlusCircle className="h-6 w-6" />
|
||
<span>点击上传图片</span>
|
||
</>
|
||
)}
|
||
<Input
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
setReferenceImage(e.target.files?.[0] || null)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{mode === "multiview" && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{multiViewSlots.map((slot, i) => (
|
||
<label
|
||
className="relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-white/10 border-dashed bg-zinc-950 hover:bg-zinc-900"
|
||
key={slot.id}
|
||
>
|
||
{slot.previewUrl ? (
|
||
<img
|
||
className="h-full w-full object-cover"
|
||
src={slot.previewUrl}
|
||
/>
|
||
) : (
|
||
<span className="text-xs text-zinc-600">
|
||
视图 {i + 1}
|
||
</span>
|
||
)}
|
||
<Input
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
handleMultiViewFileChange(
|
||
i,
|
||
e.target.files?.[0] || null
|
||
)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Collapsible
|
||
onOpenChange={setAdvancedOpen}
|
||
open={advancedOpen}
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<Button
|
||
className="w-full justify-between border-white/10 bg-transparent text-zinc-400 hover:bg-white/5 hover:text-white"
|
||
variant="outline"
|
||
>
|
||
高级参数
|
||
{advancedOpen ? (
|
||
<ChevronUp className="h-4 w-4" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="space-y-4 pt-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">
|
||
模型版本
|
||
</Label>
|
||
<Select
|
||
onValueChange={(v) => setModelVersion(v as any)}
|
||
value={modelVersion}
|
||
>
|
||
<SelectTrigger className="border-white/10 bg-zinc-950 text-zinc-300">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||
{tripoModelOptions.map((option) => (
|
||
<SelectItem
|
||
className="focus:bg-zinc-800 focus:text-white"
|
||
key={option.id}
|
||
value={option.id}
|
||
>
|
||
{option.displayName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{modelFeatureVisibility.pbr !== false ||
|
||
modelFeatureVisibility.quad !== false ||
|
||
modelFeatureVisibility.smartLowPoly !== false ? (
|
||
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
|
||
{modelFeatureVisibility.pbr !== false ? (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span className="flex items-center gap-2">
|
||
PBR 材质
|
||
<CostPill label={pbrExtraCostLabel} />
|
||
</span>
|
||
<Switch
|
||
checked={pbr}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setPbr}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{modelFeatureVisibility.quad !== false ? (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span className="flex items-center gap-2">
|
||
四边面优化
|
||
<CostPill label={quadExtraCostLabel} />
|
||
</span>
|
||
<Switch
|
||
checked={quad}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setQuad}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{modelFeatureVisibility.smartLowPoly !== false ? (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span className="flex items-center gap-2">
|
||
智能低模
|
||
<CostPill label={smartLowPolyExtraCostLabel} />
|
||
</span>
|
||
<Switch
|
||
checked={smartLowPoly}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setSmartLowPoly}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
<Button
|
||
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
|
||
disabled={isSubmitting || tripoModelOptions.length === 0}
|
||
onClick={handleGenerate}
|
||
size="lg"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-4 w-4" />
|
||
)}
|
||
{isSubmitting
|
||
? "生成中..."
|
||
: tripoModelOptions.length === 0
|
||
? "暂无可用模型"
|
||
: `开始生成 · ${modelFeatureCostLabel}`}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||
{[
|
||
{ id: "text", label: "文生", icon: PenLine },
|
||
{ id: "image", label: "图生", icon: ImagePlus },
|
||
{ id: "multi-image", label: "多图", icon: Images },
|
||
{ id: "video", label: "视频", icon: Video },
|
||
].map((item) => (
|
||
<button
|
||
className={cn(
|
||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||
worldMode === item.id
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
key={item.id}
|
||
onClick={() => setWorldMode(item.id as WorldGenerationMode)}
|
||
type="button"
|
||
>
|
||
<item.icon className="h-4 w-4" />
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-3 text-xs">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-zinc-400">预计消耗</span>
|
||
<span className="font-semibold text-yellow-100">
|
||
{worldFeatureCostLabel}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-zinc-500">
|
||
{worldFeatureCostHint}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">世界名称</Label>
|
||
<Input
|
||
className="border-white/10 bg-zinc-950 text-zinc-300"
|
||
onChange={(e) => setWorldDisplayName(e.target.value)}
|
||
placeholder="可选,用于区分历史记录"
|
||
value={worldDisplayName}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">
|
||
{worldMode === "text" ? "世界描述" : "附加提示词"}
|
||
</Label>
|
||
<Textarea
|
||
className="min-h-24 resize-none border-white/10 bg-zinc-950 text-sm text-zinc-300 placeholder:text-zinc-700 focus-visible:ring-yellow-500/50"
|
||
onChange={(e) => setWorldPrompt(e.target.value)}
|
||
placeholder={
|
||
worldMode === "text"
|
||
? "描述你想生成的 3D 世界,例如:sunlit modern loft with warm wood and panoramic city skyline"
|
||
: "可选。补充风格、氛围或场景要求。"
|
||
}
|
||
value={worldPrompt}
|
||
/>
|
||
</div>
|
||
|
||
{worldMode === "image" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">参考图</Label>
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-white/10 border-dashed bg-zinc-950 px-3 py-6 text-xs text-zinc-500 transition-colors hover:bg-zinc-900/50">
|
||
{worldImagePreviewUrl ? (
|
||
<div className="relative w-full">
|
||
<img
|
||
alt="world-ref"
|
||
className="h-32 w-full rounded-md object-contain"
|
||
src={worldImagePreviewUrl}
|
||
/>
|
||
<Button
|
||
className="-right-2 -top-2 absolute h-6 w-6 rounded-full"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
setWorldImageFile(null);
|
||
}}
|
||
size="icon"
|
||
variant="destructive"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ImageIcon className="h-6 w-6" />
|
||
<span>点击上传单张参考图</span>
|
||
</>
|
||
)}
|
||
<Input
|
||
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
setWorldImageFile(e.target.files?.[0] || null)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{worldMode === "multi-image" && (
|
||
<div className="space-y-3">
|
||
<div className="rounded-lg border border-white/10 bg-zinc-950/70 p-3 text-[11px] text-zinc-500">
|
||
建议上传围绕同一空间的多角度图片,并尽量填写合理的方位角。常用值:0 / 90 / 180 / 270。
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{worldImageSlots.map((slot, index) => (
|
||
<div
|
||
className="space-y-2 rounded-lg border border-white/10 bg-zinc-950 p-2"
|
||
key={slot.id}
|
||
>
|
||
<label className="relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-white/10 border-dashed bg-zinc-950 hover:bg-zinc-900">
|
||
{slot.previewUrl ? (
|
||
<img
|
||
className="h-full w-full object-cover"
|
||
src={slot.previewUrl}
|
||
/>
|
||
) : (
|
||
<span className="text-xs text-zinc-600">
|
||
角度图 {index + 1}
|
||
</span>
|
||
)}
|
||
<Input
|
||
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
handleWorldImageSlotChange(
|
||
index,
|
||
e.target.files?.[0] || null
|
||
)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px] text-zinc-500">
|
||
方位角(度)
|
||
</Label>
|
||
<Input
|
||
className="h-8 border-white/10 bg-zinc-900 text-xs text-zinc-300"
|
||
onChange={(e) =>
|
||
handleWorldImageAzimuthChange(
|
||
index,
|
||
Number(e.target.value || 0)
|
||
)
|
||
}
|
||
type="number"
|
||
value={slot.azimuth}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{worldMode === "video" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">视频输入</Label>
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border border-white/10 border-dashed bg-zinc-950 px-3 py-6 text-xs text-zinc-500 transition-colors hover:bg-zinc-900/50">
|
||
{worldVideoFile ? (
|
||
<>
|
||
<div className="flex items-center gap-2 text-zinc-300">
|
||
<Video className="h-4 w-4" />
|
||
<span className="max-w-[220px] truncate">
|
||
{worldVideoFile.name}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
className="h-7"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
setWorldVideoFile(null);
|
||
}}
|
||
size="sm"
|
||
variant="destructive"
|
||
>
|
||
移除视频
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Video className="h-6 w-6" />
|
||
<span>点击上传视频</span>
|
||
</>
|
||
)}
|
||
<Input
|
||
accept="video/mp4,video/quicktime,video/webm,video/x-m4v"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
setWorldVideoFile(e.target.files?.[0] || null)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
<Collapsible
|
||
onOpenChange={setAdvancedOpen}
|
||
open={advancedOpen}
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<Button
|
||
className="w-full justify-between border-white/10 bg-transparent text-zinc-400 hover:bg-white/5 hover:text-white"
|
||
variant="outline"
|
||
>
|
||
世界参数
|
||
{advancedOpen ? (
|
||
<ChevronUp className="h-4 w-4" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="space-y-4 pt-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">生成质量</Label>
|
||
<Select
|
||
onValueChange={(value) => setWorldModel(value as any)}
|
||
value={worldModel}
|
||
>
|
||
<SelectTrigger className="border-white/10 bg-zinc-950 text-zinc-300">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||
{worldModelOptions.map((item) => (
|
||
<SelectItem
|
||
className="focus:bg-zinc-800 focus:text-white"
|
||
key={item.id}
|
||
textValue={item.displayName}
|
||
value={item.id}
|
||
>
|
||
{item.displayName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
|
||
{(worldMode === "image" || worldMode === "multi-image") && (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>关闭自动重描述</span>
|
||
<Switch
|
||
checked={worldDisableRecaption}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setWorldDisableRecaption}
|
||
/>
|
||
</div>
|
||
)}
|
||
{worldMode === "image" && (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>输入图已是全景图</span>
|
||
<Switch
|
||
checked={worldIsPano}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setWorldIsPano}
|
||
/>
|
||
</div>
|
||
)}
|
||
{worldMode === "multi-image" && (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>重建输入图</span>
|
||
<Switch
|
||
checked={worldReconstructImages}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setWorldReconstructImages}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
<Button
|
||
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
|
||
disabled={isSubmitting}
|
||
onClick={handleWorldGenerate}
|
||
size="lg"
|
||
>
|
||
{isSubmitting || uploadingImage ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Globe className="mr-2 h-4 w-4" />
|
||
)}
|
||
{isSubmitting || uploadingImage
|
||
? "处理中..."
|
||
: `开始生成世界 · ${worldFeatureCostLabel}`}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)
|
||
)}
|
||
|
||
{activeTab === "edit" &&
|
||
(workspaceMode === "model" ? (
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={importDisabled}
|
||
onClick={handleImportToCanvas}
|
||
variant="secondary"
|
||
>
|
||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||
</Button>
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={!currentModelUrl}
|
||
onClick={handleDownloadCurrent}
|
||
variant="secondary"
|
||
>
|
||
<Download className="h-4 w-4" /> 下载
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator className="bg-white/10" />
|
||
|
||
<div className="space-y-3">
|
||
<h4 className="flex items-center gap-2 font-medium text-xs text-zinc-400">
|
||
<Package className="h-3.5 w-3.5" /> 格式转换
|
||
</h4>
|
||
<div className="flex gap-2">
|
||
<Select
|
||
onValueChange={(v) => setExportFormat(v as any)}
|
||
value={exportFormat}
|
||
>
|
||
<SelectTrigger className="flex-1 border-white/10 bg-zinc-950 text-zinc-300">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||
{EXPORT_FORMAT_OPTIONS.map((f) => (
|
||
<SelectItem
|
||
className="focus:bg-zinc-800 focus:text-white"
|
||
key={f}
|
||
value={f}
|
||
>
|
||
{f}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
className="min-w-[82px] flex-col gap-0.5 border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleConvertAndDownload}
|
||
variant="outline"
|
||
>
|
||
<span>转换</span>
|
||
<span className="text-[10px] text-zinc-500">
|
||
{convertCostLabel}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<h4 className="mb-2 font-medium text-xs text-zinc-400">
|
||
常用操作
|
||
</h4>
|
||
<Button
|
||
className="w-full justify-between border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleRefineModel}
|
||
variant="outline"
|
||
>
|
||
<span className="flex items-center">
|
||
<Sparkles className="mr-2 h-4 w-4" /> 模型精修
|
||
</span>
|
||
<CostPill label={refineCostLabel} />
|
||
</Button>
|
||
<Button
|
||
className="w-full justify-between border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleTextureModel}
|
||
variant="outline"
|
||
>
|
||
<span className="flex items-center">
|
||
<Palette className="mr-2 h-4 w-4" /> 纹理重绘
|
||
</span>
|
||
<CostPill label={textureCostLabel} />
|
||
</Button>
|
||
<Button
|
||
className="w-full justify-between border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleRigModel}
|
||
variant="outline"
|
||
>
|
||
<span className="flex items-center">
|
||
<Bone className="mr-2 h-4 w-4" /> 自动绑定
|
||
</span>
|
||
<CostPill label={rigCostLabel} />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={importDisabled}
|
||
onClick={handleImportToCanvas}
|
||
variant="secondary"
|
||
>
|
||
<PlusCircle className="h-4 w-4" /> 添加到画布
|
||
</Button>
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={!currentModelUrl}
|
||
onClick={handleDownloadCurrent}
|
||
variant="secondary"
|
||
>
|
||
<Download className="h-4 w-4" /> 下载 Mesh
|
||
</Button>
|
||
</div>
|
||
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={!currentWorldImageUrl}
|
||
onClick={handleOpenPanoUrl}
|
||
variant="outline"
|
||
>
|
||
<ImageIcon className="mr-2 h-4 w-4" /> 打开图像
|
||
</Button>
|
||
|
||
<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>
|
||
<CardDescription className="text-xs text-zinc-500">
|
||
这里保留当前世界可直接使用的核心内容入口。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 text-xs text-zinc-400">
|
||
<div className="space-y-1">
|
||
<div className="text-[11px] text-zinc-500">场景描述</div>
|
||
<div className="line-clamp-3 rounded-md bg-white/5 px-3 py-2 text-zinc-200">
|
||
{currentWorldAsset?.caption ||
|
||
currentTask?.prompt ||
|
||
"暂无描述"}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<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">
|
||
{getWorldModelDisplayLabel(
|
||
currentWorldAsset?.model || currentTask?.modelVersion
|
||
)}
|
||
</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>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
|
||
<span className="flex items-center gap-2">
|
||
<ImageIcon className="h-3.5 w-3.5" />
|
||
图像预览
|
||
</span>
|
||
<Badge
|
||
className="text-[10px]"
|
||
variant={currentWorldImageUrl ? "default" : "secondary"}
|
||
>
|
||
{currentWorldImageUrl ? "可查看" : "暂无"}
|
||
</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">
|
||
<Box className="h-3.5 w-3.5" />
|
||
Mesh
|
||
</span>
|
||
<Badge
|
||
className="text-[10px]"
|
||
variant={currentModelUrl ? "default" : "secondary"}
|
||
>
|
||
{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">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
World 视图
|
||
</span>
|
||
<Badge
|
||
className="text-[10px]"
|
||
variant={currentPrimarySpzUrl ? "default" : "secondary"}
|
||
>
|
||
{currentPrimarySpzUrl ? "可查看" : "暂无"}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
))}
|
||
|
||
{activeTab === "scene" &&
|
||
(workspaceMode === "model" ? (
|
||
<div className="space-y-4">
|
||
<div className="rounded-lg border border-white/10 bg-zinc-950 p-4">
|
||
<div className="grid grid-cols-2 gap-4 text-center">
|
||
<div>
|
||
<div className="font-bold text-lg text-white">
|
||
{viewerStats.triangles.toLocaleString()}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">面数</div>
|
||
</div>
|
||
<div>
|
||
<div className="font-bold text-lg text-white">
|
||
{viewerStats.vertices.toLocaleString()}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">顶点</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<h4 className="font-medium text-xs text-zinc-400">
|
||
网格列表 ({viewerLayers.length})
|
||
</h4>
|
||
{viewerLayers.map((layer) => (
|
||
<div
|
||
className="flex items-center justify-between rounded-md bg-zinc-900 p-2 text-xs text-zinc-300"
|
||
key={layer.id}
|
||
>
|
||
<span className="truncate">{layer.name}</span>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
className="h-6 w-6 hover:text-white"
|
||
onClick={() =>
|
||
setLayerVisibility((prev) => ({
|
||
...prev,
|
||
[layer.id]: prev[layer.id] === false,
|
||
}))
|
||
}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
{layerVisibility[layer.id] === false ? (
|
||
<EyeOff className="h-3 w-3" />
|
||
) : (
|
||
<Eye className="h-3 w-3" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
className="h-6 w-6 hover:text-white"
|
||
onClick={() =>
|
||
setIsolatedLayerId((prev) =>
|
||
prev === layer.id ? null : layer.id
|
||
)
|
||
}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Maximize className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
|
||
<CardContent className="space-y-1 p-4 text-center">
|
||
<div className="font-bold text-lg text-white">
|
||
{currentModelUrl ? viewerStats.triangles.toLocaleString() : "0"}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">Mesh 面数</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
|
||
<CardContent className="space-y-1 p-4 text-center">
|
||
<div className="font-bold text-lg text-white">
|
||
{currentSpzEntries.length}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">World 资产</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm">内容概览</CardTitle>
|
||
<CardDescription className="text-xs text-zinc-500">
|
||
这里展示当前世界的核心内容状态,方便在工作台里继续使用。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 text-xs text-zinc-400">
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">场景描述</div>
|
||
<div className="mt-1 whitespace-pre-wrap text-zinc-200">
|
||
{currentWorldAsset?.caption ||
|
||
currentTask?.prompt ||
|
||
"暂无描述"}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<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">
|
||
{getWorldModelDisplayLabel(
|
||
currentWorldAsset?.model || currentTask?.modelVersion
|
||
)}
|
||
</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">
|
||
{currentWorldImageUrl ? "可访问" : "暂无"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">Mesh</div>
|
||
<div className="truncate text-zinc-200">
|
||
{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 className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">World 资产</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentSpzEntries.length ? `${currentSpzEntries.length} 项` : "暂无"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
{/* 3. Main Viewer Area */}
|
||
<div className="relative flex-1 bg-black">
|
||
{workspaceMode === "world" && (
|
||
<div className="absolute right-6 top-6 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-950/55 p-1.5 shadow-[0_16px_40px_rgba(0,0,0,0.22)] backdrop-blur-md">
|
||
<Button
|
||
className={cn(
|
||
"h-9 w-9 rounded-full border-white/10 p-0 text-zinc-200 hover:bg-white/10 hover:text-white",
|
||
worldFirstPerson ? "bg-yellow-500/20 text-yellow-100" : "bg-white/5"
|
||
)}
|
||
disabled={!currentPrimarySpzUrl || worldViewportMode !== "splat"}
|
||
onClick={() => setWorldFirstPerson((prev) => !prev)}
|
||
size="icon"
|
||
title={worldFirstPerson ? "关闭漫游" : "开启漫游"}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Crosshair className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
className="h-9 w-9 rounded-full border-white/10 bg-white/5 p-0 text-zinc-200 hover:bg-white/10 hover:text-white"
|
||
onClick={() => setViewerResetToken((prev) => prev + 1)}
|
||
size="icon"
|
||
title="回正视角"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<RotateCw className="h-4 w-4" />
|
||
</Button>
|
||
<div className="h-5 w-px bg-white/10" />
|
||
<div className="flex items-center gap-1">
|
||
{[
|
||
{
|
||
id: "splat" as WorldViewportMode,
|
||
label: "World 视图",
|
||
icon: Sparkles,
|
||
disabled: !currentPrimarySpzUrl,
|
||
},
|
||
{
|
||
id: "imagery" as WorldViewportMode,
|
||
label: "图像预览",
|
||
icon: ImageIcon,
|
||
disabled: !currentWorldImageUrl,
|
||
},
|
||
{
|
||
id: "mesh" as WorldViewportMode,
|
||
label: "Mesh 视图",
|
||
icon: Box,
|
||
disabled: !currentModelUrl,
|
||
},
|
||
].map((view) => (
|
||
<Button
|
||
className={cn(
|
||
"h-9 w-9 rounded-full border p-0",
|
||
worldViewportMode === view.id
|
||
? "border-yellow-500/50 bg-yellow-500/14 text-white hover:bg-yellow-500/20"
|
||
: "border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
disabled={view.disabled}
|
||
key={view.id}
|
||
onClick={() => setWorldViewportMode(view.id)}
|
||
size="icon"
|
||
title={view.label}
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<view.icon className="h-4 w-4" />
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{workspaceMode === "world" ? (
|
||
<World3DViewer
|
||
emptyStateTitle={viewerEmptyStateTitle}
|
||
firstPerson={worldViewportMode === "splat" && worldFirstPerson}
|
||
imageUrl={currentWorldImageUrl}
|
||
meshLayerVisibility={effectiveLayerVisibility}
|
||
meshSettings={viewerSettings}
|
||
meshUrl={currentModelUrl}
|
||
mode={worldViewportMode === "imagery" ? "image" : worldViewportMode}
|
||
onMeshLayersChange={handleLayersChange}
|
||
onMeshStatsChange={setViewerStats}
|
||
previewImageUrl={currentPreviewImage}
|
||
resetViewToken={viewerResetToken}
|
||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||
showMovementControls={
|
||
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
||
}
|
||
splatUrl={currentPrimarySpzUrl}
|
||
worldUrl={currentWorldAsset?.worldUrl}
|
||
/>
|
||
) : (
|
||
<Model3DViewer
|
||
emptyStateTitle={viewerEmptyStateTitle}
|
||
layerVisibility={effectiveLayerVisibility}
|
||
modelUrl={currentModelUrl}
|
||
onLayersChange={handleLayersChange}
|
||
onStatsChange={setViewerStats}
|
||
previewImageUrl={currentPreviewImage}
|
||
resetViewToken={viewerResetToken}
|
||
settings={viewerSettings}
|
||
/>
|
||
)}
|
||
|
||
{/* Floating Toolbar */}
|
||
{(workspaceMode !== "world" || worldViewportMode === "mesh") && (
|
||
<div className="-translate-x-1/2 absolute bottom-8 left-1/2 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/80 p-2 shadow-2xl backdrop-blur-md">
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||
onClick={() =>
|
||
setViewerSettings((prev) => ({
|
||
...prev,
|
||
showGrid: !prev.showGrid,
|
||
}))
|
||
}
|
||
size="icon"
|
||
variant={viewerSettings.showGrid ? "secondary" : "ghost"}
|
||
>
|
||
<Grid3X3 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>网格</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||
onClick={() =>
|
||
setViewerSettings((prev) => ({
|
||
...prev,
|
||
autoRotate: !prev.autoRotate,
|
||
}))
|
||
}
|
||
size="icon"
|
||
variant={viewerSettings.autoRotate ? "secondary" : "ghost"}
|
||
>
|
||
<RotateCw className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>自动旋转</TooltipContent>
|
||
</Tooltip>
|
||
<Separator className="h-4 bg-white/20" orientation="vertical" />
|
||
<div className="flex items-center gap-2 px-2">
|
||
<Sun className="h-3 w-3 text-zinc-400" />
|
||
<Slider
|
||
className="w-20"
|
||
max={2}
|
||
min={0.2}
|
||
onValueChange={(v) =>
|
||
setViewerSettings((prev) => ({
|
||
...prev,
|
||
lightIntensity: v[0] || 1,
|
||
}))
|
||
}
|
||
step={0.1}
|
||
value={[viewerSettings.lightIntensity]}
|
||
/>
|
||
</div>
|
||
</TooltipProvider>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 4. Right Assets Panel */}
|
||
<div className="z-20 flex w-72 flex-col border-white/10 border-l bg-zinc-900/50 backdrop-blur-sm">
|
||
<div className="flex h-14 items-center justify-between border-white/10 border-b px-4">
|
||
<div className="min-w-0">
|
||
<h2 className="font-semibold text-sm text-white">
|
||
{workspaceMode === "world" ? "世界资产库" : "模型资产库"}
|
||
</h2>
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
点击切换{workspaceMode === "world" ? "世界" : "模型"}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
className="h-8 w-8 text-zinc-400 hover:text-white"
|
||
onClick={handleClearHistory}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
{workspaceMode === "world" ? (
|
||
<div className="space-y-2 border-white/10 border-b px-3 py-3">
|
||
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||
<span>分享</span>
|
||
<span>已选 {selectedWorldShareTasks.length}</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
|
||
disabled={shareableWorldTasks.length === 0}
|
||
onClick={handleSelectAllShareableWorlds}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
{selectedWorldShareTaskIds.length === shareableWorldTasks.length &&
|
||
shareableWorldTasks.length > 0
|
||
? "取消全选"
|
||
: "全选世界"}
|
||
</Button>
|
||
<Button
|
||
className="h-8 border-cyan-400/30 bg-cyan-400/15 text-xs text-cyan-100 hover:bg-cyan-400/25"
|
||
disabled={selectedWorldShareTasks.length < 2}
|
||
onClick={handleShareSelectedWorlds}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
分享所选
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2 pt-1 text-[11px] text-zinc-500">
|
||
<span>添加到画布</span>
|
||
<span>已选 {selectedWorldCanvasTasks.length}</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
|
||
disabled={importableWorldTasks.length === 0}
|
||
onClick={handleSelectAllImportableWorlds}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
{selectedWorldCanvasTaskIds.length === importableWorldTasks.length &&
|
||
importableWorldTasks.length > 0
|
||
? "取消全选"
|
||
: "全选可添加"}
|
||
</Button>
|
||
<Button
|
||
className="h-8 border-yellow-400/30 bg-yellow-400/15 text-xs text-yellow-100 hover:bg-yellow-400/25"
|
||
disabled={selectedWorldCanvasTasks.length === 0}
|
||
onClick={handleImportSelectedWorldsToCanvas}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
添加 ({selectedWorldCanvasTasks.length})
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<ScrollArea className="flex-1">
|
||
<div className="grid grid-cols-2 gap-2 p-3">
|
||
{visibleTaskHistory.map((task) => {
|
||
const canShareWorld =
|
||
task.provider === "worldlabs-3d" && task.status === "success";
|
||
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
|
||
const canImportWorld =
|
||
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
|
||
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
|
||
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"group overflow-hidden rounded-lg border border-white/5 bg-zinc-950/85 transition-all hover:border-yellow-500/50",
|
||
currentTask?.taskId === task.taskId &&
|
||
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
|
||
isSelectedForShare && "border-cyan-300 ring-2 ring-cyan-300/40",
|
||
isSelectedForCanvas && "border-yellow-300 ring-2 ring-yellow-300/40"
|
||
)}
|
||
key={task.taskId}
|
||
>
|
||
<button
|
||
className="relative block aspect-square w-full overflow-hidden bg-zinc-950"
|
||
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>
|
||
)}
|
||
</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">
|
||
{canShareWorld ? (
|
||
<Button
|
||
aria-label={task.shareEnabled ? "复制分享链接" : "开启分享"}
|
||
className={cn(
|
||
"h-7 w-7 rounded-full border-white/10 bg-zinc-900/90 p-0 text-zinc-200 hover:bg-zinc-800 hover:text-white",
|
||
task.shareEnabled &&
|
||
"border-amber-400/40 bg-amber-400/15 text-amber-100"
|
||
)}
|
||
onClick={(event) => void handleShareTask(task, event)}
|
||
size="icon"
|
||
title={task.shareEnabled ? "复制分享链接" : "开启分享"}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Link2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
) : null}
|
||
{canShareWorld ? (
|
||
<Button
|
||
aria-label={
|
||
isSelectedForShare ? "取消分享选择" : "选中分享"
|
||
}
|
||
className={cn(
|
||
"h-7 w-7 rounded-full border-white/10 bg-zinc-900/90 p-0 text-zinc-200 hover:bg-zinc-800 hover:text-white",
|
||
isSelectedForShare &&
|
||
"border-cyan-300/60 bg-cyan-400/20 text-cyan-50"
|
||
)}
|
||
onClick={(event) => handleToggleWorldShareSelection(task, event)}
|
||
size="icon"
|
||
title={isSelectedForShare ? "取消分享选择" : "选中分享"}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isSelectedForShare ? (
|
||
<Check className="h-3.5 w-3.5" />
|
||
) : (
|
||
<CircleDashed className="h-3.5 w-3.5" />
|
||
)}
|
||
</Button>
|
||
) : null}
|
||
{canImportWorld ? (
|
||
<Button
|
||
aria-label={
|
||
isSelectedForCanvas
|
||
? "取消添加选择"
|
||
: "选中添加到画布"
|
||
}
|
||
className={cn(
|
||
"h-7 w-7 rounded-full border-yellow-400/25 bg-yellow-400/10 p-0 text-yellow-100 hover:bg-yellow-400/20",
|
||
isSelectedForCanvas &&
|
||
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50"
|
||
)}
|
||
onClick={(event) => handleToggleWorldCanvasSelection(task, event)}
|
||
size="icon"
|
||
title={
|
||
isSelectedForCanvas
|
||
? "取消添加选择"
|
||
: "选中添加到画布"
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isSelectedForCanvas ? (
|
||
<Check className="h-3.5 w-3.5" />
|
||
) : (
|
||
<PlusCircle className="h-3.5 w-3.5" />
|
||
)}
|
||
</Button>
|
||
) : null}
|
||
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
||
<Button
|
||
aria-label="下载模型"
|
||
className="h-7 w-7 rounded-full border-white/10 bg-zinc-900/90 p-0 text-zinc-200 hover:bg-zinc-800 hover:text-white"
|
||
onClick={(event) => handleDownloadTask(task, event)}
|
||
size="icon"
|
||
title="下载模型"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{visibleTaskHistory.length === 0 && (
|
||
<div className="col-span-2 py-10 text-center text-xs text-zinc-500">
|
||
{workspaceMode === "world" ? "暂无世界记录" : "暂无模型记录"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|