fix: polish canvas generation workflows
This commit is contained in:
parent
a420e4cf33
commit
cf21c80d24
@ -81,6 +81,8 @@ export function LocalAiImagePipelineSections({
|
||||
setWatermarkRemoveConfig,
|
||||
angleEditConfig,
|
||||
setAngleEditConfig,
|
||||
pointsSystemConfig,
|
||||
updatePointsCostField,
|
||||
layerSplitConfig,
|
||||
setLayerSplitConfig,
|
||||
isLocalAiSectionExpanded,
|
||||
@ -1447,6 +1449,12 @@ export function LocalAiImagePipelineSections({
|
||||
label: aiWashConfig.modeLabel || "AI洗图",
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label: aiWashConfig.hideModelSelector
|
||||
? "前台隐藏模型"
|
||||
: "前台可选模型",
|
||||
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
@ -1633,6 +1641,23 @@ export function LocalAiImagePipelineSections({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">隐藏前台生图模型选择</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
开启后,AI洗图页面不再显示生图模型下拉框,直接使用后台预设或默认模型。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={aiWashConfig.hideModelSelector}
|
||||
onCheckedChange={(checked) =>
|
||||
setAiWashConfig((prev) => ({
|
||||
...prev,
|
||||
hideModelSelector: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border p-4">
|
||||
@ -2383,6 +2408,13 @@ export function LocalAiImagePipelineSections({
|
||||
label: `${angleEditConfig.inferenceSteps} steps`,
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
|
||||
tone:
|
||||
pointsSystemConfig.creditCostPerImageAngleEdit > 0
|
||||
? "neutral"
|
||||
: "disabled",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
@ -2453,6 +2485,28 @@ export function LocalAiImagePipelineSections({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 积分消耗 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="angleEditCreditCost">积分消耗</Label>
|
||||
<Input
|
||||
id="angleEditCreditCost"
|
||||
min={0}
|
||||
onChange={(e) =>
|
||||
updatePointsCostField(
|
||||
"creditCostPerImageAngleEdit",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
type="number"
|
||||
value={pointsSystemConfig.creditCostPerImageAngleEdit}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
每次成功生成多角度图片后扣除的积分;填写 0 表示免费。保存本配置时会同步保存到全站积分设置。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* LoRA 强度 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
EnhanceProPreset,
|
||||
} from "@/lib/enhance-pro/config";
|
||||
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config";
|
||||
import type { PointsSystemConfig } from "@/lib/points/config";
|
||||
import type { ProductPlanningApiConfig } from "@/lib/product-planning/config";
|
||||
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
|
||||
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
|
||||
@ -331,6 +332,11 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
|
||||
angleEditConfig: AngleEditConfig;
|
||||
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
|
||||
pointsSystemConfig: PointsSystemConfig;
|
||||
updatePointsCostField: (
|
||||
key: keyof PointsSystemConfig,
|
||||
value: unknown
|
||||
) => void;
|
||||
layerSplitConfig: LayerSplitConfig;
|
||||
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
|
||||
renderEnhanceProPresetEditor: (
|
||||
|
||||
@ -262,6 +262,7 @@ import {
|
||||
type EnhanceProPointsResolutionBucket,
|
||||
normalizePointsPackages,
|
||||
normalizePointsSystemConfig,
|
||||
POINTS_SYSTEM_CONFIG_KEY,
|
||||
type PointsPackage,
|
||||
type PointsSystemConfig,
|
||||
} from "@/lib/points/config";
|
||||
@ -799,7 +800,8 @@ type PointsCostField =
|
||||
| "creditCostPerImageBoxCutout"
|
||||
| "creditCostPerImageWatermarkRemove"
|
||||
| "creditCostPerImageExtend"
|
||||
| "creditCostPerImageRepaint";
|
||||
| "creditCostPerImageRepaint"
|
||||
| "creditCostPerImageAngleEdit";
|
||||
|
||||
const THREE_D_GENERATION_POINT_FIELDS: Array<{
|
||||
key: PointsCostField;
|
||||
@ -885,6 +887,11 @@ const IMAGE_TOOL_POINT_FIELDS: Array<{
|
||||
label: "重绘",
|
||||
description: "调用图片提示词重绘功能时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPerImageAngleEdit",
|
||||
label: "多角度",
|
||||
description: "调用 fal.ai 多角度 / 角度编辑功能时扣除。",
|
||||
},
|
||||
];
|
||||
|
||||
type AiModel = {
|
||||
@ -4484,7 +4491,7 @@ export function AdminSettingsClient() {
|
||||
});
|
||||
};
|
||||
|
||||
const updatePointsCostField = (key: PointsCostField, value: unknown) => {
|
||||
const updatePointsCostField = (key: keyof PointsSystemConfig, value: unknown) => {
|
||||
setSiteSettings((prev) => {
|
||||
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
|
||||
return {
|
||||
@ -5298,7 +5305,11 @@ export function AdminSettingsClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
const normalizedPointsSystemConfig = normalizePointsSystemConfig(
|
||||
siteSettings.pointsSystemConfig
|
||||
);
|
||||
const [angleResponse, pointsResponse] = await Promise.all([
|
||||
fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@ -5313,9 +5324,18 @@ export function AdminSettingsClient() {
|
||||
timeout: angleEditConfig.timeout,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}),
|
||||
fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: POINTS_SYSTEM_CONFIG_KEY,
|
||||
value: normalizedPointsSystemConfig,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!response.ok) throw new Error("保存失败");
|
||||
if (!angleResponse.ok || !pointsResponse.ok) throw new Error("保存失败");
|
||||
toast.success("角度编辑API配置保存成功");
|
||||
} catch (error) {
|
||||
toast.error("保存角度编辑API配置失败");
|
||||
@ -17738,6 +17758,7 @@ export function AdminSettingsClient() {
|
||||
isLocalAiSectionExpanded={isLocalAiSectionExpanded}
|
||||
layerSplitConfig={layerSplitConfig}
|
||||
modelsData={modelsData}
|
||||
pointsSystemConfig={pointsSystemConfig}
|
||||
renderEnhanceProPresetEditor={renderEnhanceProPresetEditor}
|
||||
rmbgConfig={rmbgConfig}
|
||||
savedEnhanceProKeyRows={savedEnhanceProKeyRows}
|
||||
@ -17756,6 +17777,7 @@ export function AdminSettingsClient() {
|
||||
setSelectedEnhanceProPresetId={setSelectedEnhanceProPresetId}
|
||||
setWatermarkRemoveConfig={setWatermarkRemoveConfig}
|
||||
toggleLocalAiSection={toggleLocalAiSection}
|
||||
updatePointsCostField={updatePointsCostField}
|
||||
watermarkRemoveConfig={watermarkRemoveConfig}
|
||||
/>
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createMediaAssetRelation,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
getPointsSystemConfig,
|
||||
getSystemSetting,
|
||||
upsertMediaAsset,
|
||||
} from "@/lib/db/queries";
|
||||
@ -10,6 +11,8 @@ import { uploadFile } from "@/lib/storage/service";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
|
||||
const logger = createLogger("angle-edit-api");
|
||||
|
||||
@ -171,6 +174,19 @@ export async function POST(request: NextRequest) {
|
||||
zoom: finalZoom,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
|
||||
reason: "image_angle_edit",
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
note: "多角度编辑消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
// 4. 准备图片输入
|
||||
let imageInput: string;
|
||||
if (image_url) {
|
||||
@ -256,6 +272,19 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `多角度编辑消耗积分(${savedImage.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
logger.error("points_consume_failed", pointsError, {
|
||||
imageId: savedImage.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAssets =
|
||||
typeof image_url === "string" && image_url.trim()
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelDisplayEntries,
|
||||
getStyleProImageModelEntries,
|
||||
sortStyleProImageModelEntries,
|
||||
toPublicStyleProModelOption,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
import {
|
||||
@ -64,7 +65,9 @@ export async function GET() {
|
||||
success: true,
|
||||
defaultModelId: defaultEntry?.model.id || null,
|
||||
realisticUnitCost,
|
||||
models: displayEntries.map(toPublicStyleProModelOption),
|
||||
models: sortStyleProImageModelEntries(displayEntries).map(
|
||||
toPublicStyleProModelOption
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Style Pro Model Options] Failed:", error);
|
||||
|
||||
@ -81,6 +81,9 @@ function formatLedgerDetail(params: {
|
||||
if (reason === "realtime_canvas_generation") {
|
||||
return "实时绘画生成消耗积分";
|
||||
}
|
||||
if (reason === "image_angle_edit") {
|
||||
return "多角度编辑消耗积分";
|
||||
}
|
||||
|
||||
if (reason === "model3d_task") {
|
||||
const taskMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN);
|
||||
|
||||
@ -339,6 +339,71 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveWorldTaskSplatUrl(task?: TaskRecord | null) {
|
||||
if (!task || task.provider !== "worldlabs-3d") return null;
|
||||
return pickBestArchivedWorldLabsSplatUrl(task.assetPayload?.spzUrls);
|
||||
}
|
||||
|
||||
function resolveWorldTaskImageUrl(task?: TaskRecord | null) {
|
||||
if (!task || task.provider !== "worldlabs-3d") return null;
|
||||
return (
|
||||
task.assetPayload?.panoUrl ||
|
||||
task.assetPayload?.thumbnailUrl ||
|
||||
task.previewImageUrl ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) {
|
||||
if (!task) return null;
|
||||
return (
|
||||
task.previewImageUrl ||
|
||||
task.assetPayload?.thumbnailUrl ||
|
||||
task.assetPayload?.panoUrl ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function canImportTaskToCanvas(task?: TaskRecord | null) {
|
||||
if (!task || task.status !== "success") return false;
|
||||
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
|
||||
return Boolean(
|
||||
resolveTaskModelUrl(task) ||
|
||||
task.assetPayload?.worldUrl ||
|
||||
resolveWorldTaskImageUrl(task) ||
|
||||
resolveWorldTaskSplatUrl(task)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCanvasImportPosition(params: {
|
||||
index: number;
|
||||
totalCount: number;
|
||||
viewportX: number;
|
||||
viewportY: number;
|
||||
viewportZoom: number;
|
||||
stageWidth: number;
|
||||
stageHeight: number;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
}) {
|
||||
const column = params.index % 3;
|
||||
const row = Math.floor(params.index / 3);
|
||||
const gap = 36;
|
||||
const centerX =
|
||||
(params.stageWidth * 0.48 - params.viewportX) / params.viewportZoom;
|
||||
const centerY =
|
||||
(params.stageHeight * 0.45 - params.viewportY) / params.viewportZoom;
|
||||
const totalColumns = Math.min(3, Math.max(1, params.totalCount));
|
||||
const xOffset =
|
||||
(column - (totalColumns - 1) / 2) * (params.targetWidth + gap);
|
||||
const yOffset = row * (params.targetHeight + gap);
|
||||
|
||||
return {
|
||||
x: centerX - params.targetWidth / 2 + xOffset,
|
||||
y: centerY - params.targetHeight / 2 + yOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiViewSlots(count = 4): MultiViewSlot[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `slot_${index + 1}`,
|
||||
@ -651,7 +716,6 @@ export default function CanvasModel3DPage() {
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
|
||||
@ -722,12 +786,13 @@ export default function CanvasModel3DPage() {
|
||||
);
|
||||
const [worldViewportMode, setWorldViewportMode] =
|
||||
useState<WorldViewportMode>("world");
|
||||
const [worldFirstPerson, setWorldFirstPerson] = useState(false);
|
||||
const [worldFirstPerson, setWorldFirstPerson] = useState(true);
|
||||
const [viewerResetToken, setViewerResetToken] = useState(0);
|
||||
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
|
||||
const [selectedWorldCanvasTaskIds, setSelectedWorldCanvasTaskIds] = useState<string[]>([]);
|
||||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
@ -1274,10 +1339,21 @@ export default function CanvasModel3DPage() {
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const importableWorldTasks = useMemo(
|
||||
() =>
|
||||
visibleTaskHistory.filter(
|
||||
(task) => task.provider === "worldlabs-3d" && canImportTaskToCanvas(task)
|
||||
),
|
||||
[visibleTaskHistory]
|
||||
);
|
||||
const selectedWorldShareTasks = useMemo(() => {
|
||||
const selectedIds = new Set(selectedWorldShareTaskIds);
|
||||
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
|
||||
const selectedWorldCanvasTasks = useMemo(() => {
|
||||
const selectedIds = new Set(selectedWorldCanvasTaskIds);
|
||||
return importableWorldTasks.filter((task) => selectedIds.has(task.taskId));
|
||||
}, [selectedWorldCanvasTaskIds, importableWorldTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
|
||||
@ -1287,6 +1363,14 @@ export default function CanvasModel3DPage() {
|
||||
});
|
||||
}, [shareableWorldTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const importableIds = new Set(importableWorldTasks.map((task) => task.taskId));
|
||||
setSelectedWorldCanvasTaskIds((prev) => {
|
||||
const next = prev.filter((taskId) => importableIds.has(taskId));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [importableWorldTasks]);
|
||||
|
||||
const currentModelUrl = resolveTaskModelUrl(currentTask);
|
||||
const currentPreviewImage =
|
||||
currentTask?.previewImageUrl ||
|
||||
@ -1993,35 +2077,12 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportToCanvas = async () => {
|
||||
if (!currentTask || currentTask.status !== "success") {
|
||||
const handleImportTasksToCanvas = async (tasks: TaskRecord[]) => {
|
||||
const importableTasks = tasks.filter(canImportTaskToCanvas);
|
||||
if (importableTasks.length === 0) {
|
||||
toast.error("暂无可添加的 3D 资产");
|
||||
return;
|
||||
}
|
||||
|
||||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||||
const worldAssetPayload = currentTask.assetPayload ?? null;
|
||||
const primarySplatUrl = isWorldTask
|
||||
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
|
||||
: null;
|
||||
const worldImageUrl =
|
||||
worldAssetPayload?.panoUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
currentTask.previewImageUrl ||
|
||||
null;
|
||||
|
||||
const canImportWorld = Boolean(
|
||||
currentModelUrl ||
|
||||
worldAssetPayload?.worldUrl ||
|
||||
worldImageUrl ||
|
||||
primarySplatUrl
|
||||
);
|
||||
|
||||
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
|
||||
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型");
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportX = viewport.x || 0;
|
||||
const viewportY = viewport.y || 0;
|
||||
const viewportZoom = viewport.zoom || 1;
|
||||
@ -2030,92 +2091,109 @@ export default function CanvasModel3DPage() {
|
||||
typeof window !== "undefined" ? window.innerHeight : 720;
|
||||
const targetWidth = 380;
|
||||
const targetHeight = 300;
|
||||
const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2;
|
||||
const y =
|
||||
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
|
||||
const existing3dNodeCount = nodes.filter(
|
||||
(node) => node.type === "world3d" || node.type === "model3d"
|
||||
).length;
|
||||
|
||||
if (isWorldTask) {
|
||||
const worldNodeIds = nodes
|
||||
.filter((node) => node.type === "world3d")
|
||||
.map((node) => node.id);
|
||||
for (const nodeId of worldNodeIds) {
|
||||
deleteNode(nodeId);
|
||||
}
|
||||
}
|
||||
importableTasks.forEach((task, index) => {
|
||||
const isWorldTask = task.provider === "worldlabs-3d";
|
||||
const modelUrl = resolveTaskModelUrl(task);
|
||||
const worldAssetPayload = task.assetPayload ?? null;
|
||||
const primarySplatUrl = resolveWorldTaskSplatUrl(task);
|
||||
const worldImageUrl = resolveWorldTaskImageUrl(task);
|
||||
const position = resolveCanvasImportPosition({
|
||||
index: existing3dNodeCount + index,
|
||||
totalCount: existing3dNodeCount + importableTasks.length,
|
||||
viewportX,
|
||||
viewportY,
|
||||
viewportZoom,
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
});
|
||||
|
||||
addNode(
|
||||
isWorldTask
|
||||
? {
|
||||
type: "world3d",
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage:
|
||||
currentTask.previewImageUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
status: "completed",
|
||||
activeView: primarySplatUrl
|
||||
? "splat"
|
||||
: worldImageUrl
|
||||
? "image"
|
||||
: "mesh",
|
||||
firstPerson: false,
|
||||
hotspots: [],
|
||||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
addNode(
|
||||
isWorldTask
|
||||
? {
|
||||
type: "world3d",
|
||||
position,
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage: resolveWorldTaskPreviewUrl(task) || undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
status: "completed",
|
||||
activeView: primarySplatUrl ? "splat" : worldImageUrl ? "image" : "mesh",
|
||||
firstPerson: false,
|
||||
hotspots: [],
|
||||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||||
metadata: {
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
|
||||
vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
|
||||
meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
}
|
||||
: {
|
||||
type: "model3d",
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: currentModelUrl!,
|
||||
previewImage: currentTask.previewImageUrl || undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
format: extractModelFormat(currentModelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
provider: currentTask.provider,
|
||||
mode: currentTask.mode,
|
||||
sourceTaskId: currentTask.sourceTaskId,
|
||||
progress: currentTask.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: currentTask.assetPayload ?? null,
|
||||
connections: [],
|
||||
}
|
||||
: {
|
||||
type: "model3d",
|
||||
position,
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: modelUrl!,
|
||||
previewImage: task.previewImageUrl || undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
format: extractModelFormat(modelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined,
|
||||
vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined,
|
||||
meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
}
|
||||
);
|
||||
connections: [],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await canvasService.saveCanvasState(id, getCanvasState());
|
||||
} catch (error) {
|
||||
console.error("[Model3DPage] 保存画布失败:", error);
|
||||
}
|
||||
toast.success(isWorldTask ? "3D 世界已添加到画布" : "3D 模型已添加到画布");
|
||||
toast.success(
|
||||
importableTasks.length > 1
|
||||
? `已添加 ${importableTasks.length} 个 3D 世界到画布`
|
||||
: importableTasks[0]?.provider === "worldlabs-3d"
|
||||
? "3D 世界已添加到画布"
|
||||
: "3D 模型已添加到画布"
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportToCanvas = async () => {
|
||||
if (!currentTask || currentTask.status !== "success") {
|
||||
toast.error("暂无可添加的 3D 资产");
|
||||
return;
|
||||
}
|
||||
await handleImportTasksToCanvas([currentTask]);
|
||||
};
|
||||
|
||||
const handleDownloadCurrent = () => {
|
||||
@ -2229,6 +2307,37 @@ export default function CanvasModel3DPage() {
|
||||
);
|
||||
}, [shareableWorldTasks]);
|
||||
|
||||
const handleToggleWorldCanvasSelection = useCallback(
|
||||
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
|
||||
event?.stopPropagation();
|
||||
if (task.provider !== "worldlabs-3d" || !canImportTaskToCanvas(task)) {
|
||||
return;
|
||||
}
|
||||
setSelectedWorldCanvasTaskIds((prev) =>
|
||||
prev.includes(task.taskId)
|
||||
? prev.filter((taskId) => taskId !== task.taskId)
|
||||
: [...prev, task.taskId]
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectAllImportableWorlds = useCallback(() => {
|
||||
setSelectedWorldCanvasTaskIds((prev) =>
|
||||
prev.length === importableWorldTasks.length
|
||||
? []
|
||||
: importableWorldTasks.map((task) => task.taskId)
|
||||
);
|
||||
}, [importableWorldTasks]);
|
||||
|
||||
const handleImportSelectedWorldsToCanvas = useCallback(async () => {
|
||||
if (selectedWorldCanvasTasks.length === 0) {
|
||||
toast.error("请选择要添加到画布的世界");
|
||||
return;
|
||||
}
|
||||
await handleImportTasksToCanvas(selectedWorldCanvasTasks);
|
||||
}, [handleImportTasksToCanvas, selectedWorldCanvasTasks]);
|
||||
|
||||
const handleShareSelectedWorlds = useCallback(async () => {
|
||||
if (selectedWorldShareTasks.length < 2) {
|
||||
toast.error("请至少选择 2 个成功生成的 3D 世界");
|
||||
@ -3892,31 +4001,27 @@ export default function CanvasModel3DPage() {
|
||||
{worldViewportMode === "splat" ? (
|
||||
<>
|
||||
<div className="font-medium text-zinc-200">
|
||||
{worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景主体中心旋转"}
|
||||
{worldFirstPerson ? "漫游:WASD / 方向键" : "观察:拖拽旋转"}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div className="mt-1 truncate">
|
||||
{worldFirstPerson
|
||||
? "按住左键拖动画面转向,WASD 或方向键移动,Q/R 上升,E/F 下降。"
|
||||
: "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
|
||||
? "左键转向 · WASD 移动 · Q/R 上升 · E/F 下降"
|
||||
: "左键旋转 · 右键平移 · 滚轮缩放"}
|
||||
</div>
|
||||
</>
|
||||
) : worldViewportMode === "mesh" ? (
|
||||
<>
|
||||
<div className="font-medium text-zinc-200">
|
||||
Mesh 视图已恢复
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
当前世界关联的 Mesh 会在这里直接显示,也可以从右侧资产库点击缩略图切换当前模型。
|
||||
Mesh 视图
|
||||
</div>
|
||||
<div className="mt-1 truncate">检查几何结构与模型资产。</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-zinc-200">
|
||||
当前为图像预览
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
可以切回 World 查看空间结果,或切到 Mesh 继续检查几何结构。
|
||||
图像预览
|
||||
</div>
|
||||
<div className="mt-1 truncate">查看全景/缩略图结果。</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -3937,6 +4042,9 @@ export default function CanvasModel3DPage() {
|
||||
previewImageUrl={currentPreviewImage}
|
||||
resetViewToken={viewerResetToken}
|
||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||
showMovementControls={
|
||||
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
||||
}
|
||||
splatUrl={currentPrimarySpzUrl}
|
||||
worldUrl={currentWorldAsset?.worldUrl}
|
||||
/>
|
||||
@ -4023,7 +4131,7 @@ export default function CanvasModel3DPage() {
|
||||
{workspaceMode === "world" ? "世界资产库" : "模型资产库"}
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
点击缩略图切换当前{workspaceMode === "world" ? "世界 / 模型" : "模型"}
|
||||
点击切换{workspaceMode === "world" ? "世界" : "模型"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@ -4038,7 +4146,7 @@ export default function CanvasModel3DPage() {
|
||||
{workspaceMode === "world" ? (
|
||||
<div className="space-y-2 border-white/10 border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||||
<span>多世界分享</span>
|
||||
<span>分享</span>
|
||||
<span>已选 {selectedWorldShareTasks.length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@ -4064,6 +4172,33 @@ export default function CanvasModel3DPage() {
|
||||
分享所选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 pt-1 text-[11px] text-zinc-500">
|
||||
<span>添加到画布</span>
|
||||
<span>已选 {selectedWorldCanvasTasks.length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
|
||||
disabled={importableWorldTasks.length === 0}
|
||||
onClick={handleSelectAllImportableWorlds}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{selectedWorldCanvasTaskIds.length === importableWorldTasks.length &&
|
||||
importableWorldTasks.length > 0
|
||||
? "取消全选"
|
||||
: "全选可添加"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 border-yellow-400/30 bg-yellow-400/15 text-xs text-yellow-100 hover:bg-yellow-400/25"
|
||||
disabled={selectedWorldCanvasTasks.length === 0}
|
||||
onClick={handleImportSelectedWorldsToCanvas}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
添加 ({selectedWorldCanvasTasks.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollArea className="flex-1">
|
||||
@ -4072,6 +4207,10 @@ export default function CanvasModel3DPage() {
|
||||
const canShareWorld =
|
||||
task.provider === "worldlabs-3d" && task.status === "success";
|
||||
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
|
||||
const canImportWorld =
|
||||
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
|
||||
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
|
||||
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -4079,8 +4218,8 @@ export default function CanvasModel3DPage() {
|
||||
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
|
||||
currentTask?.taskId === task.taskId &&
|
||||
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
|
||||
isSelectedForShare &&
|
||||
"border-cyan-300 ring-2 ring-cyan-300/40"
|
||||
isSelectedForShare && "border-cyan-300 ring-2 ring-cyan-300/40",
|
||||
isSelectedForCanvas && "border-yellow-300 ring-2 ring-yellow-300/40"
|
||||
)}
|
||||
key={task.taskId}
|
||||
onClick={() => handleSelectTask(task)}
|
||||
@ -4106,6 +4245,26 @@ export default function CanvasModel3DPage() {
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canImportWorld ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute right-2 bottom-2 z-20 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
|
||||
isSelectedForCanvas &&
|
||||
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50 opacity-100"
|
||||
)}
|
||||
onClick={(event) => handleToggleWorldCanvasSelection(task, event)}
|
||||
size="icon"
|
||||
title={isSelectedForCanvas ? "取消添加选择" : "选中添加到画布"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isSelectedForCanvas ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PlusCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{canShareWorld ? (
|
||||
<Button
|
||||
className={cn(
|
||||
@ -4135,18 +4294,19 @@ export default function CanvasModel3DPage() {
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{task.previewImageUrl ? (
|
||||
{previewUrl ? (
|
||||
<img
|
||||
alt="preview"
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-110"
|
||||
src={task.previewImageUrl}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
|
||||
src={previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
||||
<Box className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/72 via-black/10 to-black/18 opacity-95" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
className="h-4 px-1 text-[9px]"
|
||||
@ -4163,23 +4323,20 @@ export default function CanvasModel3DPage() {
|
||||
{task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-left text-[10px] text-zinc-300">
|
||||
{modeLabel(task.mode)}
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-[10px]">
|
||||
<span className="min-w-0 truncate text-left text-zinc-300">
|
||||
{modeLabel(task.mode)}
|
||||
</span>
|
||||
<span className="shrink-0 text-zinc-500">
|
||||
{formatDistanceToNow(new Date(task.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-left text-[10px] text-zinc-500">
|
||||
{formatDistanceToNow(new Date(task.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})}
|
||||
</div>
|
||||
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
||||
<div className="mt-1 text-left text-[10px] text-zinc-200">
|
||||
点击右上角可下载模型
|
||||
</div>
|
||||
) : null}
|
||||
{task.provider === "worldlabs-3d" && task.shareEnabled ? (
|
||||
<div className="mt-1 text-left text-[10px] text-amber-200">
|
||||
已开启分享
|
||||
已分享
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -47,6 +47,7 @@ import {
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
@ -181,6 +182,44 @@ function hashValue(value: string) {
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function shouldUploadAiWashReferenceImage(value: string) {
|
||||
return /^data:image\/[a-z0-9.+-]+;base64,/i.test(value) || value.startsWith("blob:");
|
||||
}
|
||||
|
||||
async function uploadAiWashReferenceImage(imageUrl: string) {
|
||||
const normalized = imageUrl.trim();
|
||||
if (!shouldUploadAiWashReferenceImage(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const response = await fetch(normalized);
|
||||
if (!response.ok) {
|
||||
throw new Error("读取洗图底图失败,请重新上传图片");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (!blob.type.startsWith("image/")) {
|
||||
throw new Error("洗图底图格式无效,请上传图片文件");
|
||||
}
|
||||
|
||||
const extension = blob.type.includes("jpeg")
|
||||
? "jpg"
|
||||
: blob.type.includes("webp")
|
||||
? "webp"
|
||||
: blob.type.includes("png")
|
||||
? "png"
|
||||
: "jpg";
|
||||
const file = new File([blob], `ai-wash-reference-${Date.now()}.${extension}`, {
|
||||
type: blob.type || "image/jpeg",
|
||||
});
|
||||
const uploadResult = await imageGenerationService.uploadImage(file);
|
||||
const uploadedUrl = uploadResult.data?.url || "";
|
||||
if (!uploadResult.success || !uploadedUrl) {
|
||||
throw new Error(uploadResult.error || "洗图底图上传失败,请稍后重试");
|
||||
}
|
||||
return uploadedUrl;
|
||||
}
|
||||
|
||||
function createHistoryScopeKey(value?: string | null) {
|
||||
const raw = String(value || "").trim();
|
||||
return raw ? `ai-wash-scope-${hashValue(raw)}` : "ai-wash-scope-global";
|
||||
@ -534,7 +573,15 @@ export function AiWashWorkspace({
|
||||
[enabledPresets, modelOptionByRef, selectedLevelIndexByControl, selectedUserModel, selectedUserModelId]
|
||||
);
|
||||
const shouldShowUserModelSelector = useMemo(
|
||||
() => presetExecutionStates.some((item) => item.usesUserSelectableModel),
|
||||
() =>
|
||||
!config.hideModelSelector &&
|
||||
presetExecutionStates.some((item) => item.usesUserSelectableModel),
|
||||
[config.hideModelSelector, presetExecutionStates]
|
||||
);
|
||||
const hasExecutableModelRefs = useMemo(
|
||||
() =>
|
||||
presetExecutionStates.length > 0 &&
|
||||
presetExecutionStates.every((item) => Boolean(item.effectiveModelRef)),
|
||||
[presetExecutionStates]
|
||||
);
|
||||
|
||||
@ -680,6 +727,10 @@ export function AiWashWorkspace({
|
||||
toast.error("请先选择一个可用的生图模型");
|
||||
return;
|
||||
}
|
||||
if (!hasExecutableModelRefs) {
|
||||
toast.error("当前没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRunId = runIdRef.current + 1;
|
||||
runIdRef.current = currentRunId;
|
||||
@ -714,6 +765,20 @@ export function AiWashWorkspace({
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let requestReferenceImage = compareSourceUrl;
|
||||
try {
|
||||
if (shouldUploadAiWashReferenceImage(compareSourceUrl)) {
|
||||
toast.info("正在准备洗图底图...");
|
||||
}
|
||||
requestReferenceImage = await uploadAiWashReferenceImage(compareSourceUrl);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "洗图底图上传失败,请稍后重试";
|
||||
setLastErrorMessage(message);
|
||||
toast.error(message);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskResults = await Promise.all<AiWashTaskOutcome>(
|
||||
presetExecutionStates.map(async (executionState, index) => {
|
||||
@ -728,7 +793,7 @@ export function AiWashWorkspace({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: executionState.resolvedPrompt,
|
||||
referenceImage: compareSourceUrl,
|
||||
referenceImage: requestReferenceImage,
|
||||
selectedModelId: executionState.effectiveModelRef,
|
||||
imageSize: config.imageSize,
|
||||
promptExtend: config.promptExtend,
|
||||
@ -844,7 +909,7 @@ export function AiWashWorkspace({
|
||||
id: `ai-wash-history-${startedAt.toString(36)}`,
|
||||
scopeKey: resolvedHistoryScopeKey,
|
||||
createdAt: startedAt,
|
||||
sourceImageUrl: getPersistableSourceImageUrl(compareSourceUrl) || undefined,
|
||||
sourceImageUrl: getPersistableSourceImageUrl(requestReferenceImage) || undefined,
|
||||
items: successfulItems,
|
||||
},
|
||||
...currentEntries,
|
||||
@ -867,6 +932,7 @@ export function AiWashWorkspace({
|
||||
config.imageSize,
|
||||
config.promptExtend,
|
||||
enabledPresets,
|
||||
hasExecutableModelRefs,
|
||||
presetExecutionStates,
|
||||
persistHistoryBatches,
|
||||
resolvedHistoryScopeKey,
|
||||
@ -1312,6 +1378,7 @@ export function AiWashWorkspace({
|
||||
isSubmitting ||
|
||||
!compareSourceUrl ||
|
||||
enabledPresets.length === 0 ||
|
||||
!hasExecutableModelRefs ||
|
||||
(shouldShowUserModelSelector && !selectedUserModel)
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
|
||||
@ -214,6 +214,59 @@ const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = [
|
||||
"2:3",
|
||||
];
|
||||
|
||||
function parseRealtimeAspectRatio(ratio: RealtimeAspectRatio) {
|
||||
const [width, height] = ratio.split(":").map((part) => Number(part));
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) {
|
||||
return { width: 1, height: 1, value: 1 };
|
||||
}
|
||||
return { width, height, value: width / height };
|
||||
}
|
||||
|
||||
function fitRealtimeFrameSize(params: {
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
ratio: RealtimeAspectRatio;
|
||||
}) {
|
||||
const ratio = parseRealtimeAspectRatio(params.ratio).value;
|
||||
const availableWidth = Math.max(1, Math.floor(params.containerWidth));
|
||||
const availableHeight = Math.max(1, Math.floor(params.containerHeight));
|
||||
let width = availableWidth;
|
||||
let height = width / ratio;
|
||||
|
||||
if (height > availableHeight) {
|
||||
height = availableHeight;
|
||||
width = height * ratio;
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.floor(width)),
|
||||
height: Math.max(1, Math.floor(height)),
|
||||
};
|
||||
}
|
||||
|
||||
function scaleImageLayersToFrame(params: {
|
||||
layers: ImageLayer[];
|
||||
fromWidth: number;
|
||||
fromHeight: number;
|
||||
toWidth: number;
|
||||
toHeight: number;
|
||||
}) {
|
||||
if (params.fromWidth <= 0 || params.fromHeight <= 0) {
|
||||
return cloneImageLayers(params.layers);
|
||||
}
|
||||
const scaleX = params.toWidth / params.fromWidth;
|
||||
const scaleY = params.toHeight / params.fromHeight;
|
||||
return params.layers.map((layer) => ({
|
||||
...layer,
|
||||
x: layer.x * scaleX,
|
||||
y: layer.y * scaleY,
|
||||
width: layer.width * scaleX,
|
||||
height: layer.height * scaleY,
|
||||
baseWidth: layer.baseWidth * scaleX,
|
||||
baseHeight: layer.baseHeight * scaleY,
|
||||
}));
|
||||
}
|
||||
|
||||
function cloneImageLayers(layers: ImageLayer[]) {
|
||||
return layers.map((layer) => ({ ...layer }));
|
||||
}
|
||||
@ -849,6 +902,7 @@ export function RealtimeCanvasDialog({
|
||||
const sessionIdRef = useRef(
|
||||
`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
);
|
||||
const hasInitializedRealtimeCanvasRef = useRef(false);
|
||||
const seedRef = useRef<number>(createSessionSeed());
|
||||
const hasLoadedInitialImageRef = useRef(false);
|
||||
const canvasBackgroundRef = useRef(CANVAS_BACKGROUND);
|
||||
@ -874,6 +928,14 @@ export function RealtimeCanvasDialog({
|
||||
const [, setStatusMessage] = useState("等待连接");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewImageSize, setPreviewImageSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [realtimeFrameSize, setRealtimeFrameSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const [workspaceMode, setWorkspaceMode] =
|
||||
useState<RealtimeWorkspaceMode>(initialMode);
|
||||
const [realtimeGenerationMode, setRealtimeGenerationMode] =
|
||||
@ -1763,6 +1825,8 @@ export function RealtimeCanvasDialog({
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
imageUrl?: string;
|
||||
imageDataURI?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
cost?: number;
|
||||
transport?: string;
|
||||
warning?: string;
|
||||
@ -1792,6 +1856,16 @@ export function RealtimeCanvasDialog({
|
||||
|
||||
lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq);
|
||||
setPreviewUrl(nextPreviewUrl);
|
||||
const nextPreviewWidth = Number(payload.width);
|
||||
const nextPreviewHeight = Number(payload.height);
|
||||
setPreviewImageSize(
|
||||
nextPreviewWidth > 0 && nextPreviewHeight > 0
|
||||
? {
|
||||
width: nextPreviewWidth,
|
||||
height: nextPreviewHeight,
|
||||
}
|
||||
: null
|
||||
);
|
||||
setStatus("live");
|
||||
setStatusMessage(
|
||||
payload.transport === "ws" ? "实时渲染中(WS)" : "实时渲染中(HTTP)"
|
||||
@ -1964,6 +2038,7 @@ export function RealtimeCanvasDialog({
|
||||
if (nextRatio === realtimeAspectRatioRef.current) return;
|
||||
realtimeAspectRatioRef.current = nextRatio;
|
||||
setRealtimeAspectRatio(nextRatio);
|
||||
setPreviewImageSize(null);
|
||||
|
||||
if (realtimeGenerationModeRef.current === "auto") {
|
||||
scheduleFlush(true, false, "refine");
|
||||
@ -2172,8 +2247,13 @@ export function RealtimeCanvasDialog({
|
||||
}
|
||||
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const cssWidth = Math.max(1, Math.floor(rect.width));
|
||||
const cssHeight = Math.max(1, Math.floor(rect.height));
|
||||
const frameSize = fitRealtimeFrameSize({
|
||||
containerWidth: rect.width,
|
||||
containerHeight: rect.height,
|
||||
ratio: realtimeAspectRatioRef.current,
|
||||
});
|
||||
const cssWidth = frameSize.width;
|
||||
const cssHeight = frameSize.height;
|
||||
if (cssWidth <= 1 || cssHeight <= 1) {
|
||||
return false;
|
||||
}
|
||||
@ -2199,6 +2279,7 @@ export function RealtimeCanvasDialog({
|
||||
configureCanvas(imageLayerCanvas);
|
||||
configureCanvas(canvas);
|
||||
configureCanvas(previewCanvas);
|
||||
setRealtimeFrameSize(frameSize);
|
||||
|
||||
if (drawingSnapshot) {
|
||||
await loadDrawingLayerFromDataUrl(drawingSnapshot);
|
||||
@ -2470,18 +2551,40 @@ export function RealtimeCanvasDialog({
|
||||
return true;
|
||||
}, [createPlacedLayer, initialImageUrl, markCanvasDirty, open, saveHistory]);
|
||||
|
||||
const initializeCanvas = useCallback(() => {
|
||||
const initializeCanvas = useCallback(async (options?: { preserveContent?: boolean }) => {
|
||||
const canvas = canvasRef.current;
|
||||
const imageLayerCanvas = imageLayerCanvasRef.current;
|
||||
const previewCanvas = previewCanvasRef.current;
|
||||
const wrap = canvasWrapRef.current;
|
||||
if (!canvas || !imageLayerCanvas || !previewCanvas || !wrap) return;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const frameSize = fitRealtimeFrameSize({
|
||||
containerWidth: rect.width,
|
||||
containerHeight: rect.height,
|
||||
ratio: realtimeAspectRatioRef.current,
|
||||
});
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
canvas.width = Math.max(640, Math.floor(rect.width * dpr));
|
||||
canvas.height = Math.max(480, Math.floor(rect.height * dpr));
|
||||
canvas.style.width = `${Math.floor(rect.width)}px`;
|
||||
canvas.style.height = `${Math.floor(rect.height)}px`;
|
||||
const previousCssWidth = Math.max(
|
||||
1,
|
||||
Number.parseFloat(canvas.style.width) || frameSize.width
|
||||
);
|
||||
const previousCssHeight = Math.max(
|
||||
1,
|
||||
Number.parseFloat(canvas.style.height) || frameSize.height
|
||||
);
|
||||
const preserveContent = options?.preserveContent === true;
|
||||
const drawingSnapshot =
|
||||
preserveContent && drawingCanvasHasContent(true)
|
||||
? exportDrawingLayerDataUrl()
|
||||
: "";
|
||||
const layersSnapshot = preserveContent
|
||||
? cloneImageLayers(imageLayersRef.current)
|
||||
: [];
|
||||
|
||||
canvas.width = Math.max(1, Math.floor(frameSize.width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(frameSize.height * dpr));
|
||||
canvas.style.width = `${frameSize.width}px`;
|
||||
canvas.style.height = `${frameSize.height}px`;
|
||||
imageLayerCanvas.width = canvas.width;
|
||||
imageLayerCanvas.height = canvas.height;
|
||||
imageLayerCanvas.style.width = canvas.style.width;
|
||||
@ -2510,9 +2613,39 @@ export function RealtimeCanvasDialog({
|
||||
imageLayerCanvas.height
|
||||
);
|
||||
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
|
||||
resetCanvas();
|
||||
hasLoadedInitialImageRef.current = false;
|
||||
}, [resetCanvas]);
|
||||
setRealtimeFrameSize(frameSize);
|
||||
|
||||
if (preserveContent) {
|
||||
const nextLayers = scaleImageLayersToFrame({
|
||||
layers: layersSnapshot,
|
||||
fromWidth: previousCssWidth,
|
||||
fromHeight: previousCssHeight,
|
||||
toWidth: frameSize.width,
|
||||
toHeight: frameSize.height,
|
||||
});
|
||||
imageLayersRef.current = nextLayers;
|
||||
setImageLayers(nextLayers);
|
||||
if (drawingSnapshot) {
|
||||
await loadDrawingLayerFromDataUrl(drawingSnapshot);
|
||||
} else {
|
||||
clearDrawingCanvas();
|
||||
}
|
||||
await renderImageLayers();
|
||||
markCanvasDirty(null);
|
||||
} else {
|
||||
resetCanvas();
|
||||
hasLoadedInitialImageRef.current = false;
|
||||
}
|
||||
hasInitializedRealtimeCanvasRef.current = true;
|
||||
}, [
|
||||
clearDrawingCanvas,
|
||||
drawingCanvasHasContent,
|
||||
exportDrawingLayerDataUrl,
|
||||
loadDrawingLayerFromDataUrl,
|
||||
markCanvasDirty,
|
||||
renderImageLayers,
|
||||
resetCanvas,
|
||||
]);
|
||||
|
||||
const getCanvasPoint = useCallback(
|
||||
(event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
@ -2949,6 +3082,7 @@ export function RealtimeCanvasDialog({
|
||||
if (!open) return;
|
||||
seedRef.current = createSessionSeed();
|
||||
sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
hasInitializedRealtimeCanvasRef.current = false;
|
||||
seqRef.current = 0;
|
||||
lastRenderedSeqRef.current = 0;
|
||||
requestStartMapRef.current.clear();
|
||||
@ -2964,19 +3098,37 @@ export function RealtimeCanvasDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
initializeCanvas();
|
||||
void drawInitialImage().then((hasInitialImage) => {
|
||||
if (hasInitialImage) {
|
||||
scheduleFlushRef.current(true, false);
|
||||
}
|
||||
void initializeCanvas().then(() => {
|
||||
void drawInitialImage().then((hasInitialImage) => {
|
||||
if (hasInitialImage) {
|
||||
scheduleFlushRef.current(true, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
const onResize = () => initializeCanvas();
|
||||
const onResize = () => {
|
||||
void initializeCanvas({ preserveContent: true });
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [drawInitialImage, initializeCanvas, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
workspaceMode !== "realtime" ||
|
||||
!hasInitializedRealtimeCanvasRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void initializeCanvas({ preserveContent: true }).then(() => {
|
||||
if (realtimeGenerationModeRef.current === "auto") {
|
||||
scheduleFlushRef.current(true, false, "refine");
|
||||
}
|
||||
});
|
||||
}, [initializeCanvas, open, realtimeAspectRatio, workspaceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
@ -3281,9 +3433,20 @@ export function RealtimeCanvasDialog({
|
||||
prompt: prompt.trim() || FALLBACK_PROMPT,
|
||||
title: "实时画布结果",
|
||||
model: "bfl/flux-2",
|
||||
width: previewImageSize?.width || realtimeFrameSize.width || undefined,
|
||||
height: previewImageSize?.height || realtimeFrameSize.height || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
|
||||
}, [
|
||||
onApplyResult,
|
||||
onOpenChange,
|
||||
previewImageSize?.height,
|
||||
previewImageSize?.width,
|
||||
previewUrl,
|
||||
prompt,
|
||||
realtimeFrameSize.height,
|
||||
realtimeFrameSize.width,
|
||||
]);
|
||||
|
||||
const handleApplyEnhanceResult = useCallback(
|
||||
(payload: {
|
||||
@ -4000,14 +4163,25 @@ export function RealtimeCanvasDialog({
|
||||
{/* Right: Output Preview */}
|
||||
<div className="relative flex flex-1 flex-col items-center justify-center overflow-hidden bg-[#fcfcfc] dark:bg-[#0b0c0f]">
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
alt="实时预览"
|
||||
className="max-h-full max-w-full object-contain shadow-sm"
|
||||
src={previewDisplayUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-[#666666] dark:text-white/55">
|
||||
<div
|
||||
className="relative flex items-center justify-center overflow-hidden bg-white shadow-sm dark:bg-[#0c0d10]"
|
||||
style={{
|
||||
width: realtimeFrameSize.width || undefined,
|
||||
height: realtimeFrameSize.height || undefined,
|
||||
aspectRatio: `${parseRealtimeAspectRatio(realtimeAspectRatio).width} / ${parseRealtimeAspectRatio(realtimeAspectRatio).height}`,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.03]" />
|
||||
{previewUrl ? (
|
||||
<img
|
||||
alt="实时预览"
|
||||
className="relative h-full w-full object-cover"
|
||||
src={previewDisplayUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col items-center justify-center gap-3 text-[#666666] dark:text-white/55">
|
||||
{status === "connecting" ? (
|
||||
<>
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
||||
@ -4019,7 +4193,8 @@ export function RealtimeCanvasDialog({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{previewUrl ? (
|
||||
<div className="absolute right-5 bottom-5">
|
||||
<Button
|
||||
|
||||
@ -234,6 +234,80 @@ function collectSceneMeta(scene: Object3D): {
|
||||
};
|
||||
}
|
||||
|
||||
const COLOR_TEXTURE_KEYS = new Set([
|
||||
"map",
|
||||
"emissiveMap",
|
||||
"sheenColorMap",
|
||||
"specularColorMap",
|
||||
]);
|
||||
|
||||
const MATERIAL_TEXTURE_KEYS = [
|
||||
"alphaMap",
|
||||
"aoMap",
|
||||
"bumpMap",
|
||||
"clearcoatMap",
|
||||
"clearcoatNormalMap",
|
||||
"clearcoatRoughnessMap",
|
||||
"displacementMap",
|
||||
"emissiveMap",
|
||||
"iridescenceMap",
|
||||
"iridescenceThicknessMap",
|
||||
"lightMap",
|
||||
"map",
|
||||
"metalnessMap",
|
||||
"normalMap",
|
||||
"roughnessMap",
|
||||
"sheenColorMap",
|
||||
"sheenRoughnessMap",
|
||||
"specularColorMap",
|
||||
"specularIntensityMap",
|
||||
"specularMap",
|
||||
"thicknessMap",
|
||||
"transmissionMap",
|
||||
] as const;
|
||||
|
||||
function configureMaterialTextures(
|
||||
material: THREE.Material,
|
||||
geometry?: THREE.BufferGeometry | null
|
||||
) {
|
||||
const materialRecord = material as any;
|
||||
|
||||
if (materialRecord.aoMap && geometry?.attributes.uv && !geometry.attributes.uv2) {
|
||||
geometry.setAttribute("uv2", geometry.attributes.uv);
|
||||
}
|
||||
|
||||
for (const key of MATERIAL_TEXTURE_KEYS) {
|
||||
const texture = materialRecord[key];
|
||||
if (!texture?.isTexture) continue;
|
||||
|
||||
if ("colorSpace" in texture) {
|
||||
texture.colorSpace = COLOR_TEXTURE_KEYS.has(key)
|
||||
? THREE.SRGBColorSpace
|
||||
: THREE.NoColorSpace;
|
||||
}
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
function resolveSceneGroundY(
|
||||
scene: Object3D,
|
||||
modelRotation?: [number, number, number]
|
||||
) {
|
||||
const probeScene = scene.clone(true);
|
||||
const probeGroup = new THREE.Group();
|
||||
if (modelRotation) {
|
||||
probeGroup.rotation.set(...modelRotation);
|
||||
}
|
||||
probeGroup.add(probeScene);
|
||||
probeGroup.updateMatrixWorld(true);
|
||||
|
||||
const bounds = new THREE.Box3().setFromObject(probeGroup);
|
||||
if (bounds.isEmpty() || !Number.isFinite(bounds.min.y)) return 0;
|
||||
return bounds.min.y;
|
||||
}
|
||||
|
||||
function ModelScene({
|
||||
url,
|
||||
settings,
|
||||
@ -241,6 +315,7 @@ function ModelScene({
|
||||
layerVisibility,
|
||||
onLayersChange,
|
||||
onStatsChange,
|
||||
onGroundYChange,
|
||||
}: {
|
||||
url: string;
|
||||
settings: Model3DViewerSettings;
|
||||
@ -248,6 +323,7 @@ function ModelScene({
|
||||
layerVisibility?: Record<string, boolean>;
|
||||
onLayersChange?: (layers: Model3DLayer[]) => void;
|
||||
onStatsChange?: (stats: Model3DStats) => void;
|
||||
onGroundYChange?: (groundY: number) => void;
|
||||
}) {
|
||||
const { gl } = useThree();
|
||||
const ktx2Loader = useMemo(() => {
|
||||
@ -273,12 +349,22 @@ function ModelScene({
|
||||
);
|
||||
const scene = useMemo(() => model.scene.clone(), [model.scene]);
|
||||
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
|
||||
const modelRotationKey = modelRotation?.join("|") || "";
|
||||
const groundY = useMemo(
|
||||
() => resolveSceneGroundY(scene, modelRotation),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[scene, modelRotationKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLayersChange?.(meta.layers);
|
||||
onStatsChange?.(meta.stats);
|
||||
}, [meta.layers, meta.stats, onLayersChange, onStatsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onGroundYChange?.(groundY);
|
||||
}, [groundY, onGroundYChange]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const { layerId, mesh } of meta.meshRefs) {
|
||||
const nextVisible = layerVisibility?.[layerId];
|
||||
@ -295,18 +381,10 @@ function ModelScene({
|
||||
: [mesh.material];
|
||||
for (const material of materials) {
|
||||
if (!material) continue;
|
||||
const textureLikeKeys = ["map", "emissiveMap"] as const;
|
||||
for (const key of textureLikeKeys) {
|
||||
const texture = (material as any)?.[key];
|
||||
if (texture && "colorSpace" in texture) {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
configureMaterialTextures(material, mesh.geometry);
|
||||
if ("wireframe" in material) {
|
||||
(material as any).wireframe = settings.wireframe;
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}, [meta.meshRefs, settings.wireframe]);
|
||||
@ -565,9 +643,22 @@ export function Model3DViewer({
|
||||
}: Model3DViewerProps) {
|
||||
const controlsRef = useRef<any>(null);
|
||||
const movementStateRef = useRef<MovementState>(createMovementState());
|
||||
const [groundY, setGroundY] = React.useState(0);
|
||||
const resolvedModelUrl = useMemo(() => {
|
||||
return buildThreeAssetUrl(modelUrl);
|
||||
}, [modelUrl]);
|
||||
const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01;
|
||||
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
|
||||
setGroundY((previousGroundY) =>
|
||||
Math.abs(previousGroundY - nextGroundY) < 0.001
|
||||
? previousGroundY
|
||||
: nextGroundY
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setGroundY(0);
|
||||
}, [resolvedModelUrl]);
|
||||
|
||||
if (!modelUrl) {
|
||||
return (
|
||||
@ -654,7 +745,7 @@ export function Model3DViewer({
|
||||
cellSize={0.5}
|
||||
fadeDistance={30}
|
||||
fadeStrength={1}
|
||||
position={[0, -0.01, 0]}
|
||||
position={[0, gridY, 0]}
|
||||
sectionColor="#52525b"
|
||||
sectionSize={2}
|
||||
/>
|
||||
@ -672,6 +763,7 @@ export function Model3DViewer({
|
||||
<ModelScene
|
||||
layerVisibility={layerVisibility}
|
||||
modelRotation={modelRotation}
|
||||
onGroundYChange={handleGroundYChange}
|
||||
onLayersChange={onLayersChange}
|
||||
onStatsChange={onStatsChange}
|
||||
settings={settings}
|
||||
@ -682,6 +774,7 @@ export function Model3DViewer({
|
||||
color="#000000"
|
||||
far={4}
|
||||
opacity={0.4}
|
||||
position={[0, groundY, 0]}
|
||||
resolution={256}
|
||||
scale={10}
|
||||
/>
|
||||
@ -692,7 +785,7 @@ export function Model3DViewer({
|
||||
autoRotate={settings.autoRotate}
|
||||
dampingFactor={0.08}
|
||||
enableDamping
|
||||
enablePan={false}
|
||||
enablePan
|
||||
enableRotate
|
||||
enableZoom
|
||||
makeDefault
|
||||
@ -700,10 +793,16 @@ export function Model3DViewer({
|
||||
mouseButtons={{
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.DOLLY,
|
||||
RIGHT: THREE.MOUSE.ROTATE,
|
||||
RIGHT: THREE.MOUSE.PAN,
|
||||
}}
|
||||
panSpeed={0.8}
|
||||
screenSpacePanning
|
||||
minPolarAngle={0}
|
||||
ref={controlsRef}
|
||||
touches={{
|
||||
ONE: THREE.TOUCH.ROTATE,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||
}}
|
||||
/>
|
||||
</Canvas>
|
||||
{showMovementControls ? <ModelMovementPad movementStateRef={movementStateRef} /> : null}
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
Unlink2,
|
||||
Wand2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
@ -161,6 +162,15 @@ import {
|
||||
getDefaultThreeDModelAliasId,
|
||||
type PublicThreeDModelAliasConfig,
|
||||
} from "@/lib/model3d/model-alias-config";
|
||||
import {
|
||||
defaultPointsSystemConfig,
|
||||
get3DTaskCreditCost,
|
||||
get3DWorldCreditCost,
|
||||
getImageToolCreditCost,
|
||||
normalizePointsSystemConfig,
|
||||
type ImageToolCreditFeature,
|
||||
type PointsSystemConfig,
|
||||
} from "@/lib/points/config";
|
||||
import {
|
||||
isArchivedWorldLabsAssetUrl,
|
||||
pickBestArchivedWorldLabsSplatUrl,
|
||||
@ -291,6 +301,74 @@ type AiWashModelOptionsResponse = {
|
||||
models?: AiWashModelOption[];
|
||||
};
|
||||
|
||||
type MembershipSummaryResponse = {
|
||||
isHighVip?: boolean;
|
||||
pointsSystemConfig?: PointsSystemConfig;
|
||||
};
|
||||
|
||||
type ImageActionCreditBadgeProps = {
|
||||
label?: string | null;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function formatPointAmount(value: number) {
|
||||
const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
||||
return normalized.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function formatImageNodeFeatureCostLabel({
|
||||
cost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
}: {
|
||||
cost: number;
|
||||
isHighVip?: boolean;
|
||||
pointsEnabled: boolean;
|
||||
}) {
|
||||
if (!pointsEnabled) return "不扣积分";
|
||||
if (isHighVip) return "VIP免费";
|
||||
if (cost <= 0) return "免费";
|
||||
return `${formatPointAmount(cost)} 积分`;
|
||||
}
|
||||
|
||||
function formatImageNodeFeatureCostShortLabel({
|
||||
cost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
}: {
|
||||
cost: number;
|
||||
isHighVip?: boolean;
|
||||
pointsEnabled: boolean;
|
||||
}) {
|
||||
if (!pointsEnabled) return null;
|
||||
if (isHighVip) return "VIP";
|
||||
if (cost <= 0) return "0";
|
||||
return formatPointAmount(cost);
|
||||
}
|
||||
|
||||
function ImageActionCreditBadge({
|
||||
className,
|
||||
compact = false,
|
||||
label,
|
||||
}: ImageActionCreditBadgeProps) {
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto inline-flex shrink-0 items-center gap-1 rounded-md bg-slate-900 px-1.5 py-0.5 text-[11px] font-semibold leading-none text-white shadow-sm dark:bg-white dark:text-slate-950",
|
||||
compact && "ml-1 px-1 text-[10px]",
|
||||
className
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
<Zap className="h-3 w-3 fill-current" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type AngleEditPanelMode = "subject" | "camera";
|
||||
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
|
||||
type CropInteractionState = {
|
||||
@ -1044,6 +1122,10 @@ export function ImageNode({
|
||||
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
|
||||
fetcher
|
||||
);
|
||||
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>(
|
||||
"/api/user/membership-summary",
|
||||
fetcher
|
||||
);
|
||||
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
|
||||
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
|
||||
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
|
||||
@ -1756,6 +1838,63 @@ export function ImageNode({
|
||||
null,
|
||||
[selectedWorldModelVersion, worldModelOptions]
|
||||
);
|
||||
const pointsSystemConfig = useMemo(
|
||||
() =>
|
||||
normalizePointsSystemConfig(
|
||||
membershipSummary?.pointsSystemConfig || defaultPointsSystemConfig
|
||||
),
|
||||
[membershipSummary?.pointsSystemConfig]
|
||||
);
|
||||
const pointsEnabled = pointsSystemConfig.enabled === true;
|
||||
const isHighVip = membershipSummary?.isHighVip === true;
|
||||
const tripoImageToModelCreditCost = useMemo(
|
||||
() =>
|
||||
get3DTaskCreditCost(pointsSystemConfig, {
|
||||
taskType: "image_to_model",
|
||||
}),
|
||||
[pointsSystemConfig]
|
||||
);
|
||||
const worldImageCreditCost = useMemo(
|
||||
() => get3DWorldCreditCost(pointsSystemConfig, selectedWorldModelEntry?.id),
|
||||
[pointsSystemConfig, selectedWorldModelEntry?.id]
|
||||
);
|
||||
const tripoImageToModelCostLabel = formatImageNodeFeatureCostLabel({
|
||||
cost: tripoImageToModelCreditCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
});
|
||||
const worldImageCostLabel = formatImageNodeFeatureCostLabel({
|
||||
cost: worldImageCreditCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
});
|
||||
const tripoImageToModelCostShortLabel = formatImageNodeFeatureCostShortLabel({
|
||||
cost: tripoImageToModelCreditCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
});
|
||||
const worldImageCostShortLabel = formatImageNodeFeatureCostShortLabel({
|
||||
cost: worldImageCreditCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
});
|
||||
const getImageToolCostShortLabel = useCallback(
|
||||
(feature: ImageToolCreditFeature) =>
|
||||
formatImageNodeFeatureCostShortLabel({
|
||||
cost: getImageToolCreditCost(pointsSystemConfig, feature),
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
}),
|
||||
[isHighVip, pointsEnabled, pointsSystemConfig]
|
||||
);
|
||||
const removeBackgroundCostShortLabel =
|
||||
getImageToolCostShortLabel("remove-background");
|
||||
const boxCutoutCostShortLabel = getImageToolCostShortLabel("box-cutout");
|
||||
const removeWatermarkCostShortLabel =
|
||||
getImageToolCostShortLabel("watermark-remove");
|
||||
const extendCostShortLabel = getImageToolCostShortLabel("extend");
|
||||
const promptRegenerateCostShortLabel = getImageToolCostShortLabel("repaint");
|
||||
const angleEditCostShortLabel = getImageToolCostShortLabel("angle-edit");
|
||||
|
||||
// 🆕 物品拾取相关状态
|
||||
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
|
||||
@ -8367,7 +8506,10 @@ export function ImageNode({
|
||||
fallback={Scissors}
|
||||
item={imageRemoveBackgroundItem}
|
||||
/>
|
||||
{imageRemoveBackgroundItem.label}
|
||||
<span>{imageRemoveBackgroundItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={removeBackgroundCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imageRemoveWatermarkEnabled ? (
|
||||
@ -8377,7 +8519,10 @@ export function ImageNode({
|
||||
fallback={Eraser}
|
||||
item={imageRemoveWatermarkItem}
|
||||
/>
|
||||
{imageRemoveWatermarkItem.label}
|
||||
<span>{imageRemoveWatermarkItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={removeWatermarkCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imageEnhanceEnabled ? (
|
||||
@ -8422,12 +8567,15 @@ export function ImageNode({
|
||||
) : null}
|
||||
{imageMultiAngleEnabled ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("angle")}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Box}
|
||||
item={imageMultiAngleItem}
|
||||
/>
|
||||
{imageMultiAngleItem.label}
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Box}
|
||||
item={imageMultiAngleItem}
|
||||
/>
|
||||
<span>{imageMultiAngleItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={angleEditCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imagePromptRegenerateEnabled ? (
|
||||
@ -8439,7 +8587,10 @@ export function ImageNode({
|
||||
fallback={RefreshCw}
|
||||
item={imagePromptRegenerateItem}
|
||||
/>
|
||||
{imagePromptRegenerateItem.label}
|
||||
<span>{imagePromptRegenerateItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={promptRegenerateCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imageTextEditEnabled ? (
|
||||
@ -8459,7 +8610,8 @@ export function ImageNode({
|
||||
fallback={Expand}
|
||||
item={imageExtendItem}
|
||||
/>
|
||||
{imageExtendItem.label}
|
||||
<span>{imageExtendItem.label}</span>
|
||||
<ImageActionCreditBadge label={extendCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{imageCropEnabled ? (
|
||||
@ -8594,6 +8746,16 @@ export function ImageNode({
|
||||
/>
|
||||
|
||||
<ThreeDGenerationDialog
|
||||
costLabel={
|
||||
threedGenerationDialogMode === "world"
|
||||
? worldImageCostLabel
|
||||
: tripoImageToModelCostLabel
|
||||
}
|
||||
costShortLabel={
|
||||
threedGenerationDialogMode === "world"
|
||||
? worldImageCostShortLabel
|
||||
: tripoImageToModelCostShortLabel
|
||||
}
|
||||
isProcessing={isProcessing}
|
||||
mode={threedGenerationDialogMode}
|
||||
onConfirm={handleConfirmThreedGeneration}
|
||||
@ -9027,6 +9189,10 @@ export function ImageNode({
|
||||
item={imageRemoveBackgroundItem}
|
||||
/>
|
||||
{imageRemoveBackgroundItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={removeBackgroundCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9088,6 +9254,7 @@ export function ImageNode({
|
||||
item={imageBoxCutoutItem}
|
||||
/>
|
||||
{imageBoxCutoutItem.label}
|
||||
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9122,6 +9289,10 @@ export function ImageNode({
|
||||
item={imageRemoveWatermarkItem}
|
||||
/>
|
||||
{imageRemoveWatermarkItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={removeWatermarkCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9156,6 +9327,10 @@ export function ImageNode({
|
||||
item={imageMultiAngleItem}
|
||||
/>
|
||||
{imageMultiAngleItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={angleEditCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9173,6 +9348,10 @@ export function ImageNode({
|
||||
item={imageGenerate3dModelItem}
|
||||
/>
|
||||
{imageGenerate3dModelItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={tripoImageToModelCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9190,6 +9369,10 @@ export function ImageNode({
|
||||
item={imageGenerate3dWorldItem}
|
||||
/>
|
||||
{imageGenerate3dWorldItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={worldImageCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9224,6 +9407,7 @@ export function ImageNode({
|
||||
item={imageExtendItem}
|
||||
/>
|
||||
{imageExtendItem.label}
|
||||
<ImageActionCreditBadge compact label={extendCostShortLabel} />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9292,6 +9476,10 @@ export function ImageNode({
|
||||
item={imagePromptRegenerateItem}
|
||||
/>
|
||||
{imagePromptRegenerateItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={promptRegenerateCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@ -9346,7 +9534,8 @@ export function ImageNode({
|
||||
fallback={Scan}
|
||||
item={imageBoxCutoutItem}
|
||||
/>
|
||||
{imageBoxCutoutItem.label}
|
||||
<span>{imageBoxCutoutItem.label}</span>
|
||||
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("localEdit") ? (
|
||||
@ -9366,7 +9555,10 @@ export function ImageNode({
|
||||
fallback={Eraser}
|
||||
item={imageRemoveWatermarkItem}
|
||||
/>
|
||||
{imageRemoveWatermarkItem.label}
|
||||
<span>{imageRemoveWatermarkItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={removeWatermarkCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("resize") ? (
|
||||
@ -9386,7 +9578,10 @@ export function ImageNode({
|
||||
fallback={Box}
|
||||
item={imageMultiAngleItem}
|
||||
/>
|
||||
{imageMultiAngleItem.label}
|
||||
<span>{imageMultiAngleItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={angleEditCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("generate3dModel") ? (
|
||||
@ -9398,7 +9593,10 @@ export function ImageNode({
|
||||
fallback={Box}
|
||||
item={imageGenerate3dModelItem}
|
||||
/>
|
||||
{imageGenerate3dModelItem.label}
|
||||
<span>{imageGenerate3dModelItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={tripoImageToModelCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("generate3dWorld") ? (
|
||||
@ -9410,7 +9608,8 @@ export function ImageNode({
|
||||
fallback={Globe2}
|
||||
item={imageGenerate3dWorldItem}
|
||||
/>
|
||||
{imageGenerate3dWorldItem.label}
|
||||
<span>{imageGenerate3dWorldItem.label}</span>
|
||||
<ImageActionCreditBadge label={worldImageCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("layerSplit") ? (
|
||||
@ -9430,7 +9629,8 @@ export function ImageNode({
|
||||
fallback={Expand}
|
||||
item={imageExtendItem}
|
||||
/>
|
||||
{imageExtendItem.label}
|
||||
<span>{imageExtendItem.label}</span>
|
||||
<ImageActionCreditBadge label={extendCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("crop") ? (
|
||||
@ -9472,7 +9672,10 @@ export function ImageNode({
|
||||
fallback={RefreshCw}
|
||||
item={imagePromptRegenerateItem}
|
||||
/>
|
||||
{imagePromptRegenerateItem.label}
|
||||
<span>{imagePromptRegenerateItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
label={promptRegenerateCostShortLabel}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("note") ? (
|
||||
@ -10336,6 +10539,11 @@ export function ImageNode({
|
||||
) : (
|
||||
<>
|
||||
立即使用
|
||||
<ImageActionCreditBadge
|
||||
className="ml-0 bg-white/18 text-white"
|
||||
compact
|
||||
label={angleEditCostShortLabel}
|
||||
/>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Globe2, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Box, Globe2, Loader2, RefreshCw, Zap } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -38,6 +38,8 @@ interface PromptRegenerateDialogProps {
|
||||
}
|
||||
|
||||
interface ThreeDGenerationDialogProps {
|
||||
costLabel?: string;
|
||||
costShortLabel?: string | null;
|
||||
isProcessing: boolean;
|
||||
mode: ThreeDGenerationDialogMode;
|
||||
onConfirm: () => void;
|
||||
@ -108,6 +110,8 @@ export function PromptRegenerateDialog({
|
||||
}
|
||||
|
||||
export function ThreeDGenerationDialog({
|
||||
costLabel,
|
||||
costShortLabel,
|
||||
isProcessing,
|
||||
mode,
|
||||
onConfirm,
|
||||
@ -138,7 +142,7 @@ export function ThreeDGenerationDialog({
|
||||
>
|
||||
<DialogContent
|
||||
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
|
||||
overlayClassName="bg-black"
|
||||
overlayClassName="bg-black/35 backdrop-blur-[1px]"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@ -189,6 +193,12 @@ export function ThreeDGenerationDialog({
|
||||
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
|
||||
{selectedEntry?.description || "当前模型暂无说明。"}
|
||||
</div>
|
||||
{costLabel ? (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-slate-900 px-2.5 py-1 text-[12px] font-medium text-white shadow-sm dark:bg-white dark:text-slate-950">
|
||||
<Zap className="h-3.5 w-3.5 fill-current" />
|
||||
{costLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@ -206,7 +216,13 @@ export function ThreeDGenerationDialog({
|
||||
) : (
|
||||
<Box className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
开始生成
|
||||
<span>开始生成</span>
|
||||
{costShortLabel ? (
|
||||
<span className="ml-2 inline-flex items-center gap-1 rounded-md bg-black/18 px-1.5 py-0.5 text-[12px] font-semibold text-current dark:bg-white/18">
|
||||
<Zap className="h-3 w-3 fill-current" />
|
||||
{costShortLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -109,6 +109,8 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
|
||||
const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0];
|
||||
const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1);
|
||||
const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08;
|
||||
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22;
|
||||
|
||||
const EMPTY_MOVEMENT_STATE: MovementState = {
|
||||
forward: false,
|
||||
@ -340,6 +342,10 @@ function getWorldLabsDisplayGroundPlaneOffset(value: unknown) {
|
||||
return -value;
|
||||
}
|
||||
|
||||
function getWorldLabsDisplayGroundHelperOffset(value: number | null) {
|
||||
return value === null ? null : value - WORLDLABS_DISPLAY_GROUND_HELPER_BIAS;
|
||||
}
|
||||
|
||||
function getLoadedSplatCount(splatMesh: SparkSplatMesh) {
|
||||
try {
|
||||
return Math.max(
|
||||
@ -469,8 +475,16 @@ function positionCameraInsideWorld({
|
||||
groundPlaneOffset <= worldBounds.max.y + size.y * 0.25
|
||||
? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y)
|
||||
: null;
|
||||
const estimatedFloorY = semanticGroundY ?? worldBounds.min.y + size.y * 0.06;
|
||||
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.18, 0.9, 1.48);
|
||||
const displayFloorY =
|
||||
semanticGroundY !== null
|
||||
? THREE.MathUtils.clamp(
|
||||
semanticGroundY - size.y * WORLDLABS_DISPLAY_GROUND_BIAS_RATIO,
|
||||
worldBounds.min.y,
|
||||
worldBounds.max.y
|
||||
)
|
||||
: worldBounds.min.y + size.y * 0.04;
|
||||
const estimatedFloorY = displayFloorY;
|
||||
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.24, 1.05, 1.7);
|
||||
const rawEyeY = estimatedFloorY + humanEyeOffset;
|
||||
const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42);
|
||||
const maxEyeY = Math.min(
|
||||
@ -483,9 +497,9 @@ function positionCameraInsideWorld({
|
||||
: center.y - size.y * 0.08;
|
||||
const anchor = new THREE.Vector3(center.x, eyeY, center.z);
|
||||
const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9);
|
||||
const cameraPosition = anchor
|
||||
.clone()
|
||||
.addScaledVector(horizontalAxis, cameraDistance);
|
||||
const cameraPosition = firstPerson
|
||||
? anchor.clone()
|
||||
: anchor.clone().addScaledVector(horizontalAxis, cameraDistance);
|
||||
|
||||
cameraPosition.x = clampInsideAxis(
|
||||
cameraPosition.x,
|
||||
@ -502,7 +516,9 @@ function positionCameraInsideWorld({
|
||||
center.z
|
||||
);
|
||||
|
||||
const lookDirection = anchor.clone().sub(cameraPosition);
|
||||
const lookDirection = firstPerson
|
||||
? horizontalAxis.clone()
|
||||
: anchor.clone().sub(cameraPosition);
|
||||
if (lookDirection.lengthSq() < 0.0001) {
|
||||
lookDirection.copy(horizontalAxis).multiplyScalar(-1);
|
||||
}
|
||||
@ -511,7 +527,7 @@ function positionCameraInsideWorld({
|
||||
.clone()
|
||||
.addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4));
|
||||
const targetDrop = firstPerson
|
||||
? THREE.MathUtils.clamp(humanEyeOffset * 0.46 + size.y * 0.035, 0.38, 0.95)
|
||||
? THREE.MathUtils.clamp(humanEyeOffset * 0.18 + size.y * 0.018, 0.16, 0.46)
|
||||
: THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18);
|
||||
const minLookTargetY = Math.max(
|
||||
worldBounds.min.y + Math.max(size.y * 0.04, 0.08),
|
||||
@ -830,6 +846,34 @@ function WorldSplatLoadingOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
function stopViewerSurfaceWheel(event: React.SyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function stopViewerSurfaceContextMenu(event: React.SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function WorldViewerInteractionSurface({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("h-full w-full touch-none overscroll-contain", className)}
|
||||
data-canvas-no-drag="true"
|
||||
onContextMenu={stopViewerSurfaceContextMenu}
|
||||
onWheel={stopViewerSurfaceWheel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function readSplatFetchError(response: Response) {
|
||||
const fallback = `SPZ 请求失败(HTTP ${response.status})`;
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
@ -1337,6 +1381,7 @@ function WorldSplatScene({
|
||||
const groundPlaneOffset = getWorldLabsDisplayGroundPlaneOffset(
|
||||
semanticsMetadata?.ground_plane_offset
|
||||
);
|
||||
const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset);
|
||||
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
|
||||
orbitControlsRef.current = controls;
|
||||
setControlsMountVersion((version) => version + 1);
|
||||
@ -1622,10 +1667,10 @@ function WorldSplatScene({
|
||||
<>
|
||||
<ambientLight intensity={1.6} />
|
||||
<directionalLight intensity={1.25} position={[6, 10, 4]} />
|
||||
{groundPlaneOffset !== null ? (
|
||||
{groundHelperOffset !== null ? (
|
||||
<gridHelper
|
||||
args={[18, 18, "#334155", "#0f172a"]}
|
||||
position={[0, groundPlaneOffset, 0]}
|
||||
position={[0, groundHelperOffset, 0]}
|
||||
/>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
@ -1661,7 +1706,7 @@ function WorldSplatScene({
|
||||
autoRotate={false}
|
||||
dampingFactor={0.08}
|
||||
enableDamping
|
||||
enablePan={false}
|
||||
enablePan
|
||||
enableRotate
|
||||
enableZoom
|
||||
maxDistance={16}
|
||||
@ -1670,10 +1715,16 @@ function WorldSplatScene({
|
||||
mouseButtons={{
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.DOLLY,
|
||||
RIGHT: THREE.MOUSE.ROTATE,
|
||||
RIGHT: THREE.MOUSE.PAN,
|
||||
}}
|
||||
panSpeed={0.8}
|
||||
screenSpacePanning
|
||||
minDistance={0.5}
|
||||
ref={handleOrbitControlsRef}
|
||||
touches={{
|
||||
ONE: THREE.TOUCH.ROTATE,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Html className="pointer-events-none" fullscreen>
|
||||
@ -2127,7 +2178,7 @@ export function World3DViewer({
|
||||
|
||||
if (mode === "mesh") {
|
||||
return (
|
||||
<div className={cn("h-full w-full", className)}>
|
||||
<WorldViewerInteractionSurface className={className}>
|
||||
<Model3DViewer
|
||||
emptyStateTitle={emptyStateTitle}
|
||||
layerVisibility={meshLayerVisibility}
|
||||
@ -2141,13 +2192,15 @@ export function World3DViewer({
|
||||
settings={meshSettings || DEFAULT_MESH_SETTINGS}
|
||||
showMovementControls={showMovementControls}
|
||||
/>
|
||||
</div>
|
||||
</WorldViewerInteractionSurface>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "world") {
|
||||
return (
|
||||
<div className={cn("h-full w-full overflow-hidden bg-zinc-950", className)}>
|
||||
<WorldViewerInteractionSurface
|
||||
className={cn("overflow-hidden bg-zinc-950", className)}
|
||||
>
|
||||
{worldUrl ? (
|
||||
<iframe
|
||||
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
|
||||
@ -2166,13 +2219,13 @@ export function World3DViewer({
|
||||
title={emptyStateTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</WorldViewerInteractionSurface>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "image") {
|
||||
return (
|
||||
<div
|
||||
<WorldViewerInteractionSurface
|
||||
className={cn(
|
||||
minimalFrame
|
||||
? "flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950"
|
||||
@ -2199,12 +2252,12 @@ export function World3DViewer({
|
||||
title={emptyStateTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</WorldViewerInteractionSurface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("h-full w-full", className)}>
|
||||
<WorldViewerInteractionSurface className={className}>
|
||||
<WorldSplatViewer
|
||||
firstPerson={firstPerson}
|
||||
fallbackMeshLayerVisibility={meshLayerVisibility}
|
||||
@ -2227,6 +2280,6 @@ export function World3DViewer({
|
||||
splatUrls={splatUrls}
|
||||
worldUrl={worldUrl}
|
||||
/>
|
||||
</div>
|
||||
</WorldViewerInteractionSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@ -139,7 +139,7 @@ export function WorldSharePage({
|
||||
const [activeView, setActiveView] = useState<World3DViewMode>(
|
||||
inferInitialView(availableViews)
|
||||
);
|
||||
const [firstPerson, setFirstPerson] = useState(false);
|
||||
const [firstPerson, setFirstPerson] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -153,7 +153,7 @@ export function WorldSharePage({
|
||||
}, [activeView, availableViews]);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstPerson(false);
|
||||
setFirstPerson(true);
|
||||
}, [activeWorld?.id]);
|
||||
|
||||
const primaryDescription =
|
||||
@ -242,9 +242,7 @@ export function WorldSharePage({
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveView(item.id);
|
||||
if (item.id !== "splat") {
|
||||
setFirstPerson(false);
|
||||
}
|
||||
setFirstPerson(item.id === "splat");
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@ -284,6 +282,7 @@ export function WorldSharePage({
|
||||
mode={activeView}
|
||||
previewImageUrl={activeWorld?.previewImageUrl || null}
|
||||
semanticsMetadata={activeWorld?.semanticsMetadata || null}
|
||||
showMovementControls={activeView === "splat" || activeView === "mesh"}
|
||||
splatUrl={activeWorld?.splatUrl || null}
|
||||
worldUrl={activeWorld?.worldUrl || null}
|
||||
/>
|
||||
@ -339,7 +338,7 @@ export function WorldSharePage({
|
||||
<div className="rounded-3xl border border-white/10 bg-black/30 p-4 shadow-2xl backdrop-blur">
|
||||
<h2 className="text-sm font-medium text-white">查看说明</h2>
|
||||
<div className="mt-3 space-y-2 text-sm leading-6 text-zinc-300">
|
||||
<p>`World` 视图最接近原生 3D 世界体验,支持方向键或 `WASD` 六向移动。</p>
|
||||
<p>`World` 视图最接近原生 3D 世界体验,支持左键转向、右键平移、滚轮缩放,以及方向键或 `WASD` 六向移动。</p>
|
||||
<p>当前页面是只读分享页,不会修改原项目或原画布内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
|
||||
import { ArrowLeft, Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
@ -14,7 +14,7 @@ import {
|
||||
} from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
import { BrandTopBar } from "@/components/layout/brand-top-bar";
|
||||
import { BrandTopBar, BrandTopBarButton } from "@/components/layout/brand-top-bar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -881,7 +881,7 @@ function BeforeAfterSlider({
|
||||
|
||||
if (!beforeUrl && !afterUrl) {
|
||||
return (
|
||||
<div className="flex min-h-[620px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400 lg:min-h-[720px]">
|
||||
<div className="flex h-full min-h-[620px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400 lg:min-h-[720px]">
|
||||
上传图片后开始生成
|
||||
</div>
|
||||
);
|
||||
@ -889,21 +889,21 @@ function BeforeAfterSlider({
|
||||
|
||||
if (!afterUrl) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-172px)] min-h-[620px] max-h-[720px] rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
|
||||
<div className="h-full min-h-[620px] overflow-hidden rounded-lg border border-gray-200 bg-gray-50 lg:min-h-[720px]">
|
||||
<ImagePreview alt="上传图片" fit="cover" imageUrl={beforeUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-[calc(100vh-172px)] min-h-[620px] max-h-[720px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50"
|
||||
className="relative h-full min-h-[620px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50 lg:min-h-[720px]"
|
||||
ref={sliderRef}
|
||||
>
|
||||
<ImagePreview
|
||||
alt="生成后"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
fit="contain"
|
||||
fit="cover"
|
||||
imageUrl={afterUrl}
|
||||
/>
|
||||
<div
|
||||
@ -913,7 +913,7 @@ function BeforeAfterSlider({
|
||||
<ImagePreview
|
||||
alt="生成前"
|
||||
className="h-full w-full"
|
||||
fit="contain"
|
||||
fit="cover"
|
||||
imageUrl={beforeUrl}
|
||||
/>
|
||||
</div>
|
||||
@ -952,6 +952,7 @@ export function StyleProWorkspace({
|
||||
canvasId,
|
||||
initialSourceImageUrl,
|
||||
onAddToCanvas,
|
||||
onBack,
|
||||
onPersistState,
|
||||
persistedState,
|
||||
}: StyleProWorkspaceProps) {
|
||||
@ -1599,7 +1600,17 @@ export function StyleProWorkspace({
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||
<BrandTopBar
|
||||
actions={<StyleProAccountActions />}
|
||||
actions={
|
||||
<>
|
||||
{onBack ? (
|
||||
<BrandTopBarButton onClick={onBack}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回画布
|
||||
</BrandTopBarButton>
|
||||
) : null}
|
||||
<StyleProAccountActions />
|
||||
</>
|
||||
}
|
||||
logo={logo}
|
||||
onLogoClick={() => {
|
||||
window.location.href = "/";
|
||||
@ -1718,7 +1729,7 @@ export function StyleProWorkspace({
|
||||
|
||||
<section className="section-card p-5">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_300px] 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="rounded-[20px] bg-black/[0.025] p-4">
|
||||
<div className="flex min-h-[calc(100vh-172px)] flex-col rounded-[20px] bg-black/[0.025] p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<span className="subsection-title">生图结果</span>
|
||||
@ -1858,11 +1869,13 @@ export function StyleProWorkspace({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<BeforeAfterSlider
|
||||
afterUrl={currentResult?.resultImageUrl}
|
||||
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
|
||||
taskState={generationTaskState}
|
||||
/>
|
||||
<div className="min-h-0 flex-1">
|
||||
<BeforeAfterSlider
|
||||
afterUrl={currentResult?.resultImageUrl}
|
||||
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
|
||||
taskState={generationTaskState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[20px] bg-black/[0.025] p-4">
|
||||
|
||||
@ -42,6 +42,7 @@ export type AiWashConfig = {
|
||||
enabled: boolean;
|
||||
modeLabel: string;
|
||||
modeIconUrl: string;
|
||||
hideModelSelector: boolean;
|
||||
imageSize: string;
|
||||
promptExtend: boolean;
|
||||
historyLimit: number;
|
||||
@ -223,6 +224,7 @@ export function defaultAiWashConfig(): AiWashConfig {
|
||||
enabled: false,
|
||||
modeLabel: AI_WASH_DEFAULT_MODE_LABEL,
|
||||
modeIconUrl: "",
|
||||
hideModelSelector: false,
|
||||
imageSize: AI_WASH_DEFAULT_IMAGE_SIZE,
|
||||
promptExtend: false,
|
||||
historyLimit: AI_WASH_DEFAULT_HISTORY_LIMIT,
|
||||
@ -287,6 +289,7 @@ export function normalizeAiWashConfig(value: unknown): AiWashConfig {
|
||||
enabled: raw.enabled === true,
|
||||
modeLabel: normalizeText(raw.modeLabel, 32) || AI_WASH_DEFAULT_MODE_LABEL,
|
||||
modeIconUrl: normalizeText(raw.modeIconUrl, 2000),
|
||||
hideModelSelector: raw.hideModelSelector === true,
|
||||
imageSize: normalizeText(raw.imageSize, 16) || AI_WASH_DEFAULT_IMAGE_SIZE,
|
||||
promptExtend: raw.promptExtend === true,
|
||||
historyLimit: Math.max(
|
||||
|
||||
@ -46,6 +46,7 @@ export type PointsSystemConfig = {
|
||||
creditCostPerImageWatermarkRemove: number;
|
||||
creditCostPerImageExtend: number;
|
||||
creditCostPerImageRepaint: number;
|
||||
creditCostPerImageAngleEdit: number;
|
||||
enhanceProPricing: EnhanceProPointsPricingConfig;
|
||||
};
|
||||
|
||||
@ -62,7 +63,8 @@ export type ImageToolCreditFeature =
|
||||
| "box-cutout"
|
||||
| "watermark-remove"
|
||||
| "extend"
|
||||
| "repaint";
|
||||
| "repaint"
|
||||
| "angle-edit";
|
||||
|
||||
export type PointsPackage = {
|
||||
id: string;
|
||||
@ -93,6 +95,7 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
|
||||
creditCostPerImageWatermarkRemove: 0,
|
||||
creditCostPerImageExtend: 0,
|
||||
creditCostPerImageRepaint: 0,
|
||||
creditCostPerImageAngleEdit: 0,
|
||||
enhanceProPricing: {
|
||||
enabled: false,
|
||||
chargeMode: "scale_factor",
|
||||
@ -487,6 +490,8 @@ export function getImageToolCreditCost(
|
||||
return normalizeCost(pointsConfig.creditCostPerImageExtend);
|
||||
case "repaint":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageRepaint);
|
||||
case "angle-edit":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageAngleEdit);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@ -542,6 +547,9 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
|
||||
),
|
||||
creditCostPerImageExtend: normalizeCost(raw.creditCostPerImageExtend),
|
||||
creditCostPerImageRepaint: normalizeCost(raw.creditCostPerImageRepaint),
|
||||
creditCostPerImageAngleEdit: normalizeCost(
|
||||
raw.creditCostPerImageAngleEdit
|
||||
),
|
||||
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
|
||||
raw.enhanceProPricing,
|
||||
legacyEnhanceProCost
|
||||
|
||||
@ -193,6 +193,24 @@ export function findStyleProImageModelByRef(
|
||||
);
|
||||
}
|
||||
|
||||
export function sortStyleProImageModelEntries<
|
||||
T extends StyleProModelEntry | StyleProDisplayModelEntry,
|
||||
>(entries: T[]) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftOrder = Number.isFinite(Number(left.model.sortOrder))
|
||||
? Number(left.model.sortOrder)
|
||||
: 0;
|
||||
const rightOrder = Number.isFinite(Number(right.model.sortOrder))
|
||||
? Number(right.model.sortOrder)
|
||||
: 0;
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
|
||||
const leftName = String(left.model.displayName || left.model.modelId || "");
|
||||
const rightName = String(right.model.displayName || right.model.modelId || "");
|
||||
return leftName.localeCompare(rightName, "zh-Hans-CN");
|
||||
});
|
||||
}
|
||||
|
||||
export function toPublicStyleProModelOption(
|
||||
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
||||
): PublicStyleProModelOption {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user