Your Name 434d4d0d8d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: add 3d model feature visibility and refunds
2026-05-08 11:21:15 +08:00

4186 lines
156 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
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,
} from "@/lib/worldlabs/splat-utils";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
resolveThreeDModelBillingId,
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
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;
};
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 {
const value = String(modelUrl || "").toLowerCase();
if (!value) return "3d";
if (value.includes(".gltf")) return "gltf";
if (value.includes(".glb")) return "glb";
if (value.includes(".usdz")) return "usdz";
if (value.includes(".fbx")) return "fbx";
if (value.includes(".obj")) return "obj";
if (value.includes(".stl")) return "stl";
if (value.includes(".3mf")) return "3mf";
return "3d";
}
function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
if (!task) return null;
if (task.modelUrl) return task.modelUrl;
const meshUrl = task.assetPayload?.meshUrl;
return typeof meshUrl === "string" && meshUrl.trim() ? meshUrl.trim() : null;
}
function createMultiViewSlots(count = 4): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({
id: `slot_${index + 1}`,
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 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(false);
const [viewerResetToken, setViewerResetToken] = useState(0);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = 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 selectedWorldShareTasks = useMemo(() => {
const selectedIds = new Set(selectedWorldShareTaskIds);
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
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]);
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"
);
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
throw new Error(uploadResult.error || "上传世界参考图失败");
}
await submitTask({
provider: "worldlabs-3d",
payload: {
mode: "image",
mediaAssetId: uploadResult.data.mediaAssetId,
providerId: uploadResult.data.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"
);
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
throw new Error(uploadResult.error || "上传世界视频失败");
}
await submitTask({
provider: "worldlabs-3d",
payload: {
mode: "video",
mediaAssetId: uploadResult.data.mediaAssetId,
providerId: uploadResult.data.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 }) => {
if (!result.success || !result.data?.mediaAssetId) {
throw new Error(result.error || "上传多图世界图片失败");
}
return {
mediaAssetId: result.data.mediaAssetId,
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 handleImportToCanvas = async () => {
if (!currentTask || currentTask.status !== "success") {
toast.error("暂无可添加的 3D 资产");
return;
}
const isWorldTask = currentTask.provider === "worldlabs-3d";
const worldAssetPayload = currentTask.assetPayload ?? null;
const primarySplatUrl = isWorldTask
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
: null;
const worldImageUrl =
worldAssetPayload?.panoUrl ||
worldAssetPayload?.thumbnailUrl ||
currentTask.previewImageUrl ||
null;
const canImportWorld = Boolean(
currentModelUrl ||
worldAssetPayload?.worldUrl ||
worldImageUrl ||
primarySplatUrl
);
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 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 x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2;
const y =
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
addNode(
isWorldTask
? {
type: "world3d",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
worldUrl: worldAssetPayload?.worldUrl || undefined,
imageUrl: worldImageUrl || undefined,
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
splatUrl: primarySplatUrl || undefined,
previewImage:
currentTask.previewImageUrl ||
worldAssetPayload?.thumbnailUrl ||
undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
status: "completed",
activeView: primarySplatUrl
? "splat"
: worldImageUrl
? "image"
: "mesh",
firstPerson: false,
hotspots: [],
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
},
},
connections: [],
}
: {
type: "model3d",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: currentModelUrl!,
previewImage: currentTask.previewImageUrl || undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
format: extractModelFormat(currentModelUrl!),
status: "completed",
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
},
},
connections: [],
}
);
try {
await canvasService.saveCanvasState(id, getCanvasState());
} catch (error) {
console.error("[Model3DPage] 保存画布失败:", error);
}
toast.success(isWorldTask ? "3D 世界已添加到画布" : "3D 模型已添加到画布");
};
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 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 = 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"
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"
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 w-[320px] rounded-[24px] border border-white/10 bg-zinc-900/82 p-3 shadow-[0_24px_60px_rgba(0,0,0,0.32)] backdrop-blur-md">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.22em] text-zinc-500">
Viewer Controls
</div>
<h3 className="mt-1 font-semibold text-sm text-white">
3D 世界控制
</h3>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
className={cn(
"h-8 rounded-full border-white/10 px-3 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="sm"
type="button"
variant="outline"
>
<Crosshair className="mr-1.5 h-3.5 w-3.5" />
漫游
</Button>
<Button
className="h-8 rounded-full border-white/10 bg-white/5 px-3 text-zinc-200 hover:bg-white/10 hover:text-white"
onClick={() => setViewerResetToken((prev) => prev + 1)}
size="sm"
type="button"
variant="outline"
>
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
回正
</Button>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{
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-10 justify-start rounded-2xl border px-3 text-left text-xs",
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="sm"
type="button"
variant="ghost"
>
<view.icon className="mr-2 h-3.5 w-3.5 shrink-0" />
{view.label}
</Button>
))}
</div>
<div className="mt-3 rounded-2xl border border-white/10 bg-black/20 px-3 py-2.5 text-[11px] leading-5 text-zinc-400">
{worldViewportMode === "splat" ? (
<>
<div className="font-medium text-zinc-200">
{worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景主体中心旋转"}
</div>
<div className="mt-1">
{worldFirstPerson
? "按住左键拖动画面转向,WASD 或方向键移动,Q/R 上升,E/F 下降。"
: "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
</div>
</>
) : worldViewportMode === "mesh" ? (
<>
<div className="font-medium text-zinc-200">
Mesh 视图已恢复
</div>
<div className="mt-1">
当前世界关联的 Mesh 会在这里直接显示,也可以从右侧资产库点击缩略图切换当前模型。
</div>
</>
) : (
<>
<div className="font-medium text-zinc-200">
当前为图像预览
</div>
<div className="mt-1">
可以切回 World 查看空间结果,或切到 Mesh 继续检查几何结构。
</div>
</>
)}
</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}
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>
) : 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);
return (
<button
className={cn(
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 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"
)}
key={task.taskId}
onClick={() => handleSelectTask(task)}
type="button"
>
{currentTask?.taskId === task.taskId ? (
<Badge className="absolute left-2 bottom-2 z-10 border-yellow-400/40 bg-yellow-500/20 text-[9px] text-yellow-50">
当前
</Badge>
) : null}
{canShareWorld ? (
<Button
className={cn(
"absolute left-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
task.shareEnabled &&
"border-amber-400/40 bg-amber-400/20 text-amber-100 opacity-100"
)}
onClick={(event) => void handleShareTask(task, event)}
size="icon"
type="button"
variant="ghost"
>
<Link2 className="h-3.5 w-3.5" />
</Button>
) : null}
{canShareWorld ? (
<Button
className={cn(
"absolute left-10 top-2 z-10 h-7 rounded-full border border-white/10 bg-black/60 px-2 text-[10px] text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
isSelectedForShare &&
"border-cyan-300/60 bg-cyan-400/25 text-cyan-50 opacity-100"
)}
onClick={(event) => handleToggleWorldShareSelection(task, event)}
size="sm"
type="button"
variant="ghost"
>
{isSelectedForShare ? (
<Check className="mr-1 h-3 w-3" />
) : null}
{isSelectedForShare ? "已选" : "选择"}
</Button>
) : null}
{task.status === "success" && resolveTaskModelUrl(task) ? (
<Button
className="absolute right-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
onClick={(event) => handleDownloadTask(task, event)}
size="icon"
type="button"
variant="ghost"
>
<Download className="h-3.5 w-3.5" />
</Button>
) : null}
{task.previewImageUrl ? (
<img
alt="preview"
className="h-full w-full object-cover transition-transform group-hover:scale-110"
src={task.previewImageUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
<div className="flex items-center justify-between">
<Badge
className="h-4 px-1 text-[9px]"
variant={
task.status === "success" ? "default" : "secondary"
}
>
{statusLabel(task.status)}
</Badge>
<Badge
className="border-white/10 bg-black/50 text-[9px] text-zinc-200"
variant="outline"
>
{task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"}
</Badge>
</div>
<div className="mt-1 truncate text-left text-[10px] text-zinc-300">
{modeLabel(task.mode)}
</div>
<div className="truncate text-left text-[10px] text-zinc-500">
{formatDistanceToNow(new Date(task.createdAt), {
addSuffix: true,
locale: zhCN,
})}
</div>
{task.status === "success" && resolveTaskModelUrl(task) ? (
<div className="mt-1 text-left text-[10px] text-zinc-200">
点击右上角可下载模型
</div>
) : null}
{task.provider === "worldlabs-3d" && task.shareEnabled ? (
<div className="mt-1 text-left text-[10px] text-amber-200">
已开启分享
</div>
) : null}
</div>
</button>
);
})}
{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>
);
}