feat: improve 3d canvas controls and points visibility
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-20 21:43:11 +08:00
parent 1cda4fa23e
commit e92e0b24f6
8 changed files with 446 additions and 54 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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(

View File

@ -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}>

View File

@ -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"}
>

View File

@ -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>