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, setWatermarkRemoveConfig,
angleEditConfig, angleEditConfig,
setAngleEditConfig, setAngleEditConfig,
pointsSystemConfig,
updatePointsCostField,
layerSplitConfig, layerSplitConfig,
setLayerSplitConfig, setLayerSplitConfig,
isLocalAiSectionExpanded, isLocalAiSectionExpanded,
@ -1447,6 +1449,12 @@ export function LocalAiImagePipelineSections({
label: aiWashConfig.modeLabel || "AI洗图", label: aiWashConfig.modeLabel || "AI洗图",
tone: "neutral", tone: "neutral",
}, },
{
label: aiWashConfig.hideModelSelector
? "前台隐藏模型"
: "前台可选模型",
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
},
]} ]}
/> />
} }
@ -1633,6 +1641,23 @@ export function LocalAiImagePipelineSections({
} }
/> />
</div> </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>
<div className="space-y-4 rounded-xl border p-4"> <div className="space-y-4 rounded-xl border p-4">
@ -2383,6 +2408,13 @@ export function LocalAiImagePipelineSections({
label: `${angleEditConfig.inferenceSteps} steps`, label: `${angleEditConfig.inferenceSteps} steps`,
tone: "neutral", tone: "neutral",
}, },
{
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
tone:
pointsSystemConfig.creditCostPerImageAngleEdit > 0
? "neutral"
: "disabled",
},
]} ]}
/> />
} }
@ -2453,6 +2485,28 @@ export function LocalAiImagePipelineSections({
<Separator /> <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 强度 */} {/* LoRA 强度 */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { import {
createMediaAssetRelation, createMediaAssetRelation,
getMediaAssetsByUserAndUrls, getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getSystemSetting, getSystemSetting,
upsertMediaAsset, upsertMediaAsset,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
@ -10,6 +11,8 @@ import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger"; 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"); const logger = createLogger("angle-edit-api");
@ -171,6 +174,19 @@ export async function POST(request: NextRequest) {
zoom: finalZoom, 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. 准备图片输入 // 4. 准备图片输入
let imageInput: string; let imageInput: string;
if (image_url) { if (image_url) {
@ -256,6 +272,19 @@ export async function POST(request: NextRequest) {
}) })
.returning(); .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 { try {
const sourceAssets = const sourceAssets =
typeof image_url === "string" && image_url.trim() typeof image_url === "string" && image_url.trim()

View File

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

View File

@ -81,6 +81,9 @@ function formatLedgerDetail(params: {
if (reason === "realtime_canvas_generation") { if (reason === "realtime_canvas_generation") {
return "实时绘画生成消耗积分"; return "实时绘画生成消耗积分";
} }
if (reason === "image_angle_edit") {
return "多角度编辑消耗积分";
}
if (reason === "model3d_task") { if (reason === "model3d_task") {
const taskMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN); 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[] { function createMultiViewSlots(count = 4): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({ return Array.from({ length: count }, (_, index) => ({
id: `slot_${index + 1}`, id: `slot_${index + 1}`,
@ -651,7 +716,6 @@ export default function CanvasModel3DPage() {
const viewport = useCanvasStore(canvasSelectors.viewport); const viewport = useCanvasStore(canvasSelectors.viewport);
const addNode = useCanvasStore((state) => state.addNode); const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore(canvasSelectors.nodes); const nodes = useCanvasStore(canvasSelectors.nodes);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model"); const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
@ -722,12 +786,13 @@ export default function CanvasModel3DPage() {
); );
const [worldViewportMode, setWorldViewportMode] = const [worldViewportMode, setWorldViewportMode] =
useState<WorldViewportMode>("world"); useState<WorldViewportMode>("world");
const [worldFirstPerson, setWorldFirstPerson] = useState(false); const [worldFirstPerson, setWorldFirstPerson] = useState(true);
const [viewerResetToken, setViewerResetToken] = useState(0); const [viewerResetToken, setViewerResetToken] = useState(0);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]); const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null); const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]); const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
const [selectedWorldCanvasTaskIds, setSelectedWorldCanvasTaskIds] = useState<string[]>([]);
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>( const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
null null
); );
@ -1274,10 +1339,21 @@ export default function CanvasModel3DPage() {
), ),
[visibleTaskHistory] [visibleTaskHistory]
); );
const importableWorldTasks = useMemo(
() =>
visibleTaskHistory.filter(
(task) => task.provider === "worldlabs-3d" && canImportTaskToCanvas(task)
),
[visibleTaskHistory]
);
const selectedWorldShareTasks = useMemo(() => { const selectedWorldShareTasks = useMemo(() => {
const selectedIds = new Set(selectedWorldShareTaskIds); const selectedIds = new Set(selectedWorldShareTaskIds);
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId)); return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
}, [selectedWorldShareTaskIds, shareableWorldTasks]); }, [selectedWorldShareTaskIds, shareableWorldTasks]);
const selectedWorldCanvasTasks = useMemo(() => {
const selectedIds = new Set(selectedWorldCanvasTaskIds);
return importableWorldTasks.filter((task) => selectedIds.has(task.taskId));
}, [selectedWorldCanvasTaskIds, importableWorldTasks]);
useEffect(() => { useEffect(() => {
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId)); const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
@ -1287,6 +1363,14 @@ export default function CanvasModel3DPage() {
}); });
}, [shareableWorldTasks]); }, [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 currentModelUrl = resolveTaskModelUrl(currentTask);
const currentPreviewImage = const currentPreviewImage =
currentTask?.previewImageUrl || currentTask?.previewImageUrl ||
@ -1993,35 +2077,12 @@ export default function CanvasModel3DPage() {
} }
}; };
const handleImportToCanvas = async () => { const handleImportTasksToCanvas = async (tasks: TaskRecord[]) => {
if (!currentTask || currentTask.status !== "success") { const importableTasks = tasks.filter(canImportTaskToCanvas);
if (importableTasks.length === 0) {
toast.error("暂无可添加的 3D 资产"); toast.error("暂无可添加的 3D 资产");
return; 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 viewportX = viewport.x || 0;
const viewportY = viewport.y || 0; const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1; const viewportZoom = viewport.zoom || 1;
@ -2030,92 +2091,109 @@ export default function CanvasModel3DPage() {
typeof window !== "undefined" ? window.innerHeight : 720; typeof window !== "undefined" ? window.innerHeight : 720;
const targetWidth = 380; const targetWidth = 380;
const targetHeight = 300; const targetHeight = 300;
const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2; const existing3dNodeCount = nodes.filter(
const y = (node) => node.type === "world3d" || node.type === "model3d"
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2; ).length;
if (isWorldTask) { importableTasks.forEach((task, index) => {
const worldNodeIds = nodes const isWorldTask = task.provider === "worldlabs-3d";
.filter((node) => node.type === "world3d") const modelUrl = resolveTaskModelUrl(task);
.map((node) => node.id); const worldAssetPayload = task.assetPayload ?? null;
for (const nodeId of worldNodeIds) { const primarySplatUrl = resolveWorldTaskSplatUrl(task);
deleteNode(nodeId); const worldImageUrl = resolveWorldTaskImageUrl(task);
} const position = resolveCanvasImportPosition({
} index: existing3dNodeCount + index,
totalCount: existing3dNodeCount + importableTasks.length,
viewportX,
viewportY,
viewportZoom,
stageWidth,
stageHeight,
targetWidth,
targetHeight,
});
addNode( addNode(
isWorldTask isWorldTask
? { ? {
type: "world3d", type: "world3d",
position: { x, y }, position,
size: { width: targetWidth, height: targetHeight }, size: { width: targetWidth, height: targetHeight },
data: { data: {
worldUrl: worldAssetPayload?.worldUrl || undefined, worldUrl: worldAssetPayload?.worldUrl || undefined,
imageUrl: worldImageUrl || undefined, imageUrl: worldImageUrl || undefined,
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined, meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
splatUrl: primarySplatUrl || undefined, splatUrl: primarySplatUrl || undefined,
previewImage: previewImage: resolveWorldTaskPreviewUrl(task) || undefined,
currentTask.previewImageUrl || prompt: task.prompt,
worldAssetPayload?.thumbnailUrl || modelVersion: task.modelVersion,
undefined, status: "completed",
prompt: currentTask.prompt, activeView: primarySplatUrl ? "splat" : worldImageUrl ? "image" : "mesh",
modelVersion: currentTask.modelVersion, firstPerson: false,
status: "completed", hotspots: [],
activeView: primarySplatUrl semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
? "splat" metadata: {
: worldImageUrl taskId: task.taskId,
? "image" provider: task.provider,
: "mesh", mode: task.mode,
firstPerson: false, sourceTaskId: task.sourceTaskId,
hotspots: [], progress: task.progress,
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined, triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
metadata: { vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
taskId: currentTask.taskId, meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
provider: currentTask.provider, assetPayload: task.assetPayload ?? null,
mode: currentTask.mode, },
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
}, },
}, connections: [],
connections: [], }
} : {
: { type: "model3d",
type: "model3d", position,
position: { x, y }, size: { width: targetWidth, height: targetHeight },
size: { width: targetWidth, height: targetHeight }, data: {
data: { src: modelUrl!,
src: currentModelUrl!, previewImage: task.previewImageUrl || undefined,
previewImage: currentTask.previewImageUrl || undefined, prompt: task.prompt,
prompt: currentTask.prompt, modelVersion: task.modelVersion,
modelVersion: currentTask.modelVersion, format: extractModelFormat(modelUrl!),
format: extractModelFormat(currentModelUrl!), status: "completed",
status: "completed", metadata: {
metadata: { taskId: task.taskId,
taskId: currentTask.taskId, provider: task.provider,
provider: currentTask.provider, mode: task.mode,
mode: currentTask.mode, sourceTaskId: task.sourceTaskId,
sourceTaskId: currentTask.sourceTaskId, progress: task.progress,
progress: currentTask.progress, triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
triangles: viewerStats.triangles, vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
vertices: viewerStats.vertices, meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
meshCount: viewerStats.meshCount, assetPayload: task.assetPayload ?? null,
assetPayload: currentTask.assetPayload ?? null, },
}, },
}, connections: [],
connections: [], }
} );
); });
try { try {
await canvasService.saveCanvasState(id, getCanvasState()); await canvasService.saveCanvasState(id, getCanvasState());
} catch (error) { } catch (error) {
console.error("[Model3DPage] 保存画布失败:", 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 = () => { const handleDownloadCurrent = () => {
@ -2229,6 +2307,37 @@ export default function CanvasModel3DPage() {
); );
}, [shareableWorldTasks]); }, [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 () => { const handleShareSelectedWorlds = useCallback(async () => {
if (selectedWorldShareTasks.length < 2) { if (selectedWorldShareTasks.length < 2) {
toast.error("请至少选择 2 个成功生成的 3D 世界"); toast.error("请至少选择 2 个成功生成的 3D 世界");
@ -3892,31 +4001,27 @@ export default function CanvasModel3DPage() {
{worldViewportMode === "splat" ? ( {worldViewportMode === "splat" ? (
<> <>
<div className="font-medium text-zinc-200"> <div className="font-medium text-zinc-200">
{worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景主体中心旋转"} {worldFirstPerson ? "漫游:WASD / 方向键" : "观察:拖拽旋转"}
</div> </div>
<div className="mt-1"> <div className="mt-1 truncate">
{worldFirstPerson {worldFirstPerson
? "按住左键拖动画面转向,WASD 或方向键移动,Q/R 上升,E/F 下降。" ? "左键转向 · WASD 移动 · Q/R 上升 · E/F 下降"
: "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"} : "左键旋转 · 右键平移 · 滚轮缩放"}
</div> </div>
</> </>
) : worldViewportMode === "mesh" ? ( ) : worldViewportMode === "mesh" ? (
<> <>
<div className="font-medium text-zinc-200"> <div className="font-medium text-zinc-200">
Mesh 视图已恢复 Mesh 视图
</div>
<div className="mt-1">
当前世界关联的 Mesh 会在这里直接显示,也可以从右侧资产库点击缩略图切换当前模型。
</div> </div>
<div className="mt-1 truncate">检查几何结构与模型资产。</div>
</> </>
) : ( ) : (
<> <>
<div className="font-medium text-zinc-200"> <div className="font-medium text-zinc-200">
当前为图像预览 图像预览
</div>
<div className="mt-1">
可以切回 World 查看空间结果,或切到 Mesh 继续检查几何结构。
</div> </div>
<div className="mt-1 truncate">查看全景/缩略图结果。</div>
</> </>
)} )}
</div> </div>
@ -3937,6 +4042,9 @@ export default function CanvasModel3DPage() {
previewImageUrl={currentPreviewImage} previewImageUrl={currentPreviewImage}
resetViewToken={viewerResetToken} resetViewToken={viewerResetToken}
semanticsMetadata={currentWorldAsset?.semanticsMetadata} semanticsMetadata={currentWorldAsset?.semanticsMetadata}
showMovementControls={
worldViewportMode === "splat" || worldViewportMode === "mesh"
}
splatUrl={currentPrimarySpzUrl} splatUrl={currentPrimarySpzUrl}
worldUrl={currentWorldAsset?.worldUrl} worldUrl={currentWorldAsset?.worldUrl}
/> />
@ -4023,7 +4131,7 @@ export default function CanvasModel3DPage() {
{workspaceMode === "world" ? "世界资产库" : "模型资产库"} {workspaceMode === "world" ? "世界资产库" : "模型资产库"}
</h2> </h2>
<p className="mt-1 text-[11px] text-zinc-500"> <p className="mt-1 text-[11px] text-zinc-500">
点击缩略图切换当前{workspaceMode === "world" ? "世界 / 模型" : "模型"} 点击切换{workspaceMode === "world" ? "世界" : "模型"}
</p> </p>
</div> </div>
<Button <Button
@ -4038,7 +4146,7 @@ export default function CanvasModel3DPage() {
{workspaceMode === "world" ? ( {workspaceMode === "world" ? (
<div className="space-y-2 border-white/10 border-b px-3 py-3"> <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"> <div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
<span>多世界分享</span> <span>分享</span>
<span>已选 {selectedWorldShareTasks.length}</span> <span>已选 {selectedWorldShareTasks.length}</span>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@ -4064,6 +4172,33 @@ export default function CanvasModel3DPage() {
分享所选 分享所选
</Button> </Button>
</div> </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> </div>
) : null} ) : null}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
@ -4072,6 +4207,10 @@ export default function CanvasModel3DPage() {
const canShareWorld = const canShareWorld =
task.provider === "worldlabs-3d" && task.status === "success"; task.provider === "worldlabs-3d" && task.status === "success";
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId); 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 ( return (
<button <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", "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 && currentTask?.taskId === task.taskId &&
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]", "border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
isSelectedForShare && isSelectedForShare && "border-cyan-300 ring-2 ring-cyan-300/40",
"border-cyan-300 ring-2 ring-cyan-300/40" isSelectedForCanvas && "border-yellow-300 ring-2 ring-yellow-300/40"
)} )}
key={task.taskId} key={task.taskId}
onClick={() => handleSelectTask(task)} onClick={() => handleSelectTask(task)}
@ -4106,6 +4245,26 @@ export default function CanvasModel3DPage() {
<Link2 className="h-3.5 w-3.5" /> <Link2 className="h-3.5 w-3.5" />
</Button> </Button>
) : null} ) : 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 ? ( {canShareWorld ? (
<Button <Button
className={cn( className={cn(
@ -4135,18 +4294,19 @@ export default function CanvasModel3DPage() {
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</Button> </Button>
) : null} ) : null}
{task.previewImageUrl ? ( {previewUrl ? (
<img <img
alt="preview" alt="preview"
className="h-full w-full object-cover transition-transform group-hover:scale-110" className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
src={task.previewImageUrl} src={previewUrl}
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-zinc-700"> <div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" /> <Box className="h-8 w-8" />
</div> </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"> <div className="flex items-center justify-between">
<Badge <Badge
className="h-4 px-1 text-[9px]" className="h-4 px-1 text-[9px]"
@ -4163,23 +4323,20 @@ export default function CanvasModel3DPage() {
{task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"} {task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"}
</Badge> </Badge>
</div> </div>
<div className="mt-1 truncate text-left text-[10px] text-zinc-300"> <div className="mt-1 flex items-center justify-between gap-2 text-[10px]">
{modeLabel(task.mode)} <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>
<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 ? ( {task.provider === "worldlabs-3d" && task.shareEnabled ? (
<div className="mt-1 text-left text-[10px] text-amber-200"> <div className="mt-1 text-left text-[10px] text-amber-200">
已开启分享 已分享
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -47,6 +47,7 @@ import {
} from "@/lib/ai-wash/config"; } from "@/lib/ai-wash/config";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
const fetcher = (url: string) => fetch(url).then((res) => res.json()); const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -181,6 +182,44 @@ function hashValue(value: string) {
return (hash >>> 0).toString(36); 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) { function createHistoryScopeKey(value?: string | null) {
const raw = String(value || "").trim(); const raw = String(value || "").trim();
return raw ? `ai-wash-scope-${hashValue(raw)}` : "ai-wash-scope-global"; return raw ? `ai-wash-scope-${hashValue(raw)}` : "ai-wash-scope-global";
@ -534,7 +573,15 @@ export function AiWashWorkspace({
[enabledPresets, modelOptionByRef, selectedLevelIndexByControl, selectedUserModel, selectedUserModelId] [enabledPresets, modelOptionByRef, selectedLevelIndexByControl, selectedUserModel, selectedUserModelId]
); );
const shouldShowUserModelSelector = useMemo( 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] [presetExecutionStates]
); );
@ -680,6 +727,10 @@ export function AiWashWorkspace({
toast.error("请先选择一个可用的生图模型"); toast.error("请先选择一个可用的生图模型");
return; return;
} }
if (!hasExecutableModelRefs) {
toast.error("当前没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
return;
}
const currentRunId = runIdRef.current + 1; const currentRunId = runIdRef.current + 1;
runIdRef.current = currentRunId; runIdRef.current = currentRunId;
@ -714,6 +765,20 @@ export function AiWashWorkspace({
); );
const startedAt = Date.now(); 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>( const taskResults = await Promise.all<AiWashTaskOutcome>(
presetExecutionStates.map(async (executionState, index) => { presetExecutionStates.map(async (executionState, index) => {
@ -728,7 +793,7 @@ export function AiWashWorkspace({
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
prompt: executionState.resolvedPrompt, prompt: executionState.resolvedPrompt,
referenceImage: compareSourceUrl, referenceImage: requestReferenceImage,
selectedModelId: executionState.effectiveModelRef, selectedModelId: executionState.effectiveModelRef,
imageSize: config.imageSize, imageSize: config.imageSize,
promptExtend: config.promptExtend, promptExtend: config.promptExtend,
@ -844,7 +909,7 @@ export function AiWashWorkspace({
id: `ai-wash-history-${startedAt.toString(36)}`, id: `ai-wash-history-${startedAt.toString(36)}`,
scopeKey: resolvedHistoryScopeKey, scopeKey: resolvedHistoryScopeKey,
createdAt: startedAt, createdAt: startedAt,
sourceImageUrl: getPersistableSourceImageUrl(compareSourceUrl) || undefined, sourceImageUrl: getPersistableSourceImageUrl(requestReferenceImage) || undefined,
items: successfulItems, items: successfulItems,
}, },
...currentEntries, ...currentEntries,
@ -867,6 +932,7 @@ export function AiWashWorkspace({
config.imageSize, config.imageSize,
config.promptExtend, config.promptExtend,
enabledPresets, enabledPresets,
hasExecutableModelRefs,
presetExecutionStates, presetExecutionStates,
persistHistoryBatches, persistHistoryBatches,
resolvedHistoryScopeKey, resolvedHistoryScopeKey,
@ -1312,6 +1378,7 @@ export function AiWashWorkspace({
isSubmitting || isSubmitting ||
!compareSourceUrl || !compareSourceUrl ||
enabledPresets.length === 0 || enabledPresets.length === 0 ||
!hasExecutableModelRefs ||
(shouldShowUserModelSelector && !selectedUserModel) (shouldShowUserModelSelector && !selectedUserModel)
} }
onClick={handleSubmit} onClick={handleSubmit}

View File

@ -214,6 +214,59 @@ const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = [
"2:3", "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[]) { function cloneImageLayers(layers: ImageLayer[]) {
return layers.map((layer) => ({ ...layer })); return layers.map((layer) => ({ ...layer }));
} }
@ -849,6 +902,7 @@ export function RealtimeCanvasDialog({
const sessionIdRef = useRef( const sessionIdRef = useRef(
`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
); );
const hasInitializedRealtimeCanvasRef = useRef(false);
const seedRef = useRef<number>(createSessionSeed()); const seedRef = useRef<number>(createSessionSeed());
const hasLoadedInitialImageRef = useRef(false); const hasLoadedInitialImageRef = useRef(false);
const canvasBackgroundRef = useRef(CANVAS_BACKGROUND); const canvasBackgroundRef = useRef(CANVAS_BACKGROUND);
@ -874,6 +928,14 @@ export function RealtimeCanvasDialog({
const [, setStatusMessage] = useState("等待连接"); const [, setStatusMessage] = useState("等待连接");
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [previewUrl, setPreviewUrl] = 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] = const [workspaceMode, setWorkspaceMode] =
useState<RealtimeWorkspaceMode>(initialMode); useState<RealtimeWorkspaceMode>(initialMode);
const [realtimeGenerationMode, setRealtimeGenerationMode] = const [realtimeGenerationMode, setRealtimeGenerationMode] =
@ -1763,6 +1825,8 @@ export function RealtimeCanvasDialog({
const payload = (await response.json().catch(() => ({}))) as { const payload = (await response.json().catch(() => ({}))) as {
imageUrl?: string; imageUrl?: string;
imageDataURI?: string; imageDataURI?: string;
width?: number;
height?: number;
cost?: number; cost?: number;
transport?: string; transport?: string;
warning?: string; warning?: string;
@ -1792,6 +1856,16 @@ export function RealtimeCanvasDialog({
lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq); lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq);
setPreviewUrl(nextPreviewUrl); setPreviewUrl(nextPreviewUrl);
const nextPreviewWidth = Number(payload.width);
const nextPreviewHeight = Number(payload.height);
setPreviewImageSize(
nextPreviewWidth > 0 && nextPreviewHeight > 0
? {
width: nextPreviewWidth,
height: nextPreviewHeight,
}
: null
);
setStatus("live"); setStatus("live");
setStatusMessage( setStatusMessage(
payload.transport === "ws" ? "实时渲染中(WS)" : "实时渲染中(HTTP)" payload.transport === "ws" ? "实时渲染中(WS)" : "实时渲染中(HTTP)"
@ -1964,6 +2038,7 @@ export function RealtimeCanvasDialog({
if (nextRatio === realtimeAspectRatioRef.current) return; if (nextRatio === realtimeAspectRatioRef.current) return;
realtimeAspectRatioRef.current = nextRatio; realtimeAspectRatioRef.current = nextRatio;
setRealtimeAspectRatio(nextRatio); setRealtimeAspectRatio(nextRatio);
setPreviewImageSize(null);
if (realtimeGenerationModeRef.current === "auto") { if (realtimeGenerationModeRef.current === "auto") {
scheduleFlush(true, false, "refine"); scheduleFlush(true, false, "refine");
@ -2172,8 +2247,13 @@ export function RealtimeCanvasDialog({
} }
const rect = wrap.getBoundingClientRect(); const rect = wrap.getBoundingClientRect();
const cssWidth = Math.max(1, Math.floor(rect.width)); const frameSize = fitRealtimeFrameSize({
const cssHeight = Math.max(1, Math.floor(rect.height)); containerWidth: rect.width,
containerHeight: rect.height,
ratio: realtimeAspectRatioRef.current,
});
const cssWidth = frameSize.width;
const cssHeight = frameSize.height;
if (cssWidth <= 1 || cssHeight <= 1) { if (cssWidth <= 1 || cssHeight <= 1) {
return false; return false;
} }
@ -2199,6 +2279,7 @@ export function RealtimeCanvasDialog({
configureCanvas(imageLayerCanvas); configureCanvas(imageLayerCanvas);
configureCanvas(canvas); configureCanvas(canvas);
configureCanvas(previewCanvas); configureCanvas(previewCanvas);
setRealtimeFrameSize(frameSize);
if (drawingSnapshot) { if (drawingSnapshot) {
await loadDrawingLayerFromDataUrl(drawingSnapshot); await loadDrawingLayerFromDataUrl(drawingSnapshot);
@ -2470,18 +2551,40 @@ export function RealtimeCanvasDialog({
return true; return true;
}, [createPlacedLayer, initialImageUrl, markCanvasDirty, open, saveHistory]); }, [createPlacedLayer, initialImageUrl, markCanvasDirty, open, saveHistory]);
const initializeCanvas = useCallback(() => { const initializeCanvas = useCallback(async (options?: { preserveContent?: boolean }) => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const imageLayerCanvas = imageLayerCanvasRef.current; const imageLayerCanvas = imageLayerCanvasRef.current;
const previewCanvas = previewCanvasRef.current; const previewCanvas = previewCanvasRef.current;
const wrap = canvasWrapRef.current; const wrap = canvasWrapRef.current;
if (!canvas || !imageLayerCanvas || !previewCanvas || !wrap) return; if (!canvas || !imageLayerCanvas || !previewCanvas || !wrap) return;
const rect = wrap.getBoundingClientRect(); const rect = wrap.getBoundingClientRect();
const frameSize = fitRealtimeFrameSize({
containerWidth: rect.width,
containerHeight: rect.height,
ratio: realtimeAspectRatioRef.current,
});
const dpr = Math.max(1, window.devicePixelRatio || 1); const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.max(640, Math.floor(rect.width * dpr)); const previousCssWidth = Math.max(
canvas.height = Math.max(480, Math.floor(rect.height * dpr)); 1,
canvas.style.width = `${Math.floor(rect.width)}px`; Number.parseFloat(canvas.style.width) || frameSize.width
canvas.style.height = `${Math.floor(rect.height)}px`; );
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.width = canvas.width;
imageLayerCanvas.height = canvas.height; imageLayerCanvas.height = canvas.height;
imageLayerCanvas.style.width = canvas.style.width; imageLayerCanvas.style.width = canvas.style.width;
@ -2510,9 +2613,39 @@ export function RealtimeCanvasDialog({
imageLayerCanvas.height imageLayerCanvas.height
); );
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
resetCanvas(); setRealtimeFrameSize(frameSize);
hasLoadedInitialImageRef.current = false;
}, [resetCanvas]); 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( const getCanvasPoint = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => { (event: ReactPointerEvent<HTMLCanvasElement>) => {
@ -2949,6 +3082,7 @@ export function RealtimeCanvasDialog({
if (!open) return; if (!open) return;
seedRef.current = createSessionSeed(); seedRef.current = createSessionSeed();
sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
hasInitializedRealtimeCanvasRef.current = false;
seqRef.current = 0; seqRef.current = 0;
lastRenderedSeqRef.current = 0; lastRenderedSeqRef.current = 0;
requestStartMapRef.current.clear(); requestStartMapRef.current.clear();
@ -2964,19 +3098,37 @@ export function RealtimeCanvasDialog({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
initializeCanvas(); void initializeCanvas().then(() => {
void drawInitialImage().then((hasInitialImage) => { void drawInitialImage().then((hasInitialImage) => {
if (hasInitialImage) { if (hasInitialImage) {
scheduleFlushRef.current(true, false); scheduleFlushRef.current(true, false);
} }
});
}); });
const onResize = () => initializeCanvas(); const onResize = () => {
void initializeCanvas({ preserveContent: true });
};
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
return () => { return () => {
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
}; };
}, [drawInitialImage, initializeCanvas, open]); }, [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(() => { useEffect(() => {
if (!open) { if (!open) {
return; return;
@ -3281,9 +3433,20 @@ export function RealtimeCanvasDialog({
prompt: prompt.trim() || FALLBACK_PROMPT, prompt: prompt.trim() || FALLBACK_PROMPT,
title: "实时画布结果", title: "实时画布结果",
model: "bfl/flux-2", model: "bfl/flux-2",
width: previewImageSize?.width || realtimeFrameSize.width || undefined,
height: previewImageSize?.height || realtimeFrameSize.height || undefined,
}); });
onOpenChange(false); onOpenChange(false);
}, [onApplyResult, onOpenChange, previewUrl, prompt]); }, [
onApplyResult,
onOpenChange,
previewImageSize?.height,
previewImageSize?.width,
previewUrl,
prompt,
realtimeFrameSize.height,
realtimeFrameSize.width,
]);
const handleApplyEnhanceResult = useCallback( const handleApplyEnhanceResult = useCallback(
(payload: { (payload: {
@ -4000,14 +4163,25 @@ export function RealtimeCanvasDialog({
{/* Right: Output Preview */} {/* 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 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"> <div className="relative flex h-full w-full items-center justify-center">
{previewUrl ? ( <div
<img className="relative flex items-center justify-center overflow-hidden bg-white shadow-sm dark:bg-[#0c0d10]"
alt="实时预览" style={{
className="max-h-full max-w-full object-contain shadow-sm" width: realtimeFrameSize.width || undefined,
src={previewDisplayUrl} height: realtimeFrameSize.height || undefined,
/> aspectRatio: `${parseRealtimeAspectRatio(realtimeAspectRatio).width} / ${parseRealtimeAspectRatio(realtimeAspectRatio).height}`,
) : ( maxWidth: "100%",
<div className="flex flex-col items-center justify-center gap-3 text-[#666666] dark:text-white/55"> 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" ? ( {status === "connecting" ? (
<> <>
<Loader2 className="h-8 w-8 animate-spin opacity-50" /> <Loader2 className="h-8 w-8 animate-spin opacity-50" />
@ -4019,7 +4193,8 @@ export function RealtimeCanvasDialog({
</> </>
)} )}
</div> </div>
)} )}
</div>
{previewUrl ? ( {previewUrl ? (
<div className="absolute right-5 bottom-5"> <div className="absolute right-5 bottom-5">
<Button <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({ function ModelScene({
url, url,
settings, settings,
@ -241,6 +315,7 @@ function ModelScene({
layerVisibility, layerVisibility,
onLayersChange, onLayersChange,
onStatsChange, onStatsChange,
onGroundYChange,
}: { }: {
url: string; url: string;
settings: Model3DViewerSettings; settings: Model3DViewerSettings;
@ -248,6 +323,7 @@ function ModelScene({
layerVisibility?: Record<string, boolean>; layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void; onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void; onStatsChange?: (stats: Model3DStats) => void;
onGroundYChange?: (groundY: number) => void;
}) { }) {
const { gl } = useThree(); const { gl } = useThree();
const ktx2Loader = useMemo(() => { const ktx2Loader = useMemo(() => {
@ -273,12 +349,22 @@ function ModelScene({
); );
const scene = useMemo(() => model.scene.clone(), [model.scene]); const scene = useMemo(() => model.scene.clone(), [model.scene]);
const meta = useMemo(() => collectSceneMeta(scene), [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(() => { useEffect(() => {
onLayersChange?.(meta.layers); onLayersChange?.(meta.layers);
onStatsChange?.(meta.stats); onStatsChange?.(meta.stats);
}, [meta.layers, meta.stats, onLayersChange, onStatsChange]); }, [meta.layers, meta.stats, onLayersChange, onStatsChange]);
useEffect(() => {
onGroundYChange?.(groundY);
}, [groundY, onGroundYChange]);
useEffect(() => { useEffect(() => {
for (const { layerId, mesh } of meta.meshRefs) { for (const { layerId, mesh } of meta.meshRefs) {
const nextVisible = layerVisibility?.[layerId]; const nextVisible = layerVisibility?.[layerId];
@ -295,18 +381,10 @@ function ModelScene({
: [mesh.material]; : [mesh.material];
for (const material of materials) { for (const material of materials) {
if (!material) continue; if (!material) continue;
const textureLikeKeys = ["map", "emissiveMap"] as const; configureMaterialTextures(material, mesh.geometry);
for (const key of textureLikeKeys) {
const texture = (material as any)?.[key];
if (texture && "colorSpace" in texture) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
}
}
if ("wireframe" in material) { if ("wireframe" in material) {
(material as any).wireframe = settings.wireframe; (material as any).wireframe = settings.wireframe;
} }
material.needsUpdate = true;
} }
} }
}, [meta.meshRefs, settings.wireframe]); }, [meta.meshRefs, settings.wireframe]);
@ -565,9 +643,22 @@ export function Model3DViewer({
}: Model3DViewerProps) { }: Model3DViewerProps) {
const controlsRef = useRef<any>(null); const controlsRef = useRef<any>(null);
const movementStateRef = useRef<MovementState>(createMovementState()); const movementStateRef = useRef<MovementState>(createMovementState());
const [groundY, setGroundY] = React.useState(0);
const resolvedModelUrl = useMemo(() => { const resolvedModelUrl = useMemo(() => {
return buildThreeAssetUrl(modelUrl); return buildThreeAssetUrl(modelUrl);
}, [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) { if (!modelUrl) {
return ( return (
@ -654,7 +745,7 @@ export function Model3DViewer({
cellSize={0.5} cellSize={0.5}
fadeDistance={30} fadeDistance={30}
fadeStrength={1} fadeStrength={1}
position={[0, -0.01, 0]} position={[0, gridY, 0]}
sectionColor="#52525b" sectionColor="#52525b"
sectionSize={2} sectionSize={2}
/> />
@ -672,6 +763,7 @@ export function Model3DViewer({
<ModelScene <ModelScene
layerVisibility={layerVisibility} layerVisibility={layerVisibility}
modelRotation={modelRotation} modelRotation={modelRotation}
onGroundYChange={handleGroundYChange}
onLayersChange={onLayersChange} onLayersChange={onLayersChange}
onStatsChange={onStatsChange} onStatsChange={onStatsChange}
settings={settings} settings={settings}
@ -682,6 +774,7 @@ export function Model3DViewer({
color="#000000" color="#000000"
far={4} far={4}
opacity={0.4} opacity={0.4}
position={[0, groundY, 0]}
resolution={256} resolution={256}
scale={10} scale={10}
/> />
@ -692,7 +785,7 @@ export function Model3DViewer({
autoRotate={settings.autoRotate} autoRotate={settings.autoRotate}
dampingFactor={0.08} dampingFactor={0.08}
enableDamping enableDamping
enablePan={false} enablePan
enableRotate enableRotate
enableZoom enableZoom
makeDefault makeDefault
@ -700,10 +793,16 @@ export function Model3DViewer({
mouseButtons={{ mouseButtons={{
LEFT: THREE.MOUSE.ROTATE, LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN,
}} }}
panSpeed={0.8}
screenSpacePanning
minPolarAngle={0} minPolarAngle={0}
ref={controlsRef} ref={controlsRef}
touches={{
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
}}
/> />
</Canvas> </Canvas>
{showMovementControls ? <ModelMovementPad movementStateRef={movementStateRef} /> : null} {showMovementControls ? <ModelMovementPad movementStateRef={movementStateRef} /> : null}

View File

@ -37,6 +37,7 @@ import {
Unlink2, Unlink2,
Wand2, Wand2,
X, X,
Zap,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
@ -161,6 +162,15 @@ import {
getDefaultThreeDModelAliasId, getDefaultThreeDModelAliasId,
type PublicThreeDModelAliasConfig, type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config"; } from "@/lib/model3d/model-alias-config";
import {
defaultPointsSystemConfig,
get3DTaskCreditCost,
get3DWorldCreditCost,
getImageToolCreditCost,
normalizePointsSystemConfig,
type ImageToolCreditFeature,
type PointsSystemConfig,
} from "@/lib/points/config";
import { import {
isArchivedWorldLabsAssetUrl, isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl, pickBestArchivedWorldLabsSplatUrl,
@ -291,6 +301,74 @@ type AiWashModelOptionsResponse = {
models?: AiWashModelOption[]; 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 AngleEditPanelMode = "subject" | "camera";
type FlipRotateEditAction = "horizontal" | "vertical" | "reset"; type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
type CropInteractionState = { type CropInteractionState = {
@ -1044,6 +1122,10 @@ export function ImageNode({
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null, aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
fetcher fetcher
); );
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>(
"/api/user/membership-summary",
fetcher
);
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore"); const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat"); const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject"); const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
@ -1756,6 +1838,63 @@ export function ImageNode({
null, null,
[selectedWorldModelVersion, worldModelOptions] [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); const isPickerMode = useCanvasStore((state) => state.isPickerMode);
@ -8367,7 +8506,10 @@ export function ImageNode({
fallback={Scissors} fallback={Scissors}
item={imageRemoveBackgroundItem} item={imageRemoveBackgroundItem}
/> />
{imageRemoveBackgroundItem.label} <span>{imageRemoveBackgroundItem.label}</span>
<ImageActionCreditBadge
label={removeBackgroundCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{imageRemoveWatermarkEnabled ? ( {imageRemoveWatermarkEnabled ? (
@ -8377,7 +8519,10 @@ export function ImageNode({
fallback={Eraser} fallback={Eraser}
item={imageRemoveWatermarkItem} item={imageRemoveWatermarkItem}
/> />
{imageRemoveWatermarkItem.label} <span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge
label={removeWatermarkCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{imageEnhanceEnabled ? ( {imageEnhanceEnabled ? (
@ -8422,12 +8567,15 @@ export function ImageNode({
) : null} ) : null}
{imageMultiAngleEnabled ? ( {imageMultiAngleEnabled ? (
<DropdownMenuItem onClick={() => handleAction("angle")}> <DropdownMenuItem onClick={() => handleAction("angle")}>
<CanvasToolbarIcon <CanvasToolbarIcon
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Box} fallback={Box}
item={imageMultiAngleItem} item={imageMultiAngleItem}
/> />
{imageMultiAngleItem.label} <span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge
label={angleEditCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{imagePromptRegenerateEnabled ? ( {imagePromptRegenerateEnabled ? (
@ -8439,7 +8587,10 @@ export function ImageNode({
fallback={RefreshCw} fallback={RefreshCw}
item={imagePromptRegenerateItem} item={imagePromptRegenerateItem}
/> />
{imagePromptRegenerateItem.label} <span>{imagePromptRegenerateItem.label}</span>
<ImageActionCreditBadge
label={promptRegenerateCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{imageTextEditEnabled ? ( {imageTextEditEnabled ? (
@ -8459,7 +8610,8 @@ export function ImageNode({
fallback={Expand} fallback={Expand}
item={imageExtendItem} item={imageExtendItem}
/> />
{imageExtendItem.label} <span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} />
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{imageCropEnabled ? ( {imageCropEnabled ? (
@ -8594,6 +8746,16 @@ export function ImageNode({
/> />
<ThreeDGenerationDialog <ThreeDGenerationDialog
costLabel={
threedGenerationDialogMode === "world"
? worldImageCostLabel
: tripoImageToModelCostLabel
}
costShortLabel={
threedGenerationDialogMode === "world"
? worldImageCostShortLabel
: tripoImageToModelCostShortLabel
}
isProcessing={isProcessing} isProcessing={isProcessing}
mode={threedGenerationDialogMode} mode={threedGenerationDialogMode}
onConfirm={handleConfirmThreedGeneration} onConfirm={handleConfirmThreedGeneration}
@ -9027,6 +9189,10 @@ export function ImageNode({
item={imageRemoveBackgroundItem} item={imageRemoveBackgroundItem}
/> />
{imageRemoveBackgroundItem.label} {imageRemoveBackgroundItem.label}
<ImageActionCreditBadge
compact
label={removeBackgroundCostShortLabel}
/>
</Button> </Button>
) : null} ) : null}
@ -9088,6 +9254,7 @@ export function ImageNode({
item={imageBoxCutoutItem} item={imageBoxCutoutItem}
/> />
{imageBoxCutoutItem.label} {imageBoxCutoutItem.label}
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
</Button> </Button>
) : null} ) : null}
@ -9122,6 +9289,10 @@ export function ImageNode({
item={imageRemoveWatermarkItem} item={imageRemoveWatermarkItem}
/> />
{imageRemoveWatermarkItem.label} {imageRemoveWatermarkItem.label}
<ImageActionCreditBadge
compact
label={removeWatermarkCostShortLabel}
/>
</Button> </Button>
) : null} ) : null}
@ -9156,6 +9327,10 @@ export function ImageNode({
item={imageMultiAngleItem} item={imageMultiAngleItem}
/> />
{imageMultiAngleItem.label} {imageMultiAngleItem.label}
<ImageActionCreditBadge
compact
label={angleEditCostShortLabel}
/>
</Button> </Button>
) : null} ) : null}
@ -9173,6 +9348,10 @@ export function ImageNode({
item={imageGenerate3dModelItem} item={imageGenerate3dModelItem}
/> />
{imageGenerate3dModelItem.label} {imageGenerate3dModelItem.label}
<ImageActionCreditBadge
compact
label={tripoImageToModelCostShortLabel}
/>
</Button> </Button>
) : null} ) : null}
@ -9190,6 +9369,10 @@ export function ImageNode({
item={imageGenerate3dWorldItem} item={imageGenerate3dWorldItem}
/> />
{imageGenerate3dWorldItem.label} {imageGenerate3dWorldItem.label}
<ImageActionCreditBadge
compact
label={worldImageCostShortLabel}
/>
</Button> </Button>
) : null} ) : null}
@ -9224,6 +9407,7 @@ export function ImageNode({
item={imageExtendItem} item={imageExtendItem}
/> />
{imageExtendItem.label} {imageExtendItem.label}
<ImageActionCreditBadge compact label={extendCostShortLabel} />
</Button> </Button>
) : null} ) : null}
@ -9292,6 +9476,10 @@ export function ImageNode({
item={imagePromptRegenerateItem} item={imagePromptRegenerateItem}
/> />
{imagePromptRegenerateItem.label} {imagePromptRegenerateItem.label}
<ImageActionCreditBadge
compact
label={promptRegenerateCostShortLabel}
/>
</Button> </Button>
) : null} ) : null}
@ -9346,7 +9534,8 @@ export function ImageNode({
fallback={Scan} fallback={Scan}
item={imageBoxCutoutItem} item={imageBoxCutoutItem}
/> />
{imageBoxCutoutItem.label} <span>{imageBoxCutoutItem.label}</span>
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{isSelectedImageActionInMore("localEdit") ? ( {isSelectedImageActionInMore("localEdit") ? (
@ -9366,7 +9555,10 @@ export function ImageNode({
fallback={Eraser} fallback={Eraser}
item={imageRemoveWatermarkItem} item={imageRemoveWatermarkItem}
/> />
{imageRemoveWatermarkItem.label} <span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge
label={removeWatermarkCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{isSelectedImageActionInMore("resize") ? ( {isSelectedImageActionInMore("resize") ? (
@ -9386,7 +9578,10 @@ export function ImageNode({
fallback={Box} fallback={Box}
item={imageMultiAngleItem} item={imageMultiAngleItem}
/> />
{imageMultiAngleItem.label} <span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge
label={angleEditCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{isSelectedImageActionInMore("generate3dModel") ? ( {isSelectedImageActionInMore("generate3dModel") ? (
@ -9398,7 +9593,10 @@ export function ImageNode({
fallback={Box} fallback={Box}
item={imageGenerate3dModelItem} item={imageGenerate3dModelItem}
/> />
{imageGenerate3dModelItem.label} <span>{imageGenerate3dModelItem.label}</span>
<ImageActionCreditBadge
label={tripoImageToModelCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{isSelectedImageActionInMore("generate3dWorld") ? ( {isSelectedImageActionInMore("generate3dWorld") ? (
@ -9410,7 +9608,8 @@ export function ImageNode({
fallback={Globe2} fallback={Globe2}
item={imageGenerate3dWorldItem} item={imageGenerate3dWorldItem}
/> />
{imageGenerate3dWorldItem.label} <span>{imageGenerate3dWorldItem.label}</span>
<ImageActionCreditBadge label={worldImageCostShortLabel} />
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{isSelectedImageActionInMore("layerSplit") ? ( {isSelectedImageActionInMore("layerSplit") ? (
@ -9430,7 +9629,8 @@ export function ImageNode({
fallback={Expand} fallback={Expand}
item={imageExtendItem} item={imageExtendItem}
/> />
{imageExtendItem.label} <span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} />
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{isSelectedImageActionInMore("crop") ? ( {isSelectedImageActionInMore("crop") ? (
@ -9472,7 +9672,10 @@ export function ImageNode({
fallback={RefreshCw} fallback={RefreshCw}
item={imagePromptRegenerateItem} item={imagePromptRegenerateItem}
/> />
{imagePromptRegenerateItem.label} <span>{imagePromptRegenerateItem.label}</span>
<ImageActionCreditBadge
label={promptRegenerateCostShortLabel}
/>
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{isSelectedImageActionInMore("note") ? ( {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" /> <Sparkles className="h-4 w-4" />
</> </>
)} )}

View File

@ -1,6 +1,6 @@
"use client"; "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 { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -38,6 +38,8 @@ interface PromptRegenerateDialogProps {
} }
interface ThreeDGenerationDialogProps { interface ThreeDGenerationDialogProps {
costLabel?: string;
costShortLabel?: string | null;
isProcessing: boolean; isProcessing: boolean;
mode: ThreeDGenerationDialogMode; mode: ThreeDGenerationDialogMode;
onConfirm: () => void; onConfirm: () => void;
@ -108,6 +110,8 @@ export function PromptRegenerateDialog({
} }
export function ThreeDGenerationDialog({ export function ThreeDGenerationDialog({
costLabel,
costShortLabel,
isProcessing, isProcessing,
mode, mode,
onConfirm, onConfirm,
@ -138,7 +142,7 @@ export function ThreeDGenerationDialog({
> >
<DialogContent <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" 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> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <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"> <div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
{selectedEntry?.description || "当前模型暂无说明。"} {selectedEntry?.description || "当前模型暂无说明。"}
</div> </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>
</div> </div>
<DialogFooter> <DialogFooter>
@ -206,7 +216,13 @@ export function ThreeDGenerationDialog({
) : ( ) : (
<Box className="mr-2 h-4 w-4" /> <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> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </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_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0];
const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1); 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 = { const EMPTY_MOVEMENT_STATE: MovementState = {
forward: false, forward: false,
@ -340,6 +342,10 @@ function getWorldLabsDisplayGroundPlaneOffset(value: unknown) {
return -value; return -value;
} }
function getWorldLabsDisplayGroundHelperOffset(value: number | null) {
return value === null ? null : value - WORLDLABS_DISPLAY_GROUND_HELPER_BIAS;
}
function getLoadedSplatCount(splatMesh: SparkSplatMesh) { function getLoadedSplatCount(splatMesh: SparkSplatMesh) {
try { try {
return Math.max( return Math.max(
@ -469,8 +475,16 @@ function positionCameraInsideWorld({
groundPlaneOffset <= worldBounds.max.y + size.y * 0.25 groundPlaneOffset <= worldBounds.max.y + size.y * 0.25
? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y) ? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y)
: null; : null;
const estimatedFloorY = semanticGroundY ?? worldBounds.min.y + size.y * 0.06; const displayFloorY =
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.18, 0.9, 1.48); 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 rawEyeY = estimatedFloorY + humanEyeOffset;
const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42); const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42);
const maxEyeY = Math.min( const maxEyeY = Math.min(
@ -483,9 +497,9 @@ function positionCameraInsideWorld({
: center.y - size.y * 0.08; : center.y - size.y * 0.08;
const anchor = new THREE.Vector3(center.x, eyeY, center.z); const anchor = new THREE.Vector3(center.x, eyeY, center.z);
const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9); const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9);
const cameraPosition = anchor const cameraPosition = firstPerson
.clone() ? anchor.clone()
.addScaledVector(horizontalAxis, cameraDistance); : anchor.clone().addScaledVector(horizontalAxis, cameraDistance);
cameraPosition.x = clampInsideAxis( cameraPosition.x = clampInsideAxis(
cameraPosition.x, cameraPosition.x,
@ -502,7 +516,9 @@ function positionCameraInsideWorld({
center.z center.z
); );
const lookDirection = anchor.clone().sub(cameraPosition); const lookDirection = firstPerson
? horizontalAxis.clone()
: anchor.clone().sub(cameraPosition);
if (lookDirection.lengthSq() < 0.0001) { if (lookDirection.lengthSq() < 0.0001) {
lookDirection.copy(horizontalAxis).multiplyScalar(-1); lookDirection.copy(horizontalAxis).multiplyScalar(-1);
} }
@ -511,7 +527,7 @@ function positionCameraInsideWorld({
.clone() .clone()
.addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4)); .addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4));
const targetDrop = firstPerson 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); : THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18);
const minLookTargetY = Math.max( const minLookTargetY = Math.max(
worldBounds.min.y + Math.max(size.y * 0.04, 0.08), 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) { async function readSplatFetchError(response: Response) {
const fallback = `SPZ 请求失败(HTTP ${response.status})`; const fallback = `SPZ 请求失败(HTTP ${response.status})`;
const contentType = response.headers.get("content-type") || ""; const contentType = response.headers.get("content-type") || "";
@ -1337,6 +1381,7 @@ function WorldSplatScene({
const groundPlaneOffset = getWorldLabsDisplayGroundPlaneOffset( const groundPlaneOffset = getWorldLabsDisplayGroundPlaneOffset(
semanticsMetadata?.ground_plane_offset semanticsMetadata?.ground_plane_offset
); );
const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset);
const handleOrbitControlsRef = React.useCallback((controls: any | null) => { const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
orbitControlsRef.current = controls; orbitControlsRef.current = controls;
setControlsMountVersion((version) => version + 1); setControlsMountVersion((version) => version + 1);
@ -1622,10 +1667,10 @@ function WorldSplatScene({
<> <>
<ambientLight intensity={1.6} /> <ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} /> <directionalLight intensity={1.25} position={[6, 10, 4]} />
{groundPlaneOffset !== null ? ( {groundHelperOffset !== null ? (
<gridHelper <gridHelper
args={[18, 18, "#334155", "#0f172a"]} args={[18, 18, "#334155", "#0f172a"]}
position={[0, groundPlaneOffset, 0]} position={[0, groundHelperOffset, 0]}
/> />
) : null} ) : null}
<Suspense fallback={null}> <Suspense fallback={null}>
@ -1661,7 +1706,7 @@ function WorldSplatScene({
autoRotate={false} autoRotate={false}
dampingFactor={0.08} dampingFactor={0.08}
enableDamping enableDamping
enablePan={false} enablePan
enableRotate enableRotate
enableZoom enableZoom
maxDistance={16} maxDistance={16}
@ -1670,10 +1715,16 @@ function WorldSplatScene({
mouseButtons={{ mouseButtons={{
LEFT: THREE.MOUSE.ROTATE, LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN,
}} }}
panSpeed={0.8}
screenSpacePanning
minDistance={0.5} minDistance={0.5}
ref={handleOrbitControlsRef} ref={handleOrbitControlsRef}
touches={{
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
}}
/> />
) : null} ) : null}
<Html className="pointer-events-none" fullscreen> <Html className="pointer-events-none" fullscreen>
@ -2127,7 +2178,7 @@ export function World3DViewer({
if (mode === "mesh") { if (mode === "mesh") {
return ( return (
<div className={cn("h-full w-full", className)}> <WorldViewerInteractionSurface className={className}>
<Model3DViewer <Model3DViewer
emptyStateTitle={emptyStateTitle} emptyStateTitle={emptyStateTitle}
layerVisibility={meshLayerVisibility} layerVisibility={meshLayerVisibility}
@ -2141,13 +2192,15 @@ export function World3DViewer({
settings={meshSettings || DEFAULT_MESH_SETTINGS} settings={meshSettings || DEFAULT_MESH_SETTINGS}
showMovementControls={showMovementControls} showMovementControls={showMovementControls}
/> />
</div> </WorldViewerInteractionSurface>
); );
} }
if (mode === "world") { if (mode === "world") {
return ( return (
<div className={cn("h-full w-full overflow-hidden bg-zinc-950", className)}> <WorldViewerInteractionSurface
className={cn("overflow-hidden bg-zinc-950", className)}
>
{worldUrl ? ( {worldUrl ? (
<iframe <iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking" allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
@ -2166,13 +2219,13 @@ export function World3DViewer({
title={emptyStateTitle} title={emptyStateTitle}
/> />
)} )}
</div> </WorldViewerInteractionSurface>
); );
} }
if (mode === "image") { if (mode === "image") {
return ( return (
<div <WorldViewerInteractionSurface
className={cn( className={cn(
minimalFrame minimalFrame
? "flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950" ? "flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950"
@ -2199,12 +2252,12 @@ export function World3DViewer({
title={emptyStateTitle} title={emptyStateTitle}
/> />
)} )}
</div> </WorldViewerInteractionSurface>
); );
} }
return ( return (
<div className={cn("h-full w-full", className)}> <WorldViewerInteractionSurface className={className}>
<WorldSplatViewer <WorldSplatViewer
firstPerson={firstPerson} firstPerson={firstPerson}
fallbackMeshLayerVisibility={meshLayerVisibility} fallbackMeshLayerVisibility={meshLayerVisibility}
@ -2227,6 +2280,6 @@ export function World3DViewer({
splatUrls={splatUrls} splatUrls={splatUrls}
worldUrl={worldUrl} worldUrl={worldUrl}
/> />
</div> </WorldViewerInteractionSurface>
); );
} }

View File

@ -139,7 +139,7 @@ export function WorldSharePage({
const [activeView, setActiveView] = useState<World3DViewMode>( const [activeView, setActiveView] = useState<World3DViewMode>(
inferInitialView(availableViews) inferInitialView(availableViews)
); );
const [firstPerson, setFirstPerson] = useState(false); const [firstPerson, setFirstPerson] = useState(true);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
useEffect(() => { useEffect(() => {
@ -153,7 +153,7 @@ export function WorldSharePage({
}, [activeView, availableViews]); }, [activeView, availableViews]);
useEffect(() => { useEffect(() => {
setFirstPerson(false); setFirstPerson(true);
}, [activeWorld?.id]); }, [activeWorld?.id]);
const primaryDescription = const primaryDescription =
@ -242,9 +242,7 @@ export function WorldSharePage({
key={item.id} key={item.id}
onClick={() => { onClick={() => {
setActiveView(item.id); setActiveView(item.id);
if (item.id !== "splat") { setFirstPerson(item.id === "splat");
setFirstPerson(false);
}
}} }}
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -284,6 +282,7 @@ export function WorldSharePage({
mode={activeView} mode={activeView}
previewImageUrl={activeWorld?.previewImageUrl || null} previewImageUrl={activeWorld?.previewImageUrl || null}
semanticsMetadata={activeWorld?.semanticsMetadata || null} semanticsMetadata={activeWorld?.semanticsMetadata || null}
showMovementControls={activeView === "splat" || activeView === "mesh"}
splatUrl={activeWorld?.splatUrl || null} splatUrl={activeWorld?.splatUrl || null}
worldUrl={activeWorld?.worldUrl || 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"> <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> <h2 className="text-sm font-medium text-white">查看说明</h2>
<div className="mt-3 space-y-2 text-sm leading-6 text-zinc-300"> <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> <p>当前页面是只读分享页,不会修改原项目或原画布内容。</p>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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