"use client";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import dynamic from "next/dynamic";
import {
ArrowLeft,
Bone,
Box,
Boxes,
Check,
ChevronDown,
ChevronUp,
CircleDashed,
Crosshair,
Download,
Eye,
EyeOff,
Grid3X3,
Home,
ImagePlus,
Images,
Layers3,
LayoutGrid,
Loader2,
Maximize,
Package,
Palette,
PenLine,
Play,
PlusCircle,
RefreshCw,
RotateCw,
Scissors,
Settings2,
Sparkles,
Sun,
Globe,
ImageIcon,
Link2,
PanelTop,
Video,
WandSparkles,
X,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import {
type Model3DLayer,
type Model3DStats,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn, fetcher } from "@/lib/utils";
import {
getSortedWorldLabsSplatEntries,
pickBestArchivedWorldLabsSplatUrl,
pickPreferredInlineWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
resolveThreeDModelBillingId,
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
extractModelAssetFormat,
resolvePreferredModelAssetUrl,
} from "@/lib/model3d/model-url";
import {
defaultPointsSystemConfig,
get3DTaskCreditCost,
get3DWorldCreditCost,
normalizePointsSystemConfig,
type PointsSystemConfig,
} from "@/lib/points/config";
import {
canvasService,
type Model3DTaskStatus,
model3DGenerationService,
type WorldLabsTaskAssetPayload,
worldLabsGenerationService,
} from "@/services/canvas";
import { canvasSelectors, useCanvasStore } from "@/store/canvas";
const Model3DViewer = dynamic(
() =>
import("@/components/canvas/model3d/Model3DViewer").then(
(module) => module.Model3DViewer
),
{
loading: () => (
准备加载 3D 视图...
),
ssr: false,
}
);
const World3DViewer = dynamic(
() =>
import("@/components/canvas/world3d/World3DViewer").then(
(module) => module.World3DViewer
),
{
loading: () => (
准备加载 World 视图...
),
ssr: false,
}
);
type GenerationMode = "text" | "image" | "multiview" | "batch";
type WorkspaceMode = "model" | "world";
type WorldGenerationMode = "text" | "image" | "multi-image" | "video";
type WorldViewportMode = "world" | "imagery" | "mesh" | "splat";
type Model3DTaskAssetPayload = WorldLabsTaskAssetPayload & {
providerId?: string | null;
providerName?: string | null;
sourceModelUrl?: string | null;
storageArchivedAt?: string | null;
};
type TaskProvider = "tripo-3d" | "worldlabs-3d";
type TaskMode =
| GenerationMode
| "world-text"
| "world-image"
| "world-multi-image"
| "world-video"
| "convert"
| "refine"
| "texture"
| "stylize"
| "rigcheck"
| "rig"
| "retarget"
| "segment"
| "complete"
| "lowpoly";
type TaskRecord = {
taskId: string;
provider: TaskProvider;
status: Model3DTaskStatus;
mode: TaskMode;
prompt: string;
modelVersion: string;
createdAt: number;
progress?: number | null;
modelUrl?: string | null;
previewImageUrl?: string | null;
error?: string | null;
sourceTaskId?: string | null;
exportFormat?: string | null;
assetPayload?: Model3DTaskAssetPayload | null;
shareEnabled?: boolean;
shareToken?: string | null;
sharedAt?: number | null;
};
type TaskRecordApiEntity = {
taskId?: unknown;
provider?: unknown;
status?: unknown;
mode?: unknown;
prompt?: unknown;
modelVersion?: unknown;
progress?: unknown;
modelUrl?: unknown;
previewImageUrl?: unknown;
error?: unknown;
sourceTaskId?: unknown;
exportFormat?: unknown;
assetPayload?: unknown;
shareEnabled?: unknown;
shareToken?: unknown;
sharedAt?: unknown;
createdAt?: unknown;
};
type MultiViewSlot = {
id: string;
file: File | null;
previewUrl: string | null;
};
type WorldImageSlot = {
id: string;
file: File | null;
previewUrl: string | null;
azimuth: number;
};
const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [
"city",
"sunset",
"studio",
"dawn",
"night",
"warehouse",
"forest",
"apartment",
"park",
"lobby",
];
const EXPORT_FORMAT_OPTIONS = [
"GLTF",
"USDZ",
"FBX",
"OBJ",
"STL",
"3MF",
] as const;
const STYLE_OPTIONS = ["lego", "voxel", "voronoi", "minecraft"] as const;
const RIG_TYPE_OPTIONS = [
"biped",
"quadruped",
"hexapod",
"octopod",
"avian",
"serpentine",
"aquatic",
"others",
] as const;
const RIG_SPEC_OPTIONS = ["tripo", "mixamo"] as const;
const RIG_FORMAT_OPTIONS = ["glb", "fbx"] as const;
const ANIMATION_OPTIONS = [
"preset:idle",
"preset:walk",
"preset:run",
"preset:jump",
"preset:turn",
"preset:slash",
"preset:shoot",
"preset:hurt",
"preset:fall",
] as const;
const FINAL_TASK_STATUS = new Set([
"success",
"failed",
"banned",
"expired",
"cancelled",
"unknown",
]);
const TASK_MODE_SET = new Set([
"text",
"image",
"multiview",
"batch",
"world-text",
"world-image",
"world-multi-image",
"world-video",
"convert",
"refine",
"texture",
"stylize",
"rigcheck",
"rig",
"retarget",
"segment",
"complete",
"lowpoly",
]);
const TASK_HISTORY_LIMIT = 40;
const TASK_HISTORY_STORAGE_PREFIX = "canvas-model3d-task-history-v2";
const WORLD_MULTI_IMAGE_DEFAULTS = [0, 90, 180, 270];
function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
switch (status) {
case "queued":
case "running":
case "success":
case "failed":
case "banned":
case "expired":
case "cancelled":
case "unknown":
return status;
default:
return "unknown";
}
}
function extractModelFormat(modelUrl?: string | null): string {
return extractModelAssetFormat(modelUrl);
}
function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
if (!task) return null;
const meshUrl = task.assetPayload?.meshUrl;
return resolvePreferredModelAssetUrl({
primaryUrl: task.modelUrl,
sourceUrl: task.assetPayload?.sourceModelUrl,
fallbackUrl: typeof meshUrl === "string" ? meshUrl.trim() : null,
});
}
function resolveWorldTaskSplatUrl(task?: TaskRecord | null) {
if (!task || task.provider !== "worldlabs-3d") return null;
return pickBestArchivedWorldLabsSplatUrl(task.assetPayload?.spzUrls);
}
function resolveWorldTaskImageUrl(task?: TaskRecord | null) {
if (!task || task.provider !== "worldlabs-3d") return null;
return (
task.assetPayload?.panoUrl ||
task.assetPayload?.thumbnailUrl ||
task.previewImageUrl ||
null
);
}
function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) {
if (!task) return null;
return (
task.previewImageUrl ||
task.assetPayload?.thumbnailUrl ||
task.assetPayload?.panoUrl ||
null
);
}
function getTaskPreviewCandidates(task?: TaskRecord | null) {
if (!task) return [];
const candidates = [
task.previewImageUrl,
task.assetPayload?.thumbnailUrl,
task.assetPayload?.panoUrl,
task.assetPayload?.sourcePreviewImageUrl,
task.assetPayload?.sourceThumbnailUrl,
task.assetPayload?.sourcePanoUrl,
];
const seen = new Set();
return candidates
.map((value) => String(value || "").trim())
.filter((value) => {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function isPersistableTaskPreviewUrl(value?: string | null) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function TaskAssetPreview({ task }: { task: TaskRecord }) {
const candidates = getTaskPreviewCandidates(task);
const signature = candidates.join("|");
const [candidateIndex, setCandidateIndex] = useState(0);
useEffect(() => {
setCandidateIndex(0);
}, [signature]);
const previewUrl = candidates[candidateIndex] || null;
if (!previewUrl) {
return (
{task.status === "queued" || task.status === "running" ? (
) : (
)}
);
}
return (
setCandidateIndex((index) => index + 1)}
src={previewUrl}
/>
);
}
function canImportTaskToCanvas(task?: TaskRecord | null) {
if (!task || task.status !== "success") return false;
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
return Boolean(
resolveTaskModelUrl(task) ||
task.assetPayload?.worldUrl ||
resolveWorldTaskImageUrl(task) ||
resolveWorldTaskSplatUrl(task)
);
}
function resolveCanvasImportPosition(params: {
index: number;
totalCount: number;
viewportX: number;
viewportY: number;
viewportZoom: number;
stageWidth: number;
stageHeight: number;
targetWidth: number;
targetHeight: number;
}) {
const column = params.index % 3;
const row = Math.floor(params.index / 3);
const gap = 36;
const centerX =
(params.stageWidth * 0.48 - params.viewportX) / params.viewportZoom;
const centerY =
(params.stageHeight * 0.45 - params.viewportY) / params.viewportZoom;
const totalColumns = Math.min(3, Math.max(1, params.totalCount));
const xOffset =
(column - (totalColumns - 1) / 2) * (params.targetWidth + gap);
const yOffset = row * (params.targetHeight + gap);
return {
x: centerX - params.targetWidth / 2 + xOffset,
y: centerY - params.targetHeight / 2 + yOffset,
};
}
function createMultiViewSlots(count = 4): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({
id: `slot_${index + 1}`,
file: null,
previewUrl: null,
}));
}
function createBatchImageSlots(count = 10): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({
id: `batch_slot_${index + 1}`,
file: null,
previewUrl: null,
}));
}
function createWorldImageSlots(values = WORLD_MULTI_IMAGE_DEFAULTS): WorldImageSlot[] {
return values.map((azimuth, index) => ({
id: `world_slot_${index + 1}`,
file: null,
previewUrl: null,
azimuth,
}));
}
function modeLabel(mode: TaskMode) {
switch (mode) {
case "text":
return "文生3D";
case "image":
return "图生3D";
case "multiview":
return "多视图3D";
case "batch":
return "批量图生3D";
case "world-text":
return "文生世界";
case "world-image":
return "图生世界";
case "world-multi-image":
return "多图世界";
case "world-video":
return "视频世界";
case "convert":
return "格式转换";
case "refine":
return "模型精修";
case "texture":
return "纹理重绘";
case "stylize":
return "风格化";
case "rigcheck":
return "可绑定检测";
case "rig":
return "自动绑定";
case "retarget":
return "动作重定向";
case "segment":
return "网格分割";
case "complete":
return "网格补全";
case "lowpoly":
return "低模优化";
default:
return mode;
}
}
function statusLabel(status: Model3DTaskStatus) {
switch (status) {
case "queued":
return "排队中";
case "running":
return "生成中";
case "success":
return "成功";
case "failed":
return "失败";
case "banned":
return "审核拦截";
case "expired":
return "已过期";
case "cancelled":
return "已取消";
default:
return "未知";
}
}
function makeStorageKey(projectId: string) {
return `${TASK_HISTORY_STORAGE_PREFIX}:${projectId}`;
}
function buildWorldShareUrl(shareToken?: string | null) {
const normalized = String(shareToken || "").trim();
if (!normalized) return null;
if (typeof window === "undefined") {
return `/worlds/share/${normalized}`;
}
return new URL(`/worlds/share/${normalized}`, window.location.origin).toString();
}
function normalizeTaskProvider(provider: unknown): TaskProvider {
return String(provider || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d";
}
function normalizeAssetPayload(
payload: unknown
): Model3DTaskAssetPayload | null {
return payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Model3DTaskAssetPayload)
: null;
}
function normalizeTaskMode(mode: unknown): TaskMode {
const value = String(mode || "").trim() as TaskMode;
return TASK_MODE_SET.has(value) ? value : "text";
}
function toTaskTimestamp(value: unknown): number {
if (value instanceof Date) return value.getTime();
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
function toNullableTaskTimestamp(value: unknown): number | null {
if (value === undefined || value === null || value === "") return null;
return toTaskTimestamp(value);
}
function normalizeTaskRecord(raw: TaskRecord): TaskRecord {
return {
...raw,
taskId: String(raw.taskId || "").trim(),
provider: normalizeTaskProvider(raw.provider),
status: normalizeTaskStatus(raw.status),
mode: normalizeTaskMode(raw.mode),
prompt: String(raw.prompt || "").trim(),
modelVersion: String(raw.modelVersion || "").trim(),
createdAt: toTaskTimestamp(raw.createdAt),
progress:
raw.progress === undefined || raw.progress === null
? null
: Number.isFinite(Number(raw.progress))
? Number(raw.progress)
: null,
modelUrl: raw.modelUrl ? String(raw.modelUrl).trim() : null,
previewImageUrl: raw.previewImageUrl
? String(raw.previewImageUrl).trim()
: null,
error: raw.error ? String(raw.error).trim() : null,
sourceTaskId: raw.sourceTaskId ? String(raw.sourceTaskId).trim() : null,
exportFormat: raw.exportFormat ? String(raw.exportFormat).trim() : null,
assetPayload: normalizeAssetPayload(raw.assetPayload),
shareEnabled: raw.shareEnabled === true,
shareToken: raw.shareToken ? String(raw.shareToken).trim() : null,
sharedAt: toNullableTaskTimestamp(raw.sharedAt),
};
}
function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | null {
const taskId = String(entity?.taskId || "").trim();
if (!taskId) return null;
return normalizeTaskRecord({
taskId,
provider: normalizeTaskProvider(entity?.provider),
status: normalizeTaskStatus(String(entity?.status || "")),
mode: normalizeTaskMode(entity?.mode),
prompt: String(entity?.prompt || "").trim(),
modelVersion: String(entity?.modelVersion || "").trim(),
createdAt: toTaskTimestamp(entity?.createdAt),
progress:
entity?.progress === undefined || entity?.progress === null
? null
: Number(entity.progress),
modelUrl: entity?.modelUrl ? String(entity.modelUrl).trim() : null,
previewImageUrl: entity?.previewImageUrl
? String(entity.previewImageUrl).trim()
: null,
error: entity?.error ? String(entity.error).trim() : null,
sourceTaskId: entity?.sourceTaskId ? String(entity.sourceTaskId).trim() : null,
exportFormat: entity?.exportFormat ? String(entity.exportFormat).trim() : null,
assetPayload: normalizeAssetPayload(entity?.assetPayload),
shareEnabled: entity?.shareEnabled === true,
shareToken: entity?.shareToken ? String(entity.shareToken).trim() : null,
sharedAt: toNullableTaskTimestamp(entity?.sharedAt),
});
}
function taskRecordChanged(prev: TaskRecord, next: TaskRecord) {
return (
prev.status !== next.status ||
prev.progress !== next.progress ||
prev.modelUrl !== next.modelUrl ||
prev.previewImageUrl !== next.previewImageUrl ||
prev.error !== next.error ||
prev.prompt !== next.prompt ||
prev.modelVersion !== next.modelVersion ||
prev.sourceTaskId !== next.sourceTaskId ||
prev.exportFormat !== next.exportFormat ||
JSON.stringify(prev.assetPayload || null) !== JSON.stringify(next.assetPayload || null) ||
prev.provider !== next.provider
);
}
function inferWorkspaceModeFromTask(task: TaskRecord | null): WorkspaceMode {
return task?.provider === "worldlabs-3d" ? "world" : "model";
}
function taskMatchesWorkspace(task: TaskRecord, workspaceMode: WorkspaceMode) {
return workspaceMode === "world"
? task.provider === "worldlabs-3d"
: task.provider !== "worldlabs-3d";
}
function worldModeToTaskMode(mode: WorldGenerationMode): TaskMode {
switch (mode) {
case "text":
return "world-text";
case "image":
return "world-image";
case "multi-image":
return "world-multi-image";
case "video":
return "world-video";
default:
return "world-image";
}
}
function modelModeToTaskType(mode: GenerationMode) {
if (mode === "text") return "text_to_model";
if (mode === "multiview") return "multiview_to_model";
return "image_to_model";
}
function formatPointAmount(value: number) {
const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return normalized.toLocaleString("en-US");
}
function formatFeatureCostLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return "不扣积分";
if (isHighVip) return "VIP免费";
if (cost <= 0) return "免费";
return `${formatPointAmount(cost)} 积分`;
}
function formatFeatureExtraCostLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return "不扣积分";
if (isHighVip) return "VIP免费";
if (cost <= 0) return "免费";
return `+${formatPointAmount(cost)} 积分`;
}
function CostPill({
active,
className,
label,
}: {
active?: boolean;
className?: string;
label: string;
}) {
return (
{label}
);
}
export default function CanvasModel3DPage() {
const router = useRouter();
const routeParams = useParams();
const id = String(routeParams?.id || "");
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
const { data: session } = useSession();
const user = session?.user;
const viewport = useCanvasStore(canvasSelectors.viewport);
const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore(canvasSelectors.nodes);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const [workspaceMode, setWorkspaceMode] = useState("model");
const [mode, setMode] = useState("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(null);
const [referencePreviewUrl, setReferencePreviewUrl] = useState(
null
);
const [multiViewSlots, setMultiViewSlots] = useState(() =>
createMultiViewSlots()
);
const [batchImageSlots, setBatchImageSlots] = useState(() =>
createBatchImageSlots()
);
const [worldMode, setWorldMode] = useState("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(null);
const [worldImagePreviewUrl, setWorldImagePreviewUrl] = useState(
null
);
const [worldImageSlots, setWorldImageSlots] = useState(() =>
createWorldImageSlots()
);
const [worldVideoFile, setWorldVideoFile] = useState(null);
const [worldVideoPreviewUrl, setWorldVideoPreviewUrl] = useState(
null
);
const [worldViewportMode, setWorldViewportMode] =
useState("world");
const [worldFirstPerson, setWorldFirstPerson] = useState(true);
const [viewerResetToken, setViewerResetToken] = useState(0);
const [taskHistory, setTaskHistory] = useState([]);
const [currentTask, setCurrentTask] = useState(null);
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState([]);
const [selectedWorldCanvasTaskIds, setSelectedWorldCanvasTaskIds] = useState([]);
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState(
null
);
const [advancedOpen, setAdvancedOpen] = useState(false);
// Viewer Settings
const [viewerSettings, setViewerSettings] = useState({
cameraPreset: "iso",
envPreset: "city",
lightIntensity: 1,
autoRotate: false,
showGrid: true,
wireframe: false,
});
const [viewerStats, setViewerStats] = useState({
triangles: 0,
vertices: 0,
meshCount: 0,
});
const [viewerLayers, setViewerLayers] = useState([]);
const [layerVisibility, setLayerVisibility] = useState<
Record
>({});
const [isolatedLayerId, setIsolatedLayerId] = useState(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 pollErrorCountRef = useRef(0);
const multiViewSlotsRef = useRef([]);
const batchImageSlotsRef = useRef([]);
const MAX_RETRYABLE_TASK_POLL_FAILURES = 12;
const isRetryableTaskPollFailure = (message: string) => {
const normalized = String(message || "").toLowerCase();
return (
/http[^0-9]*5\d\d/.test(normalized) ||
normalized.includes("超时") ||
normalized.includes("timeout")
);
};
// 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: isPersistableTaskPreviewUrl(task.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 = {};
for (const layer of viewerLayers) {
next[layer.id] =
layer.id === isolatedLayerId && layerVisibility[layer.id] !== false;
}
return next;
}, [isolatedLayerId, layerVisibility, viewerLayers]);
const visibleTaskHistory = useMemo(
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
[taskHistory, workspaceMode]
);
const shareableWorldTasks = useMemo(
() =>
visibleTaskHistory.filter(
(task) => task.provider === "worldlabs-3d" && task.status === "success"
),
[visibleTaskHistory]
);
const importableWorldTasks = useMemo(
() =>
visibleTaskHistory.filter(
(task) => task.provider === "worldlabs-3d" && canImportTaskToCanvas(task)
),
[visibleTaskHistory]
);
const selectedWorldShareTasks = useMemo(() => {
const selectedIds = new Set(selectedWorldShareTaskIds);
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
const selectedWorldCanvasTasks = useMemo(() => {
const selectedIds = new Set(selectedWorldCanvasTaskIds);
return importableWorldTasks.filter((task) => selectedIds.has(task.taskId));
}, [selectedWorldCanvasTaskIds, importableWorldTasks]);
useEffect(() => {
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
setSelectedWorldShareTaskIds((prev) => {
const next = prev.filter((taskId) => shareableIds.has(taskId));
return next.length === prev.length ? prev : next;
});
}, [shareableWorldTasks]);
useEffect(() => {
const importableIds = new Set(importableWorldTasks.map((task) => task.taskId));
setSelectedWorldCanvasTaskIds((prev) => {
const next = prev.filter((taskId) => importableIds.has(taskId));
return next.length === prev.length ? prev : next;
});
}, [importableWorldTasks]);
const currentModelUrl = resolveTaskModelUrl(currentTask);
const currentPreviewImage =
currentTask?.previewImageUrl ||
(workspaceMode === "model"
? mode === "image"
? referencePreviewUrl
: mode === "multiview"
? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl
: mode === "batch"
? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl
: null
: worldMode === "image"
? worldImagePreviewUrl
: worldMode === "multi-image"
? worldImageSlots.find((slot) => slot.previewUrl)?.previewUrl
: worldMode === "video"
? worldVideoPreviewUrl
: null) ||
null;
useEffect(() => {
if (currentTask && taskMatchesWorkspace(currentTask, workspaceMode)) {
return;
}
const fallbackTask = taskHistory.find((task) =>
taskMatchesWorkspace(task, workspaceMode)
);
if (fallbackTask) {
setCurrentTask(fallbackTask);
return;
}
if (currentTask) {
setCurrentTask(null);
}
}, [currentTask, taskHistory, workspaceMode]);
// Polling logic
useEffect(() => {
if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) {
return;
}
pollAttemptsRef.current = 0;
let cancelled = false;
const runPoll = async () => {
if (cancelled) return;
pollAttemptsRef.current += 1;
const isWorldTask = currentTask.provider === "worldlabs-3d";
const result =
isWorldTask
? await worldLabsGenerationService.getTask(currentTask.taskId, {
providerId: currentTask.assetPayload?.providerId,
})
: await model3DGenerationService.getTask(currentTask.taskId, {
providerId: currentTask.assetPayload?.providerId,
});
if (!result.success || !result.data) {
const failMessage = result.error || "任务查询失败";
if (isRetryableTaskPollFailure(failMessage)) {
pollErrorCountRef.current += 1;
if (pollErrorCountRef.current === 1) {
console.warn("[Model3DPage] 任务查询临时异常,继续重试", {
taskId: currentTask.taskId,
provider: currentTask.provider,
error: failMessage,
attempt: pollAttemptsRef.current,
});
toast.info(`任务查询异常,正在重试...`);
}
if (pollErrorCountRef.current >= MAX_RETRYABLE_TASK_POLL_FAILURES) {
const failedRecord: TaskRecord = {
...currentTask,
status: "failed",
error: `${failMessage}(重试仍异常)`,
};
setCurrentTask(failedRecord);
upsertTaskHistory(failedRecord);
}
return;
}
pollErrorCountRef.current = 0;
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>["data"]
>)
: null;
const tripoResultData = !isWorldTask
? (result.data as NonNullable<
Awaited>["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,
};
pollErrorCountRef.current = 0;
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 = {
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;
mode: TaskMode;
promptText: string;
modelLabel: string;
sourceTaskId?: string | null;
exportFormat?: string | null;
assetPayload?: Model3DTaskAssetPayload | null;
previewImageUrl?: string | 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,
previewImageUrl: params.previewImageUrl || 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;
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,
},
previewImageUrl: referencePreviewUrl,
});
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,
},
previewImageUrl:
batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null,
});
successCount += 1;
}
toast.success(`已提交 ${successCount} 个批量图生3D任务`);
return;
}
setUploadingImage(true);
const uploadResults: Array<
Awaited>
> = [];
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> = [];
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,
},
previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null,
});
toast.success("已提交多视图3D任务");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建任务失败");
} finally {
setUploadingImage(false);
setIsSubmitting(false);
}
};
const handleWorldGenerate = async () => {
const normalizedPrompt = worldPrompt.trim();
const selectedWorldImages = worldImageSlots.filter((slot) => slot.file);
if (worldMode === "text" && !normalizedPrompt) {
toast.error("请输入世界描述");
return;
}
if (worldMode === "image" && !worldImageFile) {
toast.error("图生世界请先上传参考图");
return;
}
if (worldMode === "multi-image" && selectedWorldImages.length < 2) {
toast.error("多图世界至少上传 2 张图片");
return;
}
if (worldMode === "video" && !worldVideoFile) {
toast.error("视频世界请先上传视频");
return;
}
setIsSubmitting(true);
setUploadingImage(false);
try {
if (worldMode === "text") {
await submitTask({
provider: "worldlabs-3d",
payload: {
mode: "text",
prompt: normalizedPrompt,
displayName: worldDisplayName.trim() || undefined,
model: worldModel,
disableRecaption: worldDisableRecaption,
makePublic: worldMakePublic,
},
mode: "world-text",
promptText: normalizedPrompt || "文生世界",
modelLabel: worldModel,
});
toast.success("已提交文生世界任务");
return;
}
if (worldMode === "image") {
setUploadingImage(true);
const uploadResult = await worldLabsGenerationService.uploadMedia(
worldImageFile as File,
"image"
);
const uploadData = uploadResult.data || null;
const mediaAssetId = uploadData?.mediaAssetId || null;
const sourceUrl = uploadData?.sourceUrl || null;
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
throw new Error(uploadResult.error || "上传世界参考图失败");
}
await submitTask({
provider: "worldlabs-3d",
payload: {
mode: "image",
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
providerId: uploadData.providerId,
prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined,
model: worldModel,
disableRecaption: worldDisableRecaption,
isPano: worldIsPano,
makePublic: worldMakePublic,
},
mode: "world-image",
promptText: normalizedPrompt || "图生世界",
modelLabel: worldModel,
previewImageUrl: sourceUrl || worldImagePreviewUrl,
});
toast.success("已提交图生世界任务");
return;
}
if (worldMode === "video") {
setUploadingImage(true);
const uploadResult = await worldLabsGenerationService.uploadMedia(
worldVideoFile as File,
"video"
);
const uploadData = uploadResult.data || null;
const mediaAssetId = uploadData?.mediaAssetId || null;
const sourceUrl = uploadData?.sourceUrl || null;
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
throw new Error(uploadResult.error || "上传世界视频失败");
}
await submitTask({
provider: "worldlabs-3d",
payload: {
mode: "video",
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
providerId: uploadData.providerId,
prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined,
model: worldModel,
makePublic: worldMakePublic,
},
mode: "world-video",
promptText: normalizedPrompt || worldVideoFile?.name || "视频世界",
modelLabel: worldModel,
});
toast.success("已提交视频世界任务");
return;
}
setUploadingImage(true);
let boundWorldProviderId: string | null = null;
const uploads: Array<{
slot: WorldImageSlot;
result: Awaited>;
}> = [];
for (const slot of selectedWorldImages) {
const result = await worldLabsGenerationService.uploadMedia(
slot.file as File,
"image",
{
providerId: boundWorldProviderId,
}
);
if (result.success && result.data?.providerId && !boundWorldProviderId) {
boundWorldProviderId = result.data.providerId;
}
uploads.push({ slot, result });
}
const mediaAssets = uploads.map(({ slot, result }) => {
const mediaAssetId = result.data?.mediaAssetId || null;
const sourceUrl = result.data?.sourceUrl || null;
if (!result.success || (!mediaAssetId && !sourceUrl)) {
throw new Error(result.error || "上传多图世界图片失败");
}
return {
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
azimuth: slot.azimuth,
};
});
await submitTask({
provider: "worldlabs-3d",
payload: {
mode: "multi-image",
providerId: boundWorldProviderId,
mediaAssets,
prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined,
model: worldModel,
disableRecaption: worldDisableRecaption,
reconstructImages: worldReconstructImages,
makePublic: worldMakePublic,
},
mode: "world-multi-image",
promptText: normalizedPrompt || "多图世界",
modelLabel: worldModel,
previewImageUrl:
mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl ||
selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl ||
null,
});
toast.success("已提交多图世界任务");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建世界任务失败");
} finally {
setUploadingImage(false);
setIsSubmitting(false);
}
};
const handleImportTasksToCanvas = async (tasks: TaskRecord[]) => {
const importableTasks = tasks.filter(canImportTaskToCanvas);
if (importableTasks.length === 0) {
toast.error("暂无可添加的 3D 资产");
return;
}
const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1;
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
const stageHeight =
typeof window !== "undefined" ? window.innerHeight : 720;
const targetWidth = 380;
const targetHeight = 300;
const existing3dNodeCount = nodes.filter(
(node) => node.type === "world3d" || node.type === "model3d"
).length;
importableTasks.forEach((task, index) => {
const isWorldTask = task.provider === "worldlabs-3d";
const modelUrl = resolveTaskModelUrl(task);
const worldAssetPayload = task.assetPayload ?? null;
const primarySplatUrl = resolveWorldTaskSplatUrl(task);
const worldImageUrl = resolveWorldTaskImageUrl(task);
const position = resolveCanvasImportPosition({
index: existing3dNodeCount + index,
totalCount: existing3dNodeCount + importableTasks.length,
viewportX,
viewportY,
viewportZoom,
stageWidth,
stageHeight,
targetWidth,
targetHeight,
});
addNode(
isWorldTask
? {
type: "world3d",
position,
size: { width: targetWidth, height: targetHeight },
data: {
worldUrl: worldAssetPayload?.worldUrl || undefined,
imageUrl: worldImageUrl || undefined,
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
splatUrl: primarySplatUrl || undefined,
previewImage: resolveWorldTaskPreviewUrl(task) || undefined,
prompt: task.prompt,
modelVersion: task.modelVersion,
status: "completed",
activeView: primarySplatUrl ? "splat" : worldImageUrl ? "image" : "mesh",
firstPerson: false,
hotspots: [],
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
metadata: {
taskId: task.taskId,
provider: task.provider,
mode: task.mode,
sourceTaskId: task.sourceTaskId,
progress: task.progress,
triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
assetPayload: task.assetPayload ?? null,
},
},
connections: [],
}
: {
type: "model3d",
position,
size: { width: targetWidth, height: targetHeight },
data: {
src: modelUrl!,
previewImage: task.previewImageUrl || undefined,
prompt: task.prompt,
modelVersion: task.modelVersion,
format: extractModelFormat(modelUrl!),
status: "completed",
metadata: {
taskId: task.taskId,
provider: task.provider,
mode: task.mode,
sourceTaskId: task.sourceTaskId,
progress: task.progress,
triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
assetPayload: task.assetPayload ?? null,
},
},
connections: [],
}
);
});
try {
await canvasService.saveCanvasState(id, getCanvasState());
} catch (error) {
console.error("[Model3DPage] 保存画布失败:", error);
}
toast.success(
importableTasks.length > 1
? `已添加 ${importableTasks.length} 个 3D 世界到画布`
: importableTasks[0]?.provider === "worldlabs-3d"
? "3D 世界已添加到画布"
: "3D 模型已添加到画布"
);
};
const handleImportToCanvas = async () => {
if (!currentTask || currentTask.status !== "success") {
toast.error("暂无可添加的 3D 资产");
return;
}
await handleImportTasksToCanvas([currentTask]);
};
const handleDownloadCurrent = () => {
const modelUrl = resolveTaskModelUrl(currentTask);
if (!modelUrl) {
toast.error("暂无可下载的 3D 模型");
return;
}
const link = document.createElement("a");
link.href = modelUrl;
link.download = `${
currentTask?.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
}-${currentTask?.taskId || "result"}.${extractModelFormat(modelUrl)}`;
link.click();
};
const handleDownloadTask = (
task: TaskRecord,
event?: MouseEvent
) => {
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) => {
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) => {
event?.stopPropagation();
if (task.provider !== "worldlabs-3d" || task.status !== "success") {
return;
}
setSelectedWorldShareTaskIds((prev) =>
prev.includes(task.taskId)
? prev.filter((taskId) => taskId !== task.taskId)
: [...prev, task.taskId]
);
},
[]
);
const handleSelectAllShareableWorlds = useCallback(() => {
setSelectedWorldShareTaskIds((prev) =>
prev.length === shareableWorldTasks.length
? []
: shareableWorldTasks.map((task) => task.taskId)
);
}, [shareableWorldTasks]);
const handleToggleWorldCanvasSelection = useCallback(
(task: TaskRecord, event?: MouseEvent) => {
event?.stopPropagation();
if (task.provider !== "worldlabs-3d" || !canImportTaskToCanvas(task)) {
return;
}
setSelectedWorldCanvasTaskIds((prev) =>
prev.includes(task.taskId)
? prev.filter((taskId) => taskId !== task.taskId)
: [...prev, task.taskId]
);
},
[]
);
const handleSelectAllImportableWorlds = useCallback(() => {
setSelectedWorldCanvasTaskIds((prev) =>
prev.length === importableWorldTasks.length
? []
: importableWorldTasks.map((task) => task.taskId)
);
}, [importableWorldTasks]);
const handleImportSelectedWorldsToCanvas = useCallback(async () => {
if (selectedWorldCanvasTasks.length === 0) {
toast.error("请选择要添加到画布的世界");
return;
}
await handleImportTasksToCanvas(selectedWorldCanvasTasks);
}, [handleImportTasksToCanvas, selectedWorldCanvasTasks]);
const handleShareSelectedWorlds = useCallback(async () => {
if (selectedWorldShareTasks.length < 2) {
toast.error("请至少选择 2 个成功生成的 3D 世界");
return;
}
try {
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskIds: selectedWorldShareTasks.map((task) => task.taskId),
shareEnabled: true,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean;
error?: string;
items?: TaskRecordApiEntity[];
shareToken?: string | null;
};
if (!response.ok || payload?.success === false) {
throw new Error(payload?.error || "批量分享世界失败");
}
const updatedTasks = Array.isArray(payload.items)
? payload.items
.map((item) => mapApiEntityToTaskRecord(item))
.filter((item): item is TaskRecord => Boolean(item))
: [];
for (const updatedTask of updatedTasks) {
upsertTaskHistory(updatedTask, { persist: false });
}
const shareToken = payload.shareToken || updatedTasks[0]?.shareToken;
const shareUrl = buildWorldShareUrl(shareToken);
if (!shareUrl) {
throw new Error("分享链接生成失败");
}
await navigator.clipboard.writeText(shareUrl);
toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量分享世界失败");
}
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
const handleDisableCurrentShare = useCallback(async () => {
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
toast.error("当前没有可关闭分享的世界资产");
return;
}
if (!currentTask.shareEnabled) {
toast.error("当前世界尚未开启分享");
return;
}
try {
await updateWorldShareState(currentTask, false);
toast.success("已关闭世界分享");
} catch (error) {
toast.error(error instanceof Error ? error.message : "关闭分享失败");
}
}, [currentTask, updateWorldShareState]);
const handleOpenPanoUrl = () => {
const imageUrl =
currentTask?.assetPayload?.panoUrl ||
currentTask?.assetPayload?.thumbnailUrl ||
currentPreviewImage;
if (!imageUrl) {
toast.error("当前任务暂无可打开的图像预览");
return;
}
window.open(imageUrl, "_blank", "noopener,noreferrer");
};
const handleConvertAndDownload = async () => {
const readyTask = getReadyTask();
if (!readyTask) return;
setIsSubmitting(true);
try {
const baseTaskId = readyTask.taskId;
const payload: Record = {
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 = {
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 = {};
for (const layer of layers) {
next[layer.id] = prev[layer.id] !== false;
}
return next;
});
setIsolatedLayerId((prev) =>
prev && layers.some((layer) => layer.id === prev) ? prev : null
);
}, []);
const currentWorldAsset =
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
const currentWorldShareUrl =
currentTask?.provider === "worldlabs-3d" && currentTask?.shareEnabled
? buildWorldShareUrl(currentTask.shareToken)
: null;
const currentSpzEntries = getSortedWorldLabsSplatEntries(currentWorldAsset?.spzUrls);
const currentPrimarySpzUrl =
pickPreferredInlineWorldLabsSplatUrl(currentWorldAsset?.spzUrls) ||
currentSpzEntries[0]?.[1] ||
null;
const currentWorldImageUrl =
currentWorldAsset?.panoUrl ||
currentWorldAsset?.thumbnailUrl ||
(workspaceMode === "world" ? currentPreviewImage : null) ||
null;
const importDisabled =
!currentTask ||
currentTask.status !== "success" ||
(currentTask.provider === "worldlabs-3d"
? !Boolean(
currentModelUrl ||
currentWorldAsset?.worldUrl ||
currentWorldImageUrl ||
currentPrimarySpzUrl
)
: !currentModelUrl);
const availableWorldViewportModes = useMemo(() => {
const modes: WorldViewportMode[] = [];
if (currentPrimarySpzUrl) modes.push("splat");
if (currentWorldImageUrl) modes.push("imagery");
if (currentModelUrl) modes.push("mesh");
return modes;
}, [currentModelUrl, currentPrimarySpzUrl, currentWorldImageUrl]);
const preferredWorldViewportMode: WorldViewportMode =
currentPrimarySpzUrl
? "splat"
: currentWorldImageUrl
? "imagery"
: currentModelUrl
? "mesh"
: "splat";
const activeWorkspaceTitle =
workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台";
const viewerEmptyStateTitle =
workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产";
useEffect(() => {
setViewerResetToken((prev) => prev + 1);
}, [currentTask?.taskId, workspaceMode, worldViewportMode]);
useEffect(() => {
if (workspaceMode !== "world") return;
setWorldViewportMode(preferredWorldViewportMode);
}, [workspaceMode, currentTask?.taskId, preferredWorldViewportMode]);
useEffect(() => {
if (workspaceMode !== "world") return;
if (availableWorldViewportModes.length === 0) return;
if (!availableWorldViewportModes.includes(worldViewportMode)) {
setWorldViewportMode(preferredWorldViewportMode);
}
}, [
availableWorldViewportModes,
preferredWorldViewportMode,
workspaceMode,
worldViewportMode,
]);
useEffect(() => {
setWorldFirstPerson(
workspaceMode === "world" && worldViewportMode === "splat"
);
}, [workspaceMode, worldViewportMode]);
return (
{/* Top Navigation Bar */}
{/* Logo & Brand */}
router.push("/")}
>
{displayLogo ? (

) : (
)}
{siteName}
{/* Divider */}
{/* Nav Links */}
积分
{pointsLabel}
{/* User Profile */}
{user?.image ? (

) : (
{userAvatarFallback}
)}
{/* 1. Left Nav Sidebar (Icon only) */}
{/* 2. Left Tool Panel (Expandable Content) */}
{activeTab === "create" &&
(workspaceMode === "world" ? "生成世界" : "生成模型")}
{activeTab === "edit" &&
(workspaceMode === "world" ? "世界资产" : "模型编辑")}
{activeTab === "scene" &&
(workspaceMode === "world" ? "世界详情" : "场景管理")}
{activeTab === "create" && (
workspaceMode === "model" ? (
<>
{[
{ id: "text", label: "文生", icon: PenLine },
{ id: "image", label: "图生", icon: ImagePlus },
{ id: "multiview", label: "多视", icon: Box },
{ id: "batch", label: "批量", icon: Images },
].map((m) => (
))}
预计消耗
{modelFeatureCostLabel}
{modelFeatureCostHint}
{mode === "image" && (
)}
{mode === "multiview" && (
)}
{modelFeatureVisibility.pbr !== false ||
modelFeatureVisibility.quad !== false ||
modelFeatureVisibility.smartLowPoly !== false ? (
{modelFeatureVisibility.pbr !== false ? (
PBR 材质
) : null}
{modelFeatureVisibility.quad !== false ? (
四边面优化
) : null}
{modelFeatureVisibility.smartLowPoly !== false ? (
智能低模
) : null}
) : null}
>
) : (
<>
{[
{ id: "text", label: "文生", icon: PenLine },
{ id: "image", label: "图生", icon: ImagePlus },
{ id: "multi-image", label: "多图", icon: Images },
{ id: "video", label: "视频", icon: Video },
].map((item) => (
))}
预计消耗
{worldFeatureCostLabel}
{worldFeatureCostHint}
setWorldDisplayName(e.target.value)}
placeholder="可选,用于区分历史记录"
value={worldDisplayName}
/>
{worldMode === "image" && (
)}
{worldMode === "multi-image" && (
建议上传围绕同一空间的多角度图片,并尽量填写合理的方位角。常用值:0 / 90 / 180 / 270。
{worldImageSlots.map((slot, index) => (
))}
)}
{worldMode === "video" && (
)}
{(worldMode === "image" || worldMode === "multi-image") && (
关闭自动重描述
)}
{worldMode === "image" && (
输入图已是全景图
)}
{worldMode === "multi-image" && (
重建输入图
)}
>
)
)}
{activeTab === "edit" &&
(workspaceMode === "model" ? (
格式转换
) : (
当前内容
这里保留当前世界可直接使用的核心内容入口。
场景描述
{currentWorldAsset?.caption ||
currentTask?.prompt ||
"暂无描述"}
生成质量
{getWorldModelDisplayLabel(
currentWorldAsset?.model || currentTask?.modelVersion
)}
共享状态
{currentWorldShareUrl ? "已开启" : "未开启"}
图像预览
{currentWorldImageUrl ? "可查看" : "暂无"}
Mesh
{currentModelUrl ? "可导入" : "缺失"}
World 视图
{currentPrimarySpzUrl ? "可查看" : "暂无"}
))}
{activeTab === "scene" &&
(workspaceMode === "model" ? (
{viewerStats.triangles.toLocaleString()}
面数
{viewerStats.vertices.toLocaleString()}
顶点
网格列表 ({viewerLayers.length})
{viewerLayers.map((layer) => (
{layer.name}
))}
) : (
{currentModelUrl ? viewerStats.triangles.toLocaleString() : "0"}
Mesh 面数
{currentSpzEntries.length}
World 资产
内容概览
这里展示当前世界的核心内容状态,方便在工作台里继续使用。
场景描述
{currentWorldAsset?.caption ||
currentTask?.prompt ||
"暂无描述"}
生成质量
{getWorldModelDisplayLabel(
currentWorldAsset?.model || currentTask?.modelVersion
)}
图像预览
{currentWorldImageUrl ? "可访问" : "暂无"}
Mesh
{currentModelUrl ? "已归档" : "暂无"}
共享状态
{currentWorldShareUrl ? "已开启" : "未开启"}
World 资产
{currentSpzEntries.length ? `${currentSpzEntries.length} 项` : "暂无"}
))}
{/* 3. Main Viewer Area */}
{workspaceMode === "world" && (
{[
{
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) => (
))}
)}
{workspaceMode === "world" ? (
) : (
)}
{/* Floating Toolbar */}
{(workspaceMode !== "world" || worldViewportMode === "mesh") && (
网格
自动旋转
setViewerSettings((prev) => ({
...prev,
lightIntensity: v[0] || 1,
}))
}
step={0.1}
value={[viewerSettings.lightIntensity]}
/>
)}
{/* 4. Right Assets Panel */}
{workspaceMode === "world" ? "世界资产库" : "模型资产库"}
点击切换{workspaceMode === "world" ? "世界" : "模型"}
{workspaceMode === "world" ? (
分享
已选 {selectedWorldShareTasks.length}
添加到画布
已选 {selectedWorldCanvasTasks.length}
) : null}
{visibleTaskHistory.map((task) => {
const canShareWorld =
task.provider === "worldlabs-3d" && task.status === "success";
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
const canImportWorld =
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
return (
{canShareWorld ? (
) : null}
{canShareWorld ? (
) : null}
{canImportWorld ? (
) : null}
{task.status === "success" && resolveTaskModelUrl(task) ? (
) : null}
);
})}
{visibleTaskHistory.length === 0 && (
{workspaceMode === "world" ? "暂无世界记录" : "暂无模型记录"}
)}
);
}