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