feat: improve 3d canvas controls and points visibility
This commit is contained in:
parent
1cda4fa23e
commit
e92e0b24f6
@ -1577,6 +1577,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
}>;
|
||||
};
|
||||
}>("/api/site-settings", fetcher);
|
||||
const { data: sceneFusionConfigData } = useSWR<{
|
||||
enabled?: boolean;
|
||||
}>("/api/scene-fusion/config", fetcher);
|
||||
|
||||
// 生成配置
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@ -1616,7 +1619,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
const isWorkflowMaterialStepEnabled =
|
||||
workflowPageFeatureSettings.materialStepEnabled !== false;
|
||||
const isBatchProcessingEnabled =
|
||||
workflowPageFeatureSettings.batchProcessingEnabled !== false;
|
||||
workflowPageFeatureSettings.batchProcessingEnabled !== false &&
|
||||
sceneFusionConfigData?.enabled !== false;
|
||||
const sanitizedWorkflowSelections = useMemo(
|
||||
() =>
|
||||
sanitizeAgentWorkflowSelections({
|
||||
@ -2772,6 +2776,12 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
}
|
||||
}, [isBatchProcessingEnabled, shouldOpenBatchProcessing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBatchProcessingEnabled) {
|
||||
setSceneFusionDialogOpen(false);
|
||||
}
|
||||
}, [isBatchProcessingEnabled]);
|
||||
|
||||
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
|
||||
const buildPersistedCanvasData = useCallback(() => {
|
||||
return {
|
||||
@ -8643,6 +8653,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
key={`workspace-${id}`}
|
||||
>
|
||||
<CanvasWorkspace
|
||||
batchProcessingEnabled={isBatchProcessingEnabled}
|
||||
emptyStateDescription={
|
||||
isVideoMode
|
||||
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
||||
@ -9987,11 +9998,13 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
|
||||
{/* 7. 物品拾取模式覆盖层 */}
|
||||
<ObjectPickerOverlay />
|
||||
<SceneFusionDialog
|
||||
onBatchAddToCanvas={handleBatchAddToCanvas}
|
||||
onOpenChange={setSceneFusionDialogOpen}
|
||||
open={sceneFusionDialogOpen}
|
||||
/>
|
||||
{isBatchProcessingEnabled ? (
|
||||
<SceneFusionDialog
|
||||
onBatchAddToCanvas={handleBatchAddToCanvas}
|
||||
onOpenChange={setSceneFusionDialogOpen}
|
||||
open={sceneFusionDialogOpen}
|
||||
/>
|
||||
) : null}
|
||||
{loginDialogElement}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@ -99,6 +99,13 @@ import {
|
||||
resolveThreeDModelDisplayName,
|
||||
type PublicThreeDModelAliasConfig,
|
||||
} from "@/lib/model3d/model-alias-config";
|
||||
import {
|
||||
defaultPointsSystemConfig,
|
||||
get3DTaskCreditCost,
|
||||
get3DWorldCreditCost,
|
||||
normalizePointsSystemConfig,
|
||||
type PointsSystemConfig,
|
||||
} from "@/lib/points/config";
|
||||
import {
|
||||
canvasService,
|
||||
type Model3DTaskStatus,
|
||||
@ -536,6 +543,72 @@ function worldModeToTaskMode(mode: WorldGenerationMode): TaskMode {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@ -688,6 +761,8 @@ export default function CanvasModel3DPage() {
|
||||
data?: PublicThreeDModelAliasConfig;
|
||||
}>("/api/canvas/model-aliases", fetcher);
|
||||
const { data: membershipSummary } = useSWR<{
|
||||
pointsSystemConfig?: PointsSystemConfig;
|
||||
isHighVip?: boolean;
|
||||
summary?: {
|
||||
currentCredits?: number;
|
||||
};
|
||||
@ -705,6 +780,152 @@ export default function CanvasModel3DPage() {
|
||||
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 selectedBatchImageCount = useMemo(
|
||||
() => batchImageSlots.filter((slot) => Boolean(slot.file)).length,
|
||||
[batchImageSlots]
|
||||
);
|
||||
const currentModelTaskType = modelModeToTaskType(mode);
|
||||
const currentModelUnitCost = useMemo(
|
||||
() =>
|
||||
get3DTaskCreditCost(pointsSystemConfig, {
|
||||
taskType: currentModelTaskType,
|
||||
pbr,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
}),
|
||||
[currentModelTaskType, 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 worldFeatureCost = useMemo(
|
||||
() => get3DWorldCreditCost(pointsSystemConfig, worldModel),
|
||||
[pointsSystemConfig, worldModel]
|
||||
);
|
||||
const worldFeatureCostLabel = formatFeatureCostLabel({
|
||||
cost: worldFeatureCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
});
|
||||
const worldFeatureCostHint = !pointsEnabled
|
||||
? "当前未开启积分扣除"
|
||||
: isHighVip
|
||||
? "VIP 用户本次不扣积分"
|
||||
: "按当前生成质量扣除";
|
||||
const getModelModeCostLabel = useCallback(
|
||||
(targetMode: GenerationMode) => {
|
||||
const unitCost = get3DTaskCreditCost(pointsSystemConfig, {
|
||||
taskType: modelModeToTaskType(targetMode),
|
||||
pbr,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
});
|
||||
if (targetMode === "batch") {
|
||||
return `每张 ${formatFeatureCostLabel({
|
||||
cost: unitCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
})}`;
|
||||
}
|
||||
return formatFeatureCostLabel({
|
||||
cost: unitCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
});
|
||||
},
|
||||
[isHighVip, pbr, pointsEnabled, pointsSystemConfig, quad, smartLowPoly]
|
||||
);
|
||||
const getWorldModeCostLabel = useCallback(
|
||||
() =>
|
||||
formatFeatureCostLabel({
|
||||
cost: worldFeatureCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
}),
|
||||
[isHighVip, pointsEnabled, worldFeatureCost]
|
||||
);
|
||||
const getWorldModelCostLabel = useCallback(
|
||||
(targetWorldModel: string) =>
|
||||
formatFeatureCostLabel({
|
||||
cost: get3DWorldCreditCost(pointsSystemConfig, targetWorldModel),
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
}),
|
||||
[isHighVip, pointsEnabled, pointsSystemConfig]
|
||||
);
|
||||
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();
|
||||
@ -2567,7 +2788,7 @@ export default function CanvasModel3DPage() {
|
||||
<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",
|
||||
"flex min-h-[50px] flex-col items-center justify-center gap-1 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"
|
||||
@ -2575,11 +2796,15 @@ export default function CanvasModel3DPage() {
|
||||
onClick={() => setWorkspaceMode("model")}
|
||||
type="button"
|
||||
>
|
||||
3D模型模式
|
||||
<span>3D模型模式</span>
|
||||
<CostPill
|
||||
active={workspaceMode === "model"}
|
||||
label={modelFeatureCostLabel}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||||
"flex min-h-[50px] flex-col items-center justify-center gap-1 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"
|
||||
@ -2587,7 +2812,11 @@ export default function CanvasModel3DPage() {
|
||||
onClick={() => setWorkspaceMode("world")}
|
||||
type="button"
|
||||
>
|
||||
3D世界模式
|
||||
<span>3D世界模式</span>
|
||||
<CostPill
|
||||
active={workspaceMode === "world"}
|
||||
label={worldFeatureCostLabel}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="font-semibold text-sm text-white">
|
||||
@ -2604,7 +2833,7 @@ export default function CanvasModel3DPage() {
|
||||
{activeTab === "create" && (
|
||||
workspaceMode === "model" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
{[
|
||||
{ id: "text", label: "文生", icon: PenLine },
|
||||
{ id: "image", label: "图生", icon: ImagePlus },
|
||||
@ -2613,7 +2842,7 @@ export default function CanvasModel3DPage() {
|
||||
].map((m) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||||
"flex min-h-[58px] flex-col items-center justify-center gap-1 rounded-md px-1 py-2 text-[10px] transition-all",
|
||||
mode === m.id
|
||||
? "bg-zinc-800 text-white shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
@ -2623,12 +2852,29 @@ export default function CanvasModel3DPage() {
|
||||
type="button"
|
||||
>
|
||||
<m.icon className="h-4 w-4" />
|
||||
{m.label}
|
||||
<span>{m.label}</span>
|
||||
<CostPill
|
||||
active={mode === m.id}
|
||||
className="max-w-full px-1.5"
|
||||
label={getModelModeCostLabel(m.id as GenerationMode)}
|
||||
/>
|
||||
</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
|
||||
@ -2757,7 +3003,10 @@ export default function CanvasModel3DPage() {
|
||||
</div>
|
||||
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||
<span>PBR 材质</span>
|
||||
<span className="flex items-center gap-2">
|
||||
PBR 材质
|
||||
<CostPill label={pbrExtraCostLabel} />
|
||||
</span>
|
||||
<Switch
|
||||
checked={pbr}
|
||||
className="data-[state=checked]:bg-yellow-500"
|
||||
@ -2765,7 +3014,10 @@ export default function CanvasModel3DPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||
<span>四边面优化</span>
|
||||
<span className="flex items-center gap-2">
|
||||
四边面优化
|
||||
<CostPill label={quadExtraCostLabel} />
|
||||
</span>
|
||||
<Switch
|
||||
checked={quad}
|
||||
className="data-[state=checked]:bg-yellow-500"
|
||||
@ -2773,7 +3025,10 @@ export default function CanvasModel3DPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||
<span>智能低模</span>
|
||||
<span className="flex items-center gap-2">
|
||||
智能低模
|
||||
<CostPill label={smartLowPolyExtraCostLabel} />
|
||||
</span>
|
||||
<Switch
|
||||
checked={smartLowPoly}
|
||||
className="data-[state=checked]:bg-yellow-500"
|
||||
@ -2795,13 +3050,15 @@ export default function CanvasModel3DPage() {
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSubmitting ? "生成中..." : "开始生成"}
|
||||
{isSubmitting
|
||||
? "生成中..."
|
||||
: `开始生成 · ${modelFeatureCostLabel}`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
{[
|
||||
{ id: "text", label: "文生", icon: PenLine },
|
||||
{ id: "image", label: "图生", icon: ImagePlus },
|
||||
@ -2810,7 +3067,7 @@ export default function CanvasModel3DPage() {
|
||||
].map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||||
"flex min-h-[58px] flex-col items-center justify-center gap-1 rounded-md px-1 py-2 text-[10px] transition-all",
|
||||
worldMode === item.id
|
||||
? "bg-zinc-800 text-white shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
@ -2820,12 +3077,29 @@ export default function CanvasModel3DPage() {
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
<span>{item.label}</span>
|
||||
<CostPill
|
||||
active={worldMode === item.id}
|
||||
className="max-w-full px-1.5"
|
||||
label={getWorldModeCostLabel()}
|
||||
/>
|
||||
</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
|
||||
@ -3025,7 +3299,12 @@ export default function CanvasModel3DPage() {
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
>
|
||||
{item.displayName}
|
||||
<span className="flex w-full items-center justify-between gap-3">
|
||||
<span>{item.displayName}</span>
|
||||
<span className="text-[10px] text-yellow-300">
|
||||
{getWorldModelCostLabel(item.id)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@ -3077,7 +3356,9 @@ export default function CanvasModel3DPage() {
|
||||
) : (
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSubmitting || uploadingImage ? "处理中..." : "开始生成世界"}
|
||||
{isSubmitting || uploadingImage
|
||||
? "处理中..."
|
||||
: `开始生成世界 · ${worldFeatureCostLabel}`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@ -3133,7 +3414,7 @@ export default function CanvasModel3DPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
className="min-w-[82px] flex-col gap-0.5 border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!currentTask ||
|
||||
@ -3142,7 +3423,10 @@ export default function CanvasModel3DPage() {
|
||||
onClick={handleConvertAndDownload}
|
||||
variant="outline"
|
||||
>
|
||||
转换
|
||||
<span>转换</span>
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{convertCostLabel}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -3152,7 +3436,7 @@ export default function CanvasModel3DPage() {
|
||||
常用操作
|
||||
</h4>
|
||||
<Button
|
||||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
className="w-full justify-between border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!currentTask ||
|
||||
@ -3161,10 +3445,13 @@ export default function CanvasModel3DPage() {
|
||||
onClick={handleRefineModel}
|
||||
variant="outline"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" /> 模型精修
|
||||
<span className="flex items-center">
|
||||
<Sparkles className="mr-2 h-4 w-4" /> 模型精修
|
||||
</span>
|
||||
<CostPill label={refineCostLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
className="w-full justify-between border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!currentTask ||
|
||||
@ -3173,10 +3460,13 @@ export default function CanvasModel3DPage() {
|
||||
onClick={handleTextureModel}
|
||||
variant="outline"
|
||||
>
|
||||
<Palette className="mr-2 h-4 w-4" /> 纹理重绘
|
||||
<span className="flex items-center">
|
||||
<Palette className="mr-2 h-4 w-4" /> 纹理重绘
|
||||
</span>
|
||||
<CostPill label={textureCostLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
className="w-full justify-between border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!currentTask ||
|
||||
@ -3185,7 +3475,10 @@ export default function CanvasModel3DPage() {
|
||||
onClick={handleRigModel}
|
||||
variant="outline"
|
||||
>
|
||||
<Bone className="mr-2 h-4 w-4" /> 自动绑定
|
||||
<span className="flex items-center">
|
||||
<Bone className="mr-2 h-4 w-4" /> 自动绑定
|
||||
</span>
|
||||
<CostPill label={rigCostLabel} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -183,6 +183,7 @@ interface CanvasWorkspaceProps {
|
||||
emptyStateDescription?: string;
|
||||
workspaceVariant?: "canvas" | "video";
|
||||
layerSplitEnabled?: boolean;
|
||||
batchProcessingEnabled?: boolean;
|
||||
showEmptyState?: boolean;
|
||||
showZoomControls?: boolean;
|
||||
onBlankCanvasDoubleClick?: (payload: {
|
||||
@ -197,6 +198,7 @@ export function CanvasWorkspace({
|
||||
emptyStateDescription = '点击顶部"添加"按钮开始创作',
|
||||
workspaceVariant = "canvas",
|
||||
layerSplitEnabled = false,
|
||||
batchProcessingEnabled = true,
|
||||
showEmptyState = true,
|
||||
showZoomControls = true,
|
||||
onBlankCanvasDoubleClick,
|
||||
@ -1493,6 +1495,7 @@ export function CanvasWorkspace({
|
||||
{isWorkflowCanvas ? <VideoCanvasNodeMarkers node={node} /> : null}
|
||||
{node.type === 'image' && (
|
||||
<ImageNode
|
||||
batchProcessingEnabled={batchProcessingEnabled}
|
||||
isLayerSplitEnabled={layerSplitEnabled}
|
||||
isSelected={isSelected}
|
||||
node={node}
|
||||
|
||||
@ -160,6 +160,13 @@ function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isEnhanceCompareControlTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof HTMLElement &&
|
||||
Boolean(target.closest("[data-enhance-pro-compare-control='true']"))
|
||||
);
|
||||
}
|
||||
|
||||
function fitDimensions(width: number, height: number, maxOutputEdge: number) {
|
||||
const safeWidth = Math.max(1, Math.round(width || 1024));
|
||||
const safeHeight = Math.max(1, Math.round(height || 1024));
|
||||
@ -681,6 +688,9 @@ export function EnhanceProWorkspace({
|
||||
if (!activePreviewUrl || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (isEnhanceCompareControlTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewDragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
@ -783,18 +793,38 @@ export function EnhanceProWorkspace({
|
||||
<div className="pointer-events-none absolute top-3 right-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
|
||||
增强后
|
||||
</div>
|
||||
<input
|
||||
aria-label="原图和增强结果对比滑块"
|
||||
className="pointer-events-auto absolute right-6 bottom-5 left-6 accent-[#111111]"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
setComparePosition(Number(event.target.value) || 0)
|
||||
}
|
||||
<div
|
||||
className="pointer-events-auto absolute right-6 bottom-5 left-6 h-8 cursor-col-resize rounded-full"
|
||||
data-enhance-pro-compare-control="true"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="range"
|
||||
value={comparePosition}
|
||||
/>
|
||||
onPointerMove={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="-translate-y-1/2 pointer-events-none absolute top-1/2 right-0 left-0 h-1 rounded-full bg-white/55 shadow-[0_0_0_1px_rgba(0,0,0,0.2)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#111111]"
|
||||
style={{ width: `${comparePosition}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 h-5 w-5 rounded-full border-2 border-white bg-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.28)]"
|
||||
style={{ left: `${comparePosition}%` }}
|
||||
/>
|
||||
<input
|
||||
aria-label="原图和增强结果对比滑块"
|
||||
className="absolute inset-0 h-full w-full cursor-col-resize opacity-0"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
setComparePosition(Number(event.target.value) || 0)
|
||||
}
|
||||
onInput={(event) =>
|
||||
setComparePosition(Number(event.currentTarget.value) || 0)
|
||||
}
|
||||
type="range"
|
||||
value={comparePosition}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -829,6 +829,8 @@ export function RealtimeCanvasDialog({
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [workspaceMode, setWorkspaceMode] =
|
||||
useState<RealtimeWorkspaceMode>(initialMode);
|
||||
const [enhanceUploadedSourceImageUrl, setEnhanceUploadedSourceImageUrl] =
|
||||
useState("");
|
||||
const [brushMode, setBrushMode] = useState<RealtimeBrushMode>("select");
|
||||
const [brushColor, setBrushColor] = useState<string>(DEFAULT_BRUSH_COLOR);
|
||||
const [canvasBackground, setCanvasBackground] = useState<string>(
|
||||
@ -889,15 +891,23 @@ export function RealtimeCanvasDialog({
|
||||
return (
|
||||
selectedImageLayer?.src ||
|
||||
imageLayers[0]?.src ||
|
||||
enhanceUploadedSourceImageUrl ||
|
||||
previewUrl ||
|
||||
initialImageUrl ||
|
||||
""
|
||||
);
|
||||
}, [imageLayers, initialImageUrl, previewUrl, selectedImageLayer?.src]);
|
||||
}, [
|
||||
enhanceUploadedSourceImageUrl,
|
||||
imageLayers,
|
||||
initialImageUrl,
|
||||
previewUrl,
|
||||
selectedImageLayer?.src,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setWorkspaceMode(initialMode);
|
||||
setEnhanceUploadedSourceImageUrl("");
|
||||
}, [initialMode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -2854,6 +2864,17 @@ export function RealtimeCanvasDialog({
|
||||
if (files.length === 0) return;
|
||||
|
||||
try {
|
||||
if (workspaceMode === "enhance-pro") {
|
||||
const file = files[0];
|
||||
if (!file?.type.startsWith("image/")) {
|
||||
throw new Error("请上传图片文件");
|
||||
}
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
setEnhanceUploadedSourceImageUrl(dataUrl);
|
||||
toast.success("已选择用于图像增强 Pro 的图片");
|
||||
return;
|
||||
}
|
||||
|
||||
const importedCount = await importFilesAsLayers(files);
|
||||
toast.success(
|
||||
importedCount === 1
|
||||
@ -2866,7 +2887,7 @@ export function RealtimeCanvasDialog({
|
||||
event.currentTarget.value = "";
|
||||
}
|
||||
},
|
||||
[importFilesAsLayers]
|
||||
[fileToDataUrl, importFilesAsLayers, workspaceMode]
|
||||
);
|
||||
|
||||
const handleLayerSplitUpload = useCallback(
|
||||
|
||||
@ -352,12 +352,14 @@ interface ImageNodeProps {
|
||||
node: CanvasNode;
|
||||
isSelected: boolean;
|
||||
isLayerSplitEnabled?: boolean;
|
||||
batchProcessingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ImageNode({
|
||||
node,
|
||||
isSelected,
|
||||
isLayerSplitEnabled = false,
|
||||
batchProcessingEnabled = true,
|
||||
}: ImageNodeProps) {
|
||||
const data = node.data as ImageNodeData;
|
||||
const contentScale = useMemo(
|
||||
@ -429,6 +431,12 @@ export function ImageNode({
|
||||
useState<BoxCutoutPoint | null>(null);
|
||||
const [boxCutoutDragEnd, setBoxCutoutDragEnd] =
|
||||
useState<BoxCutoutPoint | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchProcessingEnabled) {
|
||||
setSceneFusionDialogOpen(false);
|
||||
}
|
||||
}, [batchProcessingEnabled]);
|
||||
const [boxCutoutMetrics, setBoxCutoutMetrics] =
|
||||
useState<ImageDisplayMetrics | null>(null);
|
||||
|
||||
@ -2263,6 +2271,10 @@ export function ImageNode({
|
||||
return;
|
||||
}
|
||||
if (action === "scene-fusion") {
|
||||
if (!batchProcessingEnabled) {
|
||||
toast.info("批量处理功能未开启");
|
||||
return;
|
||||
}
|
||||
setSceneFusionDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
@ -3377,11 +3389,13 @@ export function ImageNode({
|
||||
/>
|
||||
|
||||
{/* 批量处理对话框 */}
|
||||
<SceneFusionDialog
|
||||
onBatchAddToCanvas={handleSceneFusionBatchAddToCanvas}
|
||||
onOpenChange={setSceneFusionDialogOpen}
|
||||
open={sceneFusionDialogOpen}
|
||||
/>
|
||||
{batchProcessingEnabled ? (
|
||||
<SceneFusionDialog
|
||||
onBatchAddToCanvas={handleSceneFusionBatchAddToCanvas}
|
||||
onOpenChange={setSceneFusionDialogOpen}
|
||||
open={sceneFusionDialogOpen}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 提示词重绘对话框 */}
|
||||
<Dialog onOpenChange={setPromptDialogOpen} open={promptDialogOpen}>
|
||||
|
||||
@ -313,6 +313,7 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
|
||||
className="h-7 rounded-full px-2"
|
||||
onClick={handleToggleFirstPerson}
|
||||
size="sm"
|
||||
title={data.firstPerson ? "退出漫游" : "进入漫游"}
|
||||
type="button"
|
||||
variant={data.firstPerson ? "secondary" : "ghost"}
|
||||
>
|
||||
@ -322,6 +323,7 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
|
||||
className="h-7 rounded-full px-2"
|
||||
onClick={handleToggleHotspotMode}
|
||||
size="sm"
|
||||
title={interactionMode === "hotspot" ? "退出热点标注" : "添加热点"}
|
||||
type="button"
|
||||
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
|
||||
>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Html, OrbitControls, PointerLockControls } from "@react-three/drei";
|
||||
import { Html, OrbitControls } from "@react-three/drei";
|
||||
import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber";
|
||||
import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark";
|
||||
import {
|
||||
@ -637,11 +637,13 @@ function CameraMovementControls({
|
||||
moveRef.current.normalize().multiplyScalar(distance);
|
||||
camera.position.add(moveRef.current);
|
||||
|
||||
if (orbitControlsRef.current?.target) {
|
||||
orbitControlsRef.current.target.add(moveRef.current);
|
||||
}
|
||||
orbitControlsRef.current?.update?.();
|
||||
});
|
||||
|
||||
if (!enabled || !firstPerson) return null;
|
||||
return <PointerLockControls makeDefault />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function HotspotBillboard({
|
||||
@ -917,9 +919,16 @@ function WorldSplatScene({
|
||||
/>
|
||||
) : (
|
||||
<OrbitControls
|
||||
dampingFactor={0.08}
|
||||
enableDamping
|
||||
enablePan={false}
|
||||
enableRotate={false}
|
||||
enableRotate
|
||||
enableZoom={false}
|
||||
mouseButtons={{
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.ROTATE,
|
||||
RIGHT: THREE.MOUSE.ROTATE,
|
||||
}}
|
||||
ref={orbitControlsRef}
|
||||
/>
|
||||
)}
|
||||
@ -947,8 +956,15 @@ function WorldSplatScene({
|
||||
) : null}
|
||||
</div>
|
||||
{(firstPerson || interactionMode === "hotspot") && (
|
||||
<div className="flex items-end justify-end">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
{firstPerson && interactionMode === "navigate" ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[10px] text-zinc-300 shadow-xl backdrop-blur-sm">
|
||||
WASD / 方向键移动,拖动画面转向,Q/R 上升,E/F 下降
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
|
||||
<Crosshair className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user