fix: polish canvas generation workflows

This commit is contained in:
134 2026-06-14 09:39:49 +08:00
parent a420e4cf33
commit cf21c80d24
18 changed files with 1179 additions and 246 deletions

View File

@ -81,6 +81,8 @@ export function LocalAiImagePipelineSections({
setWatermarkRemoveConfig,
angleEditConfig,
setAngleEditConfig,
pointsSystemConfig,
updatePointsCostField,
layerSplitConfig,
setLayerSplitConfig,
isLocalAiSectionExpanded,
@ -1447,6 +1449,12 @@ export function LocalAiImagePipelineSections({
label: aiWashConfig.modeLabel || "AI洗图",
tone: "neutral",
},
{
label: aiWashConfig.hideModelSelector
? "前台隐藏模型"
: "前台可选模型",
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
},
]}
/>
}
@ -1633,6 +1641,23 @@ export function LocalAiImagePipelineSections({
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium text-sm">隐藏前台生图模型选择</div>
<div className="text-muted-foreground text-xs">
开启后,AI洗图页面不再显示生图模型下拉框,直接使用后台预设或默认模型。
</div>
</div>
<Switch
checked={aiWashConfig.hideModelSelector}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
hideModelSelector: checked,
}))
}
/>
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
@ -2383,6 +2408,13 @@ export function LocalAiImagePipelineSections({
label: `${angleEditConfig.inferenceSteps} steps`,
tone: "neutral",
},
{
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
tone:
pointsSystemConfig.creditCostPerImageAngleEdit > 0
? "neutral"
: "disabled",
},
]}
/>
}
@ -2453,6 +2485,28 @@ export function LocalAiImagePipelineSections({
<Separator />
{/* 积分消耗 */}
<div className="space-y-2">
<Label htmlFor="angleEditCreditCost">积分消耗</Label>
<Input
id="angleEditCreditCost"
min={0}
onChange={(e) =>
updatePointsCostField(
"creditCostPerImageAngleEdit",
e.target.value
)
}
type="number"
value={pointsSystemConfig.creditCostPerImageAngleEdit}
/>
<div className="text-muted-foreground text-xs">
每次成功生成多角度图片后扣除的积分;填写 0 表示免费。保存本配置时会同步保存到全站积分设置。
</div>
</div>
<Separator />
{/* LoRA 强度 */}
<div className="space-y-3">
<div className="flex justify-between">

View File

@ -8,6 +8,7 @@ import type {
EnhanceProPreset,
} from "@/lib/enhance-pro/config";
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config";
import type { PointsSystemConfig } from "@/lib/points/config";
import type { ProductPlanningApiConfig } from "@/lib/product-planning/config";
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
@ -331,6 +332,11 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
angleEditConfig: AngleEditConfig;
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
pointsSystemConfig: PointsSystemConfig;
updatePointsCostField: (
key: keyof PointsSystemConfig,
value: unknown
) => void;
layerSplitConfig: LayerSplitConfig;
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
renderEnhanceProPresetEditor: (

View File

@ -262,6 +262,7 @@ import {
type EnhanceProPointsResolutionBucket,
normalizePointsPackages,
normalizePointsSystemConfig,
POINTS_SYSTEM_CONFIG_KEY,
type PointsPackage,
type PointsSystemConfig,
} from "@/lib/points/config";
@ -799,7 +800,8 @@ type PointsCostField =
| "creditCostPerImageBoxCutout"
| "creditCostPerImageWatermarkRemove"
| "creditCostPerImageExtend"
| "creditCostPerImageRepaint";
| "creditCostPerImageRepaint"
| "creditCostPerImageAngleEdit";
const THREE_D_GENERATION_POINT_FIELDS: Array<{
key: PointsCostField;
@ -885,6 +887,11 @@ const IMAGE_TOOL_POINT_FIELDS: Array<{
label: "重绘",
description: "调用图片提示词重绘功能时扣除。",
},
{
key: "creditCostPerImageAngleEdit",
label: "多角度",
description: "调用 fal.ai 多角度 / 角度编辑功能时扣除。",
},
];
type AiModel = {
@ -4484,7 +4491,7 @@ export function AdminSettingsClient() {
});
};
const updatePointsCostField = (key: PointsCostField, value: unknown) => {
const updatePointsCostField = (key: keyof PointsSystemConfig, value: unknown) => {
setSiteSettings((prev) => {
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
return {
@ -5298,7 +5305,11 @@ export function AdminSettingsClient() {
return;
}
const response = await fetch("/api/admin/settings", {
const normalizedPointsSystemConfig = normalizePointsSystemConfig(
siteSettings.pointsSystemConfig
);
const [angleResponse, pointsResponse] = await Promise.all([
fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -5313,9 +5324,18 @@ export function AdminSettingsClient() {
timeout: angleEditConfig.timeout,
},
}),
});
}),
fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: POINTS_SYSTEM_CONFIG_KEY,
value: normalizedPointsSystemConfig,
}),
}),
]);
if (!response.ok) throw new Error("保存失败");
if (!angleResponse.ok || !pointsResponse.ok) throw new Error("保存失败");
toast.success("角度编辑API配置保存成功");
} catch (error) {
toast.error("保存角度编辑API配置失败");
@ -17738,6 +17758,7 @@ export function AdminSettingsClient() {
isLocalAiSectionExpanded={isLocalAiSectionExpanded}
layerSplitConfig={layerSplitConfig}
modelsData={modelsData}
pointsSystemConfig={pointsSystemConfig}
renderEnhanceProPresetEditor={renderEnhanceProPresetEditor}
rmbgConfig={rmbgConfig}
savedEnhanceProKeyRows={savedEnhanceProKeyRows}
@ -17756,6 +17777,7 @@ export function AdminSettingsClient() {
setSelectedEnhanceProPresetId={setSelectedEnhanceProPresetId}
setWatermarkRemoveConfig={setWatermarkRemoveConfig}
toggleLocalAiSection={toggleLocalAiSection}
updatePointsCostField={updatePointsCostField}
watermarkRemoveConfig={watermarkRemoveConfig}
/>

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
@ -10,6 +11,8 @@ import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
const logger = createLogger("angle-edit-api");
@ -171,6 +174,19 @@ export async function POST(request: NextRequest) {
zoom: finalZoom,
});
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
reason: "image_angle_edit",
modelId: "qwen-image-edit-2511-multiple-angles",
note: "多角度编辑消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
// 4. 准备图片输入
let imageInput: string;
if (image_url) {
@ -256,6 +272,19 @@ export async function POST(request: NextRequest) {
})
.returning();
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `多角度编辑消耗积分(${savedImage.id})`,
});
} catch (pointsError) {
logger.error("points_consume_failed", pointsError, {
imageId: savedImage.id,
userId: session.user.id,
});
}
}
try {
const sourceAssets =
typeof image_url === "string" && image_url.trim()

View File

@ -6,6 +6,7 @@ import {
findStyleProImageModelByRef,
getStyleProImageModelDisplayEntries,
getStyleProImageModelEntries,
sortStyleProImageModelEntries,
toPublicStyleProModelOption,
} from "@/lib/style-pro/model-options";
import {
@ -64,7 +65,9 @@ export async function GET() {
success: true,
defaultModelId: defaultEntry?.model.id || null,
realisticUnitCost,
models: displayEntries.map(toPublicStyleProModelOption),
models: sortStyleProImageModelEntries(displayEntries).map(
toPublicStyleProModelOption
),
});
} catch (error) {
console.error("[Style Pro Model Options] Failed:", error);

View File

@ -81,6 +81,9 @@ function formatLedgerDetail(params: {
if (reason === "realtime_canvas_generation") {
return "实时绘画生成消耗积分";
}
if (reason === "image_angle_edit") {
return "多角度编辑消耗积分";
}
if (reason === "model3d_task") {
const taskMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN);

View File

@ -339,6 +339,71 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | 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 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}`,
@ -651,7 +716,6 @@ export default function CanvasModel3DPage() {
const viewport = useCanvasStore(canvasSelectors.viewport);
const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore(canvasSelectors.nodes);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
@ -722,12 +786,13 @@ export default function CanvasModel3DPage() {
);
const [worldViewportMode, setWorldViewportMode] =
useState<WorldViewportMode>("world");
const [worldFirstPerson, setWorldFirstPerson] = useState(false);
const [worldFirstPerson, setWorldFirstPerson] = useState(true);
const [viewerResetToken, setViewerResetToken] = useState(0);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
const [selectedWorldCanvasTaskIds, setSelectedWorldCanvasTaskIds] = useState<string[]>([]);
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
null
);
@ -1274,10 +1339,21 @@ export default function CanvasModel3DPage() {
),
[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));
@ -1287,6 +1363,14 @@ export default function CanvasModel3DPage() {
});
}, [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 ||
@ -1993,35 +2077,12 @@ export default function CanvasModel3DPage() {
}
};
const handleImportToCanvas = async () => {
if (!currentTask || currentTask.status !== "success") {
const handleImportTasksToCanvas = async (tasks: TaskRecord[]) => {
const importableTasks = tasks.filter(canImportTaskToCanvas);
if (importableTasks.length === 0) {
toast.error("暂无可添加的 3D 资产");
return;
}
const isWorldTask = currentTask.provider === "worldlabs-3d";
const worldAssetPayload = currentTask.assetPayload ?? null;
const primarySplatUrl = isWorldTask
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
: null;
const worldImageUrl =
worldAssetPayload?.panoUrl ||
worldAssetPayload?.thumbnailUrl ||
currentTask.previewImageUrl ||
null;
const canImportWorld = Boolean(
currentModelUrl ||
worldAssetPayload?.worldUrl ||
worldImageUrl ||
primarySplatUrl
);
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型");
return;
}
const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1;
@ -2030,92 +2091,109 @@ export default function CanvasModel3DPage() {
typeof window !== "undefined" ? window.innerHeight : 720;
const targetWidth = 380;
const targetHeight = 300;
const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2;
const y =
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
const existing3dNodeCount = nodes.filter(
(node) => node.type === "world3d" || node.type === "model3d"
).length;
if (isWorldTask) {
const worldNodeIds = nodes
.filter((node) => node.type === "world3d")
.map((node) => node.id);
for (const nodeId of worldNodeIds) {
deleteNode(nodeId);
}
}
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: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
worldUrl: worldAssetPayload?.worldUrl || undefined,
imageUrl: worldImageUrl || undefined,
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
splatUrl: primarySplatUrl || undefined,
previewImage:
currentTask.previewImageUrl ||
worldAssetPayload?.thumbnailUrl ||
undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
status: "completed",
activeView: primarySplatUrl
? "splat"
: worldImageUrl
? "image"
: "mesh",
firstPerson: false,
hotspots: [],
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
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: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: currentModelUrl!,
previewImage: currentTask.previewImageUrl || undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
format: extractModelFormat(currentModelUrl!),
status: "completed",
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
connections: [],
}
: {
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: [],
}
);
connections: [],
}
);
});
try {
await canvasService.saveCanvasState(id, getCanvasState());
} catch (error) {
console.error("[Model3DPage] 保存画布失败:", error);
}
toast.success(isWorldTask ? "3D 世界已添加到画布" : "3D 模型已添加到画布");
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 = () => {
@ -2229,6 +2307,37 @@ export default function CanvasModel3DPage() {
);
}, [shareableWorldTasks]);
const handleToggleWorldCanvasSelection = useCallback(
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
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 世界");
@ -3892,31 +4001,27 @@ export default function CanvasModel3DPage() {
{worldViewportMode === "splat" ? (
<>
<div className="font-medium text-zinc-200">
{worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景主体中心旋转"}
{worldFirstPerson ? "漫游:WASD / 方向键" : "观察:拖拽旋转"}
</div>
<div className="mt-1">
<div className="mt-1 truncate">
{worldFirstPerson
? "按住左键拖动画面转向,WASD 或方向键移动,Q/R 上升,E/F 下降。"
: "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
? "左键转向 · WASD 移动 · Q/R 上升 · E/F 下降"
: "左键旋转 · 右键平移 · 滚轮缩放"}
</div>
</>
) : worldViewportMode === "mesh" ? (
<>
<div className="font-medium text-zinc-200">
Mesh 视图已恢复
</div>
<div className="mt-1">
当前世界关联的 Mesh 会在这里直接显示,也可以从右侧资产库点击缩略图切换当前模型。
Mesh 视图
</div>
<div className="mt-1 truncate">检查几何结构与模型资产。</div>
</>
) : (
<>
<div className="font-medium text-zinc-200">
当前为图像预览
</div>
<div className="mt-1">
可以切回 World 查看空间结果,或切到 Mesh 继续检查几何结构。
图像预览
</div>
<div className="mt-1 truncate">查看全景/缩略图结果。</div>
</>
)}
</div>
@ -3937,6 +4042,9 @@ export default function CanvasModel3DPage() {
previewImageUrl={currentPreviewImage}
resetViewToken={viewerResetToken}
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
showMovementControls={
worldViewportMode === "splat" || worldViewportMode === "mesh"
}
splatUrl={currentPrimarySpzUrl}
worldUrl={currentWorldAsset?.worldUrl}
/>
@ -4023,7 +4131,7 @@ export default function CanvasModel3DPage() {
{workspaceMode === "world" ? "世界资产库" : "模型资产库"}
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
点击缩略图切换当前{workspaceMode === "world" ? "世界 / 模型" : "模型"}
点击切换{workspaceMode === "world" ? "世界" : "模型"}
</p>
</div>
<Button
@ -4038,7 +4146,7 @@ export default function CanvasModel3DPage() {
{workspaceMode === "world" ? (
<div className="space-y-2 border-white/10 border-b px-3 py-3">
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
<span>多世界分享</span>
<span>分享</span>
<span>已选 {selectedWorldShareTasks.length}</span>
</div>
<div className="grid grid-cols-2 gap-2">
@ -4064,6 +4172,33 @@ export default function CanvasModel3DPage() {
分享所选
</Button>
</div>
<div className="flex items-center justify-between gap-2 pt-1 text-[11px] text-zinc-500">
<span>添加到画布</span>
<span>已选 {selectedWorldCanvasTasks.length}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
disabled={importableWorldTasks.length === 0}
onClick={handleSelectAllImportableWorlds}
size="sm"
variant="outline"
>
{selectedWorldCanvasTaskIds.length === importableWorldTasks.length &&
importableWorldTasks.length > 0
? "取消全选"
: "全选可添加"}
</Button>
<Button
className="h-8 border-yellow-400/30 bg-yellow-400/15 text-xs text-yellow-100 hover:bg-yellow-400/25"
disabled={selectedWorldCanvasTasks.length === 0}
onClick={handleImportSelectedWorldsToCanvas}
size="sm"
variant="outline"
>
添加 ({selectedWorldCanvasTasks.length})
</Button>
</div>
</div>
) : null}
<ScrollArea className="flex-1">
@ -4072,6 +4207,10 @@ export default function CanvasModel3DPage() {
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);
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
return (
<button
@ -4079,8 +4218,8 @@ export default function CanvasModel3DPage() {
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
currentTask?.taskId === task.taskId &&
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
isSelectedForShare &&
"border-cyan-300 ring-2 ring-cyan-300/40"
isSelectedForShare && "border-cyan-300 ring-2 ring-cyan-300/40",
isSelectedForCanvas && "border-yellow-300 ring-2 ring-yellow-300/40"
)}
key={task.taskId}
onClick={() => handleSelectTask(task)}
@ -4106,6 +4245,26 @@ export default function CanvasModel3DPage() {
<Link2 className="h-3.5 w-3.5" />
</Button>
) : null}
{canImportWorld ? (
<Button
className={cn(
"absolute right-2 bottom-2 z-20 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
isSelectedForCanvas &&
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50 opacity-100"
)}
onClick={(event) => handleToggleWorldCanvasSelection(task, event)}
size="icon"
title={isSelectedForCanvas ? "取消添加选择" : "选中添加到画布"}
type="button"
variant="ghost"
>
{isSelectedForCanvas ? (
<Check className="h-3.5 w-3.5" />
) : (
<PlusCircle className="h-3.5 w-3.5" />
)}
</Button>
) : null}
{canShareWorld ? (
<Button
className={cn(
@ -4135,18 +4294,19 @@ export default function CanvasModel3DPage() {
<Download className="h-3.5 w-3.5" />
</Button>
) : null}
{task.previewImageUrl ? (
{previewUrl ? (
<img
alt="preview"
className="h-full w-full object-cover transition-transform group-hover:scale-110"
src={task.previewImageUrl}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
src={previewUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/72 via-black/10 to-black/18 opacity-95" />
<div className="absolute inset-x-0 bottom-0 p-2">
<div className="flex items-center justify-between">
<Badge
className="h-4 px-1 text-[9px]"
@ -4163,23 +4323,20 @@ export default function CanvasModel3DPage() {
{task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"}
</Badge>
</div>
<div className="mt-1 truncate text-left text-[10px] text-zinc-300">
{modeLabel(task.mode)}
<div className="mt-1 flex items-center justify-between gap-2 text-[10px]">
<span className="min-w-0 truncate text-left text-zinc-300">
{modeLabel(task.mode)}
</span>
<span className="shrink-0 text-zinc-500">
{formatDistanceToNow(new Date(task.createdAt), {
addSuffix: true,
locale: zhCN,
})}
</span>
</div>
<div className="truncate text-left text-[10px] text-zinc-500">
{formatDistanceToNow(new Date(task.createdAt), {
addSuffix: true,
locale: zhCN,
})}
</div>
{task.status === "success" && resolveTaskModelUrl(task) ? (
<div className="mt-1 text-left text-[10px] text-zinc-200">
点击右上角可下载模型
</div>
) : null}
{task.provider === "worldlabs-3d" && task.shareEnabled ? (
<div className="mt-1 text-left text-[10px] text-amber-200">
已开启分享
已分享
</div>
) : null}
</div>

View File

@ -47,6 +47,7 @@ import {
} from "@/lib/ai-wash/config";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -181,6 +182,44 @@ function hashValue(value: string) {
return (hash >>> 0).toString(36);
}
function shouldUploadAiWashReferenceImage(value: string) {
return /^data:image\/[a-z0-9.+-]+;base64,/i.test(value) || value.startsWith("blob:");
}
async function uploadAiWashReferenceImage(imageUrl: string) {
const normalized = imageUrl.trim();
if (!shouldUploadAiWashReferenceImage(normalized)) {
return normalized;
}
const response = await fetch(normalized);
if (!response.ok) {
throw new Error("读取洗图底图失败,请重新上传图片");
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
throw new Error("洗图底图格式无效,请上传图片文件");
}
const extension = blob.type.includes("jpeg")
? "jpg"
: blob.type.includes("webp")
? "webp"
: blob.type.includes("png")
? "png"
: "jpg";
const file = new File([blob], `ai-wash-reference-${Date.now()}.${extension}`, {
type: blob.type || "image/jpeg",
});
const uploadResult = await imageGenerationService.uploadImage(file);
const uploadedUrl = uploadResult.data?.url || "";
if (!uploadResult.success || !uploadedUrl) {
throw new Error(uploadResult.error || "洗图底图上传失败,请稍后重试");
}
return uploadedUrl;
}
function createHistoryScopeKey(value?: string | null) {
const raw = String(value || "").trim();
return raw ? `ai-wash-scope-${hashValue(raw)}` : "ai-wash-scope-global";
@ -534,7 +573,15 @@ export function AiWashWorkspace({
[enabledPresets, modelOptionByRef, selectedLevelIndexByControl, selectedUserModel, selectedUserModelId]
);
const shouldShowUserModelSelector = useMemo(
() => presetExecutionStates.some((item) => item.usesUserSelectableModel),
() =>
!config.hideModelSelector &&
presetExecutionStates.some((item) => item.usesUserSelectableModel),
[config.hideModelSelector, presetExecutionStates]
);
const hasExecutableModelRefs = useMemo(
() =>
presetExecutionStates.length > 0 &&
presetExecutionStates.every((item) => Boolean(item.effectiveModelRef)),
[presetExecutionStates]
);
@ -680,6 +727,10 @@ export function AiWashWorkspace({
toast.error("请先选择一个可用的生图模型");
return;
}
if (!hasExecutableModelRefs) {
toast.error("当前没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
return;
}
const currentRunId = runIdRef.current + 1;
runIdRef.current = currentRunId;
@ -714,6 +765,20 @@ export function AiWashWorkspace({
);
const startedAt = Date.now();
let requestReferenceImage = compareSourceUrl;
try {
if (shouldUploadAiWashReferenceImage(compareSourceUrl)) {
toast.info("正在准备洗图底图...");
}
requestReferenceImage = await uploadAiWashReferenceImage(compareSourceUrl);
} catch (error) {
const message =
error instanceof Error ? error.message : "洗图底图上传失败,请稍后重试";
setLastErrorMessage(message);
toast.error(message);
setIsSubmitting(false);
return;
}
const taskResults = await Promise.all<AiWashTaskOutcome>(
presetExecutionStates.map(async (executionState, index) => {
@ -728,7 +793,7 @@ export function AiWashWorkspace({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: executionState.resolvedPrompt,
referenceImage: compareSourceUrl,
referenceImage: requestReferenceImage,
selectedModelId: executionState.effectiveModelRef,
imageSize: config.imageSize,
promptExtend: config.promptExtend,
@ -844,7 +909,7 @@ export function AiWashWorkspace({
id: `ai-wash-history-${startedAt.toString(36)}`,
scopeKey: resolvedHistoryScopeKey,
createdAt: startedAt,
sourceImageUrl: getPersistableSourceImageUrl(compareSourceUrl) || undefined,
sourceImageUrl: getPersistableSourceImageUrl(requestReferenceImage) || undefined,
items: successfulItems,
},
...currentEntries,
@ -867,6 +932,7 @@ export function AiWashWorkspace({
config.imageSize,
config.promptExtend,
enabledPresets,
hasExecutableModelRefs,
presetExecutionStates,
persistHistoryBatches,
resolvedHistoryScopeKey,
@ -1312,6 +1378,7 @@ export function AiWashWorkspace({
isSubmitting ||
!compareSourceUrl ||
enabledPresets.length === 0 ||
!hasExecutableModelRefs ||
(shouldShowUserModelSelector && !selectedUserModel)
}
onClick={handleSubmit}

View File

@ -214,6 +214,59 @@ const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = [
"2:3",
];
function parseRealtimeAspectRatio(ratio: RealtimeAspectRatio) {
const [width, height] = ratio.split(":").map((part) => Number(part));
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) {
return { width: 1, height: 1, value: 1 };
}
return { width, height, value: width / height };
}
function fitRealtimeFrameSize(params: {
containerWidth: number;
containerHeight: number;
ratio: RealtimeAspectRatio;
}) {
const ratio = parseRealtimeAspectRatio(params.ratio).value;
const availableWidth = Math.max(1, Math.floor(params.containerWidth));
const availableHeight = Math.max(1, Math.floor(params.containerHeight));
let width = availableWidth;
let height = width / ratio;
if (height > availableHeight) {
height = availableHeight;
width = height * ratio;
}
return {
width: Math.max(1, Math.floor(width)),
height: Math.max(1, Math.floor(height)),
};
}
function scaleImageLayersToFrame(params: {
layers: ImageLayer[];
fromWidth: number;
fromHeight: number;
toWidth: number;
toHeight: number;
}) {
if (params.fromWidth <= 0 || params.fromHeight <= 0) {
return cloneImageLayers(params.layers);
}
const scaleX = params.toWidth / params.fromWidth;
const scaleY = params.toHeight / params.fromHeight;
return params.layers.map((layer) => ({
...layer,
x: layer.x * scaleX,
y: layer.y * scaleY,
width: layer.width * scaleX,
height: layer.height * scaleY,
baseWidth: layer.baseWidth * scaleX,
baseHeight: layer.baseHeight * scaleY,
}));
}
function cloneImageLayers(layers: ImageLayer[]) {
return layers.map((layer) => ({ ...layer }));
}
@ -849,6 +902,7 @@ export function RealtimeCanvasDialog({
const sessionIdRef = useRef(
`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
const hasInitializedRealtimeCanvasRef = useRef(false);
const seedRef = useRef<number>(createSessionSeed());
const hasLoadedInitialImageRef = useRef(false);
const canvasBackgroundRef = useRef(CANVAS_BACKGROUND);
@ -874,6 +928,14 @@ export function RealtimeCanvasDialog({
const [, setStatusMessage] = useState("等待连接");
const [prompt, setPrompt] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [previewImageSize, setPreviewImageSize] = useState<{
width: number;
height: number;
} | null>(null);
const [realtimeFrameSize, setRealtimeFrameSize] = useState({
width: 0,
height: 0,
});
const [workspaceMode, setWorkspaceMode] =
useState<RealtimeWorkspaceMode>(initialMode);
const [realtimeGenerationMode, setRealtimeGenerationMode] =
@ -1763,6 +1825,8 @@ export function RealtimeCanvasDialog({
const payload = (await response.json().catch(() => ({}))) as {
imageUrl?: string;
imageDataURI?: string;
width?: number;
height?: number;
cost?: number;
transport?: string;
warning?: string;
@ -1792,6 +1856,16 @@ export function RealtimeCanvasDialog({
lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq);
setPreviewUrl(nextPreviewUrl);
const nextPreviewWidth = Number(payload.width);
const nextPreviewHeight = Number(payload.height);
setPreviewImageSize(
nextPreviewWidth > 0 && nextPreviewHeight > 0
? {
width: nextPreviewWidth,
height: nextPreviewHeight,
}
: null
);
setStatus("live");
setStatusMessage(
payload.transport === "ws" ? "实时渲染中(WS)" : "实时渲染中(HTTP)"
@ -1964,6 +2038,7 @@ export function RealtimeCanvasDialog({
if (nextRatio === realtimeAspectRatioRef.current) return;
realtimeAspectRatioRef.current = nextRatio;
setRealtimeAspectRatio(nextRatio);
setPreviewImageSize(null);
if (realtimeGenerationModeRef.current === "auto") {
scheduleFlush(true, false, "refine");
@ -2172,8 +2247,13 @@ export function RealtimeCanvasDialog({
}
const rect = wrap.getBoundingClientRect();
const cssWidth = Math.max(1, Math.floor(rect.width));
const cssHeight = Math.max(1, Math.floor(rect.height));
const frameSize = fitRealtimeFrameSize({
containerWidth: rect.width,
containerHeight: rect.height,
ratio: realtimeAspectRatioRef.current,
});
const cssWidth = frameSize.width;
const cssHeight = frameSize.height;
if (cssWidth <= 1 || cssHeight <= 1) {
return false;
}
@ -2199,6 +2279,7 @@ export function RealtimeCanvasDialog({
configureCanvas(imageLayerCanvas);
configureCanvas(canvas);
configureCanvas(previewCanvas);
setRealtimeFrameSize(frameSize);
if (drawingSnapshot) {
await loadDrawingLayerFromDataUrl(drawingSnapshot);
@ -2470,18 +2551,40 @@ export function RealtimeCanvasDialog({
return true;
}, [createPlacedLayer, initialImageUrl, markCanvasDirty, open, saveHistory]);
const initializeCanvas = useCallback(() => {
const initializeCanvas = useCallback(async (options?: { preserveContent?: boolean }) => {
const canvas = canvasRef.current;
const imageLayerCanvas = imageLayerCanvasRef.current;
const previewCanvas = previewCanvasRef.current;
const wrap = canvasWrapRef.current;
if (!canvas || !imageLayerCanvas || !previewCanvas || !wrap) return;
const rect = wrap.getBoundingClientRect();
const frameSize = fitRealtimeFrameSize({
containerWidth: rect.width,
containerHeight: rect.height,
ratio: realtimeAspectRatioRef.current,
});
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.max(640, Math.floor(rect.width * dpr));
canvas.height = Math.max(480, Math.floor(rect.height * dpr));
canvas.style.width = `${Math.floor(rect.width)}px`;
canvas.style.height = `${Math.floor(rect.height)}px`;
const previousCssWidth = Math.max(
1,
Number.parseFloat(canvas.style.width) || frameSize.width
);
const previousCssHeight = Math.max(
1,
Number.parseFloat(canvas.style.height) || frameSize.height
);
const preserveContent = options?.preserveContent === true;
const drawingSnapshot =
preserveContent && drawingCanvasHasContent(true)
? exportDrawingLayerDataUrl()
: "";
const layersSnapshot = preserveContent
? cloneImageLayers(imageLayersRef.current)
: [];
canvas.width = Math.max(1, Math.floor(frameSize.width * dpr));
canvas.height = Math.max(1, Math.floor(frameSize.height * dpr));
canvas.style.width = `${frameSize.width}px`;
canvas.style.height = `${frameSize.height}px`;
imageLayerCanvas.width = canvas.width;
imageLayerCanvas.height = canvas.height;
imageLayerCanvas.style.width = canvas.style.width;
@ -2510,9 +2613,39 @@ export function RealtimeCanvasDialog({
imageLayerCanvas.height
);
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
resetCanvas();
hasLoadedInitialImageRef.current = false;
}, [resetCanvas]);
setRealtimeFrameSize(frameSize);
if (preserveContent) {
const nextLayers = scaleImageLayersToFrame({
layers: layersSnapshot,
fromWidth: previousCssWidth,
fromHeight: previousCssHeight,
toWidth: frameSize.width,
toHeight: frameSize.height,
});
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
if (drawingSnapshot) {
await loadDrawingLayerFromDataUrl(drawingSnapshot);
} else {
clearDrawingCanvas();
}
await renderImageLayers();
markCanvasDirty(null);
} else {
resetCanvas();
hasLoadedInitialImageRef.current = false;
}
hasInitializedRealtimeCanvasRef.current = true;
}, [
clearDrawingCanvas,
drawingCanvasHasContent,
exportDrawingLayerDataUrl,
loadDrawingLayerFromDataUrl,
markCanvasDirty,
renderImageLayers,
resetCanvas,
]);
const getCanvasPoint = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
@ -2949,6 +3082,7 @@ export function RealtimeCanvasDialog({
if (!open) return;
seedRef.current = createSessionSeed();
sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
hasInitializedRealtimeCanvasRef.current = false;
seqRef.current = 0;
lastRenderedSeqRef.current = 0;
requestStartMapRef.current.clear();
@ -2964,19 +3098,37 @@ export function RealtimeCanvasDialog({
useEffect(() => {
if (!open) return;
initializeCanvas();
void drawInitialImage().then((hasInitialImage) => {
if (hasInitialImage) {
scheduleFlushRef.current(true, false);
}
void initializeCanvas().then(() => {
void drawInitialImage().then((hasInitialImage) => {
if (hasInitialImage) {
scheduleFlushRef.current(true, false);
}
});
});
const onResize = () => initializeCanvas();
const onResize = () => {
void initializeCanvas({ preserveContent: true });
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [drawInitialImage, initializeCanvas, open]);
useEffect(() => {
if (
!open ||
workspaceMode !== "realtime" ||
!hasInitializedRealtimeCanvasRef.current
) {
return;
}
void initializeCanvas({ preserveContent: true }).then(() => {
if (realtimeGenerationModeRef.current === "auto") {
scheduleFlushRef.current(true, false, "refine");
}
});
}, [initializeCanvas, open, realtimeAspectRatio, workspaceMode]);
useEffect(() => {
if (!open) {
return;
@ -3281,9 +3433,20 @@ export function RealtimeCanvasDialog({
prompt: prompt.trim() || FALLBACK_PROMPT,
title: "实时画布结果",
model: "bfl/flux-2",
width: previewImageSize?.width || realtimeFrameSize.width || undefined,
height: previewImageSize?.height || realtimeFrameSize.height || undefined,
});
onOpenChange(false);
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
}, [
onApplyResult,
onOpenChange,
previewImageSize?.height,
previewImageSize?.width,
previewUrl,
prompt,
realtimeFrameSize.height,
realtimeFrameSize.width,
]);
const handleApplyEnhanceResult = useCallback(
(payload: {
@ -4000,14 +4163,25 @@ export function RealtimeCanvasDialog({
{/* Right: Output Preview */}
<div className="relative flex flex-1 flex-col items-center justify-center overflow-hidden bg-[#fcfcfc] dark:bg-[#0b0c0f]">
<div className="relative flex h-full w-full items-center justify-center">
{previewUrl ? (
<img
alt="实时预览"
className="max-h-full max-w-full object-contain shadow-sm"
src={previewDisplayUrl}
/>
) : (
<div className="flex flex-col items-center justify-center gap-3 text-[#666666] dark:text-white/55">
<div
className="relative flex items-center justify-center overflow-hidden bg-white shadow-sm dark:bg-[#0c0d10]"
style={{
width: realtimeFrameSize.width || undefined,
height: realtimeFrameSize.height || undefined,
aspectRatio: `${parseRealtimeAspectRatio(realtimeAspectRatio).width} / ${parseRealtimeAspectRatio(realtimeAspectRatio).height}`,
maxWidth: "100%",
maxHeight: "100%",
}}
>
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.03]" />
{previewUrl ? (
<img
alt="实时预览"
className="relative h-full w-full object-cover"
src={previewDisplayUrl}
/>
) : (
<div className="relative flex flex-col items-center justify-center gap-3 text-[#666666] dark:text-white/55">
{status === "connecting" ? (
<>
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
@ -4019,7 +4193,8 @@ export function RealtimeCanvasDialog({
</>
)}
</div>
)}
)}
</div>
{previewUrl ? (
<div className="absolute right-5 bottom-5">
<Button

View File

@ -234,6 +234,80 @@ function collectSceneMeta(scene: Object3D): {
};
}
const COLOR_TEXTURE_KEYS = new Set([
"map",
"emissiveMap",
"sheenColorMap",
"specularColorMap",
]);
const MATERIAL_TEXTURE_KEYS = [
"alphaMap",
"aoMap",
"bumpMap",
"clearcoatMap",
"clearcoatNormalMap",
"clearcoatRoughnessMap",
"displacementMap",
"emissiveMap",
"iridescenceMap",
"iridescenceThicknessMap",
"lightMap",
"map",
"metalnessMap",
"normalMap",
"roughnessMap",
"sheenColorMap",
"sheenRoughnessMap",
"specularColorMap",
"specularIntensityMap",
"specularMap",
"thicknessMap",
"transmissionMap",
] as const;
function configureMaterialTextures(
material: THREE.Material,
geometry?: THREE.BufferGeometry | null
) {
const materialRecord = material as any;
if (materialRecord.aoMap && geometry?.attributes.uv && !geometry.attributes.uv2) {
geometry.setAttribute("uv2", geometry.attributes.uv);
}
for (const key of MATERIAL_TEXTURE_KEYS) {
const texture = materialRecord[key];
if (!texture?.isTexture) continue;
if ("colorSpace" in texture) {
texture.colorSpace = COLOR_TEXTURE_KEYS.has(key)
? THREE.SRGBColorSpace
: THREE.NoColorSpace;
}
texture.needsUpdate = true;
}
material.needsUpdate = true;
}
function resolveSceneGroundY(
scene: Object3D,
modelRotation?: [number, number, number]
) {
const probeScene = scene.clone(true);
const probeGroup = new THREE.Group();
if (modelRotation) {
probeGroup.rotation.set(...modelRotation);
}
probeGroup.add(probeScene);
probeGroup.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(probeGroup);
if (bounds.isEmpty() || !Number.isFinite(bounds.min.y)) return 0;
return bounds.min.y;
}
function ModelScene({
url,
settings,
@ -241,6 +315,7 @@ function ModelScene({
layerVisibility,
onLayersChange,
onStatsChange,
onGroundYChange,
}: {
url: string;
settings: Model3DViewerSettings;
@ -248,6 +323,7 @@ function ModelScene({
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
onGroundYChange?: (groundY: number) => void;
}) {
const { gl } = useThree();
const ktx2Loader = useMemo(() => {
@ -273,12 +349,22 @@ function ModelScene({
);
const scene = useMemo(() => model.scene.clone(), [model.scene]);
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
const modelRotationKey = modelRotation?.join("|") || "";
const groundY = useMemo(
() => resolveSceneGroundY(scene, modelRotation),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scene, modelRotationKey]
);
useEffect(() => {
onLayersChange?.(meta.layers);
onStatsChange?.(meta.stats);
}, [meta.layers, meta.stats, onLayersChange, onStatsChange]);
useEffect(() => {
onGroundYChange?.(groundY);
}, [groundY, onGroundYChange]);
useEffect(() => {
for (const { layerId, mesh } of meta.meshRefs) {
const nextVisible = layerVisibility?.[layerId];
@ -295,18 +381,10 @@ function ModelScene({
: [mesh.material];
for (const material of materials) {
if (!material) continue;
const textureLikeKeys = ["map", "emissiveMap"] as const;
for (const key of textureLikeKeys) {
const texture = (material as any)?.[key];
if (texture && "colorSpace" in texture) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
}
}
configureMaterialTextures(material, mesh.geometry);
if ("wireframe" in material) {
(material as any).wireframe = settings.wireframe;
}
material.needsUpdate = true;
}
}
}, [meta.meshRefs, settings.wireframe]);
@ -565,9 +643,22 @@ export function Model3DViewer({
}: Model3DViewerProps) {
const controlsRef = useRef<any>(null);
const movementStateRef = useRef<MovementState>(createMovementState());
const [groundY, setGroundY] = React.useState(0);
const resolvedModelUrl = useMemo(() => {
return buildThreeAssetUrl(modelUrl);
}, [modelUrl]);
const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01;
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
setGroundY((previousGroundY) =>
Math.abs(previousGroundY - nextGroundY) < 0.001
? previousGroundY
: nextGroundY
);
}, []);
useEffect(() => {
setGroundY(0);
}, [resolvedModelUrl]);
if (!modelUrl) {
return (
@ -654,7 +745,7 @@ export function Model3DViewer({
cellSize={0.5}
fadeDistance={30}
fadeStrength={1}
position={[0, -0.01, 0]}
position={[0, gridY, 0]}
sectionColor="#52525b"
sectionSize={2}
/>
@ -672,6 +763,7 @@ export function Model3DViewer({
<ModelScene
layerVisibility={layerVisibility}
modelRotation={modelRotation}
onGroundYChange={handleGroundYChange}
onLayersChange={onLayersChange}
onStatsChange={onStatsChange}
settings={settings}
@ -682,6 +774,7 @@ export function Model3DViewer({
color="#000000"
far={4}
opacity={0.4}
position={[0, groundY, 0]}
resolution={256}
scale={10}
/>
@ -692,7 +785,7 @@ export function Model3DViewer({
autoRotate={settings.autoRotate}
dampingFactor={0.08}
enableDamping
enablePan={false}
enablePan
enableRotate
enableZoom
makeDefault
@ -700,10 +793,16 @@ export function Model3DViewer({
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE,
RIGHT: THREE.MOUSE.PAN,
}}
panSpeed={0.8}
screenSpacePanning
minPolarAngle={0}
ref={controlsRef}
touches={{
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
}}
/>
</Canvas>
{showMovementControls ? <ModelMovementPad movementStateRef={movementStateRef} /> : null}

View File

@ -37,6 +37,7 @@ import {
Unlink2,
Wand2,
X,
Zap,
} from "lucide-react";
import type React from "react";
import { useParams, useRouter } from "next/navigation";
@ -161,6 +162,15 @@ import {
getDefaultThreeDModelAliasId,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
defaultPointsSystemConfig,
get3DTaskCreditCost,
get3DWorldCreditCost,
getImageToolCreditCost,
normalizePointsSystemConfig,
type ImageToolCreditFeature,
type PointsSystemConfig,
} from "@/lib/points/config";
import {
isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl,
@ -291,6 +301,74 @@ type AiWashModelOptionsResponse = {
models?: AiWashModelOption[];
};
type MembershipSummaryResponse = {
isHighVip?: boolean;
pointsSystemConfig?: PointsSystemConfig;
};
type ImageActionCreditBadgeProps = {
label?: string | null;
compact?: boolean;
className?: string;
};
function formatPointAmount(value: number) {
const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return normalized.toLocaleString("en-US");
}
function formatImageNodeFeatureCostLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return "不扣积分";
if (isHighVip) return "VIP免费";
if (cost <= 0) return "免费";
return `${formatPointAmount(cost)} 积分`;
}
function formatImageNodeFeatureCostShortLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return null;
if (isHighVip) return "VIP";
if (cost <= 0) return "0";
return formatPointAmount(cost);
}
function ImageActionCreditBadge({
className,
compact = false,
label,
}: ImageActionCreditBadgeProps) {
if (!label) return null;
return (
<span
className={cn(
"ml-auto inline-flex shrink-0 items-center gap-1 rounded-md bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-white shadow-sm dark:bg-white dark:text-slate-950",
compact && "ml-1 px-1 text-[10px]",
className
)}
title={label}
>
<Zap className="h-3 w-3 fill-current" />
{label}
</span>
);
}
type AngleEditPanelMode = "subject" | "camera";
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
type CropInteractionState = {
@ -1044,6 +1122,10 @@ export function ImageNode({
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
fetcher
);
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>(
"/api/user/membership-summary",
fetcher
);
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
@ -1756,6 +1838,63 @@ export function ImageNode({
null,
[selectedWorldModelVersion, worldModelOptions]
);
const pointsSystemConfig = useMemo(
() =>
normalizePointsSystemConfig(
membershipSummary?.pointsSystemConfig || defaultPointsSystemConfig
),
[membershipSummary?.pointsSystemConfig]
);
const pointsEnabled = pointsSystemConfig.enabled === true;
const isHighVip = membershipSummary?.isHighVip === true;
const tripoImageToModelCreditCost = useMemo(
() =>
get3DTaskCreditCost(pointsSystemConfig, {
taskType: "image_to_model",
}),
[pointsSystemConfig]
);
const worldImageCreditCost = useMemo(
() => get3DWorldCreditCost(pointsSystemConfig, selectedWorldModelEntry?.id),
[pointsSystemConfig, selectedWorldModelEntry?.id]
);
const tripoImageToModelCostLabel = formatImageNodeFeatureCostLabel({
cost: tripoImageToModelCreditCost,
isHighVip,
pointsEnabled,
});
const worldImageCostLabel = formatImageNodeFeatureCostLabel({
cost: worldImageCreditCost,
isHighVip,
pointsEnabled,
});
const tripoImageToModelCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: tripoImageToModelCreditCost,
isHighVip,
pointsEnabled,
});
const worldImageCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: worldImageCreditCost,
isHighVip,
pointsEnabled,
});
const getImageToolCostShortLabel = useCallback(
(feature: ImageToolCreditFeature) =>
formatImageNodeFeatureCostShortLabel({
cost: getImageToolCreditCost(pointsSystemConfig, feature),
isHighVip,
pointsEnabled,
}),
[isHighVip, pointsEnabled, pointsSystemConfig]
);
const removeBackgroundCostShortLabel =
getImageToolCostShortLabel("remove-background");
const boxCutoutCostShortLabel = getImageToolCostShortLabel("box-cutout");
const removeWatermarkCostShortLabel =
getImageToolCostShortLabel("watermark-remove");
const extendCostShortLabel = getImageToolCostShortLabel("extend");
const promptRegenerateCostShortLabel = getImageToolCostShortLabel("repaint");
const angleEditCostShortLabel = getImageToolCostShortLabel("angle-edit");
// 🆕 物品拾取相关状态
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
@ -8367,7 +8506,10 @@ export function ImageNode({
fallback={Scissors}
item={imageRemoveBackgroundItem}
/>
{imageRemoveBackgroundItem.label}
<span>{imageRemoveBackgroundItem.label}</span>
<ImageActionCreditBadge
label={removeBackgroundCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imageRemoveWatermarkEnabled ? (
@ -8377,7 +8519,10 @@ export function ImageNode({
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
<span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge
label={removeWatermarkCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imageEnhanceEnabled ? (
@ -8422,12 +8567,15 @@ export function ImageNode({
) : null}
{imageMultiAngleEnabled ? (
<DropdownMenuItem onClick={() => handleAction("angle")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
{imageMultiAngleItem.label}
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
<span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge
label={angleEditCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imagePromptRegenerateEnabled ? (
@ -8439,7 +8587,10 @@ export function ImageNode({
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
<span>{imagePromptRegenerateItem.label}</span>
<ImageActionCreditBadge
label={promptRegenerateCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{imageTextEditEnabled ? (
@ -8459,7 +8610,8 @@ export function ImageNode({
fallback={Expand}
item={imageExtendItem}
/>
{imageExtendItem.label}
<span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} />
</DropdownMenuItem>
) : null}
{imageCropEnabled ? (
@ -8594,6 +8746,16 @@ export function ImageNode({
/>
<ThreeDGenerationDialog
costLabel={
threedGenerationDialogMode === "world"
? worldImageCostLabel
: tripoImageToModelCostLabel
}
costShortLabel={
threedGenerationDialogMode === "world"
? worldImageCostShortLabel
: tripoImageToModelCostShortLabel
}
isProcessing={isProcessing}
mode={threedGenerationDialogMode}
onConfirm={handleConfirmThreedGeneration}
@ -9027,6 +9189,10 @@ export function ImageNode({
item={imageRemoveBackgroundItem}
/>
{imageRemoveBackgroundItem.label}
<ImageActionCreditBadge
compact
label={removeBackgroundCostShortLabel}
/>
</Button>
) : null}
@ -9088,6 +9254,7 @@ export function ImageNode({
item={imageBoxCutoutItem}
/>
{imageBoxCutoutItem.label}
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
</Button>
) : null}
@ -9122,6 +9289,10 @@ export function ImageNode({
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
<ImageActionCreditBadge
compact
label={removeWatermarkCostShortLabel}
/>
</Button>
) : null}
@ -9156,6 +9327,10 @@ export function ImageNode({
item={imageMultiAngleItem}
/>
{imageMultiAngleItem.label}
<ImageActionCreditBadge
compact
label={angleEditCostShortLabel}
/>
</Button>
) : null}
@ -9173,6 +9348,10 @@ export function ImageNode({
item={imageGenerate3dModelItem}
/>
{imageGenerate3dModelItem.label}
<ImageActionCreditBadge
compact
label={tripoImageToModelCostShortLabel}
/>
</Button>
) : null}
@ -9190,6 +9369,10 @@ export function ImageNode({
item={imageGenerate3dWorldItem}
/>
{imageGenerate3dWorldItem.label}
<ImageActionCreditBadge
compact
label={worldImageCostShortLabel}
/>
</Button>
) : null}
@ -9224,6 +9407,7 @@ export function ImageNode({
item={imageExtendItem}
/>
{imageExtendItem.label}
<ImageActionCreditBadge compact label={extendCostShortLabel} />
</Button>
) : null}
@ -9292,6 +9476,10 @@ export function ImageNode({
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
<ImageActionCreditBadge
compact
label={promptRegenerateCostShortLabel}
/>
</Button>
) : null}
@ -9346,7 +9534,8 @@ export function ImageNode({
fallback={Scan}
item={imageBoxCutoutItem}
/>
{imageBoxCutoutItem.label}
<span>{imageBoxCutoutItem.label}</span>
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("localEdit") ? (
@ -9366,7 +9555,10 @@ export function ImageNode({
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
<span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge
label={removeWatermarkCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("resize") ? (
@ -9386,7 +9578,10 @@ export function ImageNode({
fallback={Box}
item={imageMultiAngleItem}
/>
{imageMultiAngleItem.label}
<span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge
label={angleEditCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("generate3dModel") ? (
@ -9398,7 +9593,10 @@ export function ImageNode({
fallback={Box}
item={imageGenerate3dModelItem}
/>
{imageGenerate3dModelItem.label}
<span>{imageGenerate3dModelItem.label}</span>
<ImageActionCreditBadge
label={tripoImageToModelCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("generate3dWorld") ? (
@ -9410,7 +9608,8 @@ export function ImageNode({
fallback={Globe2}
item={imageGenerate3dWorldItem}
/>
{imageGenerate3dWorldItem.label}
<span>{imageGenerate3dWorldItem.label}</span>
<ImageActionCreditBadge label={worldImageCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("layerSplit") ? (
@ -9430,7 +9629,8 @@ export function ImageNode({
fallback={Expand}
item={imageExtendItem}
/>
{imageExtendItem.label}
<span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("crop") ? (
@ -9472,7 +9672,10 @@ export function ImageNode({
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
<span>{imagePromptRegenerateItem.label}</span>
<ImageActionCreditBadge
label={promptRegenerateCostShortLabel}
/>
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("note") ? (
@ -10336,6 +10539,11 @@ export function ImageNode({
) : (
<>
立即使用
<ImageActionCreditBadge
className="ml-0 bg-white/18 text-white"
compact
label={angleEditCostShortLabel}
/>
<Sparkles className="h-4 w-4" />
</>
)}

View File

@ -1,6 +1,6 @@
"use client";
import { Box, Globe2, Loader2, RefreshCw } from "lucide-react";
import { Box, Globe2, Loader2, RefreshCw, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -38,6 +38,8 @@ interface PromptRegenerateDialogProps {
}
interface ThreeDGenerationDialogProps {
costLabel?: string;
costShortLabel?: string | null;
isProcessing: boolean;
mode: ThreeDGenerationDialogMode;
onConfirm: () => void;
@ -108,6 +110,8 @@ export function PromptRegenerateDialog({
}
export function ThreeDGenerationDialog({
costLabel,
costShortLabel,
isProcessing,
mode,
onConfirm,
@ -138,7 +142,7 @@ export function ThreeDGenerationDialog({
>
<DialogContent
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
overlayClassName="bg-black"
overlayClassName="bg-black/35 backdrop-blur-[1px]"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@ -189,6 +193,12 @@ export function ThreeDGenerationDialog({
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
{selectedEntry?.description || "当前模型暂无说明。"}
</div>
{costLabel ? (
<div className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-slate-900 px-2.5 py-1 text-[12px] font-medium text-white shadow-sm dark:bg-white dark:text-slate-950">
<Zap className="h-3.5 w-3.5 fill-current" />
{costLabel}
</div>
) : null}
</div>
</div>
<DialogFooter>
@ -206,7 +216,13 @@ export function ThreeDGenerationDialog({
) : (
<Box className="mr-2 h-4 w-4" />
)}
开始生成
<span>开始生成</span>
{costShortLabel ? (
<span className="ml-2 inline-flex items-center gap-1 rounded-md bg-black/18 px-1.5 py-0.5 text-[12px] font-semibold text-current dark:bg-white/18">
<Zap className="h-3 w-3 fill-current" />
{costShortLabel}
</span>
) : null}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -109,6 +109,8 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0];
const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1);
const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08;
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22;
const EMPTY_MOVEMENT_STATE: MovementState = {
forward: false,
@ -340,6 +342,10 @@ function getWorldLabsDisplayGroundPlaneOffset(value: unknown) {
return -value;
}
function getWorldLabsDisplayGroundHelperOffset(value: number | null) {
return value === null ? null : value - WORLDLABS_DISPLAY_GROUND_HELPER_BIAS;
}
function getLoadedSplatCount(splatMesh: SparkSplatMesh) {
try {
return Math.max(
@ -469,8 +475,16 @@ function positionCameraInsideWorld({
groundPlaneOffset <= worldBounds.max.y + size.y * 0.25
? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y)
: null;
const estimatedFloorY = semanticGroundY ?? worldBounds.min.y + size.y * 0.06;
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.18, 0.9, 1.48);
const displayFloorY =
semanticGroundY !== null
? THREE.MathUtils.clamp(
semanticGroundY - size.y * WORLDLABS_DISPLAY_GROUND_BIAS_RATIO,
worldBounds.min.y,
worldBounds.max.y
)
: worldBounds.min.y + size.y * 0.04;
const estimatedFloorY = displayFloorY;
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.24, 1.05, 1.7);
const rawEyeY = estimatedFloorY + humanEyeOffset;
const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42);
const maxEyeY = Math.min(
@ -483,9 +497,9 @@ function positionCameraInsideWorld({
: center.y - size.y * 0.08;
const anchor = new THREE.Vector3(center.x, eyeY, center.z);
const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9);
const cameraPosition = anchor
.clone()
.addScaledVector(horizontalAxis, cameraDistance);
const cameraPosition = firstPerson
? anchor.clone()
: anchor.clone().addScaledVector(horizontalAxis, cameraDistance);
cameraPosition.x = clampInsideAxis(
cameraPosition.x,
@ -502,7 +516,9 @@ function positionCameraInsideWorld({
center.z
);
const lookDirection = anchor.clone().sub(cameraPosition);
const lookDirection = firstPerson
? horizontalAxis.clone()
: anchor.clone().sub(cameraPosition);
if (lookDirection.lengthSq() < 0.0001) {
lookDirection.copy(horizontalAxis).multiplyScalar(-1);
}
@ -511,7 +527,7 @@ function positionCameraInsideWorld({
.clone()
.addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4));
const targetDrop = firstPerson
? THREE.MathUtils.clamp(humanEyeOffset * 0.46 + size.y * 0.035, 0.38, 0.95)
? THREE.MathUtils.clamp(humanEyeOffset * 0.18 + size.y * 0.018, 0.16, 0.46)
: THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18);
const minLookTargetY = Math.max(
worldBounds.min.y + Math.max(size.y * 0.04, 0.08),
@ -830,6 +846,34 @@ function WorldSplatLoadingOverlay({
);
}
function stopViewerSurfaceWheel(event: React.SyntheticEvent) {
event.stopPropagation();
}
function stopViewerSurfaceContextMenu(event: React.SyntheticEvent) {
event.preventDefault();
event.stopPropagation();
}
function WorldViewerInteractionSurface({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn("h-full w-full touch-none overscroll-contain", className)}
data-canvas-no-drag="true"
onContextMenu={stopViewerSurfaceContextMenu}
onWheel={stopViewerSurfaceWheel}
>
{children}
</div>
);
}
async function readSplatFetchError(response: Response) {
const fallback = `SPZ 请求失败(HTTP ${response.status})`;
const contentType = response.headers.get("content-type") || "";
@ -1337,6 +1381,7 @@ function WorldSplatScene({
const groundPlaneOffset = getWorldLabsDisplayGroundPlaneOffset(
semanticsMetadata?.ground_plane_offset
);
const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset);
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
orbitControlsRef.current = controls;
setControlsMountVersion((version) => version + 1);
@ -1622,10 +1667,10 @@ function WorldSplatScene({
<>
<ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} />
{groundPlaneOffset !== null ? (
{groundHelperOffset !== null ? (
<gridHelper
args={[18, 18, "#334155", "#0f172a"]}
position={[0, groundPlaneOffset, 0]}
position={[0, groundHelperOffset, 0]}
/>
) : null}
<Suspense fallback={null}>
@ -1661,7 +1706,7 @@ function WorldSplatScene({
autoRotate={false}
dampingFactor={0.08}
enableDamping
enablePan={false}
enablePan
enableRotate
enableZoom
maxDistance={16}
@ -1670,10 +1715,16 @@ function WorldSplatScene({
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE,
RIGHT: THREE.MOUSE.PAN,
}}
panSpeed={0.8}
screenSpacePanning
minDistance={0.5}
ref={handleOrbitControlsRef}
touches={{
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
}}
/>
) : null}
<Html className="pointer-events-none" fullscreen>
@ -2127,7 +2178,7 @@ export function World3DViewer({
if (mode === "mesh") {
return (
<div className={cn("h-full w-full", className)}>
<WorldViewerInteractionSurface className={className}>
<Model3DViewer
emptyStateTitle={emptyStateTitle}
layerVisibility={meshLayerVisibility}
@ -2141,13 +2192,15 @@ export function World3DViewer({
settings={meshSettings || DEFAULT_MESH_SETTINGS}
showMovementControls={showMovementControls}
/>
</div>
</WorldViewerInteractionSurface>
);
}
if (mode === "world") {
return (
<div className={cn("h-full w-full overflow-hidden bg-zinc-950", className)}>
<WorldViewerInteractionSurface
className={cn("overflow-hidden bg-zinc-950", className)}
>
{worldUrl ? (
<iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
@ -2166,13 +2219,13 @@ export function World3DViewer({
title={emptyStateTitle}
/>
)}
</div>
</WorldViewerInteractionSurface>
);
}
if (mode === "image") {
return (
<div
<WorldViewerInteractionSurface
className={cn(
minimalFrame
? "flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950"
@ -2199,12 +2252,12 @@ export function World3DViewer({
title={emptyStateTitle}
/>
)}
</div>
</WorldViewerInteractionSurface>
);
}
return (
<div className={cn("h-full w-full", className)}>
<WorldViewerInteractionSurface className={className}>
<WorldSplatViewer
firstPerson={firstPerson}
fallbackMeshLayerVisibility={meshLayerVisibility}
@ -2227,6 +2280,6 @@ export function World3DViewer({
splatUrls={splatUrls}
worldUrl={worldUrl}
/>
</div>
</WorldViewerInteractionSurface>
);
}

View File

@ -139,7 +139,7 @@ export function WorldSharePage({
const [activeView, setActiveView] = useState<World3DViewMode>(
inferInitialView(availableViews)
);
const [firstPerson, setFirstPerson] = useState(false);
const [firstPerson, setFirstPerson] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
@ -153,7 +153,7 @@ export function WorldSharePage({
}, [activeView, availableViews]);
useEffect(() => {
setFirstPerson(false);
setFirstPerson(true);
}, [activeWorld?.id]);
const primaryDescription =
@ -242,9 +242,7 @@ export function WorldSharePage({
key={item.id}
onClick={() => {
setActiveView(item.id);
if (item.id !== "splat") {
setFirstPerson(false);
}
setFirstPerson(item.id === "splat");
}}
size="sm"
variant="ghost"
@ -284,6 +282,7 @@ export function WorldSharePage({
mode={activeView}
previewImageUrl={activeWorld?.previewImageUrl || null}
semanticsMetadata={activeWorld?.semanticsMetadata || null}
showMovementControls={activeView === "splat" || activeView === "mesh"}
splatUrl={activeWorld?.splatUrl || null}
worldUrl={activeWorld?.worldUrl || null}
/>
@ -339,7 +338,7 @@ export function WorldSharePage({
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
<h2 className="text-sm font-medium text-white">查看说明</h2>
<div className="mt-3 space-y-2 text-sm leading-6 text-zinc-300">
<p>`World` 视图最接近原生 3D 世界体验,支持方向键或 `WASD` 六向移动。</p>
<p>`World` 视图最接近原生 3D 世界体验,支持左键转向、右键平移、滚轮缩放,以及方向键或 `WASD` 六向移动。</p>
<p>当前页面是只读分享页,不会修改原项目或原画布内容。</p>
</div>
</div>

View File

@ -1,6 +1,6 @@
"use client";
import { Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
import { ArrowLeft, Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
import {
type CSSProperties,
@ -14,7 +14,7 @@ import {
} from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { BrandTopBar } from "@/components/layout/brand-top-bar";
import { BrandTopBar, BrandTopBarButton } from "@/components/layout/brand-top-bar";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -881,7 +881,7 @@ function BeforeAfterSlider({
if (!beforeUrl && !afterUrl) {
return (
<div className="flex min-h-[620px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400 lg:min-h-[720px]">
<div className="flex h-full min-h-[620px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400 lg:min-h-[720px]">
上传图片后开始生成
</div>
);
@ -889,21 +889,21 @@ function BeforeAfterSlider({
if (!afterUrl) {
return (
<div className="h-[calc(100vh-172px)] min-h-[620px] max-h-[720px] rounded-lg border border-gray-200 bg-gray-50 p-3">
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
<div className="h-full min-h-[620px] overflow-hidden rounded-lg border border-gray-200 bg-gray-50 lg:min-h-[720px]">
<ImagePreview alt="上传图片" fit="cover" imageUrl={beforeUrl} />
</div>
);
}
return (
<div
className="relative h-[calc(100vh-172px)] min-h-[620px] max-h-[720px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50"
className="relative h-full min-h-[620px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50 lg:min-h-[720px]"
ref={sliderRef}
>
<ImagePreview
alt="生成后"
className="absolute inset-0 h-full w-full"
fit="contain"
fit="cover"
imageUrl={afterUrl}
/>
<div
@ -913,7 +913,7 @@ function BeforeAfterSlider({
<ImagePreview
alt="生成前"
className="h-full w-full"
fit="contain"
fit="cover"
imageUrl={beforeUrl}
/>
</div>
@ -952,6 +952,7 @@ export function StyleProWorkspace({
canvasId,
initialSourceImageUrl,
onAddToCanvas,
onBack,
onPersistState,
persistedState,
}: StyleProWorkspaceProps) {
@ -1599,7 +1600,17 @@ export function StyleProWorkspace({
<main className="min-w-0 flex-1 overflow-y-auto">
<BrandTopBar
actions={<StyleProAccountActions />}
actions={
<>
{onBack ? (
<BrandTopBarButton onClick={onBack}>
<ArrowLeft className="h-3.5 w-3.5" />
返回画布
</BrandTopBarButton>
) : null}
<StyleProAccountActions />
</>
}
logo={logo}
onLogoClick={() => {
window.location.href = "/";
@ -1718,7 +1729,7 @@ export function StyleProWorkspace({
<section className="section-card p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_300px] 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="flex min-h-[calc(100vh-172px)] flex-col rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<span className="subsection-title">生图结果</span>
@ -1858,11 +1869,13 @@ export function StyleProWorkspace({
</Button>
</div>
</div>
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
taskState={generationTaskState}
/>
<div className="min-h-0 flex-1">
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
taskState={generationTaskState}
/>
</div>
</div>
<div className="rounded-[20px] bg-black/[0.025] p-4">

View File

@ -42,6 +42,7 @@ export type AiWashConfig = {
enabled: boolean;
modeLabel: string;
modeIconUrl: string;
hideModelSelector: boolean;
imageSize: string;
promptExtend: boolean;
historyLimit: number;
@ -223,6 +224,7 @@ export function defaultAiWashConfig(): AiWashConfig {
enabled: false,
modeLabel: AI_WASH_DEFAULT_MODE_LABEL,
modeIconUrl: "",
hideModelSelector: false,
imageSize: AI_WASH_DEFAULT_IMAGE_SIZE,
promptExtend: false,
historyLimit: AI_WASH_DEFAULT_HISTORY_LIMIT,
@ -287,6 +289,7 @@ export function normalizeAiWashConfig(value: unknown): AiWashConfig {
enabled: raw.enabled === true,
modeLabel: normalizeText(raw.modeLabel, 32) || AI_WASH_DEFAULT_MODE_LABEL,
modeIconUrl: normalizeText(raw.modeIconUrl, 2000),
hideModelSelector: raw.hideModelSelector === true,
imageSize: normalizeText(raw.imageSize, 16) || AI_WASH_DEFAULT_IMAGE_SIZE,
promptExtend: raw.promptExtend === true,
historyLimit: Math.max(

View File

@ -46,6 +46,7 @@ export type PointsSystemConfig = {
creditCostPerImageWatermarkRemove: number;
creditCostPerImageExtend: number;
creditCostPerImageRepaint: number;
creditCostPerImageAngleEdit: number;
enhanceProPricing: EnhanceProPointsPricingConfig;
};
@ -62,7 +63,8 @@ export type ImageToolCreditFeature =
| "box-cutout"
| "watermark-remove"
| "extend"
| "repaint";
| "repaint"
| "angle-edit";
export type PointsPackage = {
id: string;
@ -93,6 +95,7 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
creditCostPerImageWatermarkRemove: 0,
creditCostPerImageExtend: 0,
creditCostPerImageRepaint: 0,
creditCostPerImageAngleEdit: 0,
enhanceProPricing: {
enabled: false,
chargeMode: "scale_factor",
@ -487,6 +490,8 @@ export function getImageToolCreditCost(
return normalizeCost(pointsConfig.creditCostPerImageExtend);
case "repaint":
return normalizeCost(pointsConfig.creditCostPerImageRepaint);
case "angle-edit":
return normalizeCost(pointsConfig.creditCostPerImageAngleEdit);
default:
return 0;
}
@ -542,6 +547,9 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
),
creditCostPerImageExtend: normalizeCost(raw.creditCostPerImageExtend),
creditCostPerImageRepaint: normalizeCost(raw.creditCostPerImageRepaint),
creditCostPerImageAngleEdit: normalizeCost(
raw.creditCostPerImageAngleEdit
),
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
raw.enhanceProPricing,
legacyEnhanceProCost

View File

@ -193,6 +193,24 @@ export function findStyleProImageModelByRef(
);
}
export function sortStyleProImageModelEntries<
T extends StyleProModelEntry | StyleProDisplayModelEntry,
>(entries: T[]) {
return [...entries].sort((left, right) => {
const leftOrder = Number.isFinite(Number(left.model.sortOrder))
? Number(left.model.sortOrder)
: 0;
const rightOrder = Number.isFinite(Number(right.model.sortOrder))
? Number(right.model.sortOrder)
: 0;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
const leftName = String(left.model.displayName || left.model.modelId || "");
const rightName = String(right.model.displayName || right.model.modelId || "");
return leftName.localeCompare(rightName, "zh-Hans-CN");
});
}
export function toPublicStyleProModelOption(
entry: StyleProModelEntry | StyleProDisplayModelEntry
): PublicStyleProModelOption {