diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
index bb846af0b..090bdd21b 100644
--- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
+++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
@@ -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}`}
>
-
+ {isBatchProcessingEnabled ? (
+
+ ) : null}
{loginDialogElement}
diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx
index 6f55da7af..4a94ebd85 100644
--- a/app/canvas/[id]/model3d/page.tsx
+++ b/app/canvas/[id]/model3d/page.tsx
@@ -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 (
+
+ {label}
+
+ );
+}
+
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() {
@@ -2604,7 +2833,7 @@ export default function CanvasModel3DPage() {
{activeTab === "create" && (
workspaceMode === "model" ? (
<>
-
+
{[
{ id: "text", label: "文生", icon: PenLine },
{ id: "image", label: "图生", icon: ImagePlus },
@@ -2613,7 +2842,7 @@ export default function CanvasModel3DPage() {
].map((m) => (
))}
+
+
+ 预计消耗
+
+ {modelFeatureCostLabel}
+
+
+
+ {modelFeatureCostHint}
+
+
+
diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx
index b09c65592..cbef14f75 100644
--- a/components/canvas/CanvasWorkspace.tsx
+++ b/components/canvas/CanvasWorkspace.tsx
@@ -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 ?
: null}
{node.type === 'image' && (
增强后
-
- setComparePosition(Number(event.target.value) || 0)
- }
+
event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
- type="range"
- value={comparePosition}
- />
+ onPointerMove={(event) => event.stopPropagation()}
+ >
+
+
+
+ setComparePosition(Number(event.target.value) || 0)
+ }
+ onInput={(event) =>
+ setComparePosition(Number(event.currentTarget.value) || 0)
+ }
+ type="range"
+ value={comparePosition}
+ />
+
>
) : null}
diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx
index ee1a4c65d..2bc560b47 100644
--- a/components/canvas/RealtimeCanvasDialog.tsx
+++ b/components/canvas/RealtimeCanvasDialog.tsx
@@ -829,6 +829,8 @@ export function RealtimeCanvasDialog({
const [previewUrl, setPreviewUrl] = useState("");
const [workspaceMode, setWorkspaceMode] =
useState(initialMode);
+ const [enhanceUploadedSourceImageUrl, setEnhanceUploadedSourceImageUrl] =
+ useState("");
const [brushMode, setBrushMode] = useState("select");
const [brushColor, setBrushColor] = useState(DEFAULT_BRUSH_COLOR);
const [canvasBackground, setCanvasBackground] = useState(
@@ -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(
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx
index 7a9333704..7a17a802b 100644
--- a/components/canvas/nodes/ImageNode.tsx
+++ b/components/canvas/nodes/ImageNode.tsx
@@ -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(null);
const [boxCutoutDragEnd, setBoxCutoutDragEnd] =
useState(null);
+
+ useEffect(() => {
+ if (!batchProcessingEnabled) {
+ setSceneFusionDialogOpen(false);
+ }
+ }, [batchProcessingEnabled]);
const [boxCutoutMetrics, setBoxCutoutMetrics] =
useState(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({
/>
{/* 批量处理对话框 */}
-
+ {batchProcessingEnabled ? (
+
+ ) : null}
{/* 提示词重绘对话框 */}