fix: improve canvas local edit output
This commit is contained in:
parent
ce283d287f
commit
61da591a5c
@ -86,11 +86,64 @@ const DEFAULT_REGION_COLORS = [
|
|||||||
"#ff2d55",
|
"#ff2d55",
|
||||||
"#8d6e63",
|
"#8d6e63",
|
||||||
];
|
];
|
||||||
|
const SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS = [
|
||||||
|
"1:1",
|
||||||
|
"1:4",
|
||||||
|
"1:8",
|
||||||
|
"2:3",
|
||||||
|
"3:2",
|
||||||
|
"3:4",
|
||||||
|
"4:1",
|
||||||
|
"4:3",
|
||||||
|
"4:5",
|
||||||
|
"5:4",
|
||||||
|
"8:1",
|
||||||
|
"9:16",
|
||||||
|
"16:9",
|
||||||
|
"21:9",
|
||||||
|
] as const;
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAspectRatioValue(ratio: string) {
|
||||||
|
const [width, height] = ratio.split(":").map((part) => Number(part));
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return width / height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferClosestAspectRatio(width: number, height: number) {
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return "1:1";
|
||||||
|
}
|
||||||
|
const sourceRatio = width / height;
|
||||||
|
let closest: (typeof SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS)[number] = "1:1";
|
||||||
|
let closestDiff = Number.POSITIVE_INFINITY;
|
||||||
|
for (const candidate of SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS) {
|
||||||
|
const diff = Math.abs(parseAspectRatioValue(candidate) - sourceRatio);
|
||||||
|
if (diff < closestDiff) {
|
||||||
|
closest = candidate;
|
||||||
|
closestDiff = diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocalEditAspectRatio(input: unknown, sourceWidth: number, sourceHeight: number) {
|
||||||
|
const requested = String(input || "").trim();
|
||||||
|
if (
|
||||||
|
requested &&
|
||||||
|
requested.toLowerCase() !== "auto" &&
|
||||||
|
(SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS as readonly string[]).includes(requested)
|
||||||
|
) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
return inferClosestAspectRatio(sourceWidth, sourceHeight);
|
||||||
|
}
|
||||||
|
|
||||||
function toArrayBuffer(buffer: Buffer) {
|
function toArrayBuffer(buffer: Buffer) {
|
||||||
return buffer.buffer.slice(
|
return buffer.buffer.slice(
|
||||||
buffer.byteOffset,
|
buffer.byteOffset,
|
||||||
@ -331,7 +384,7 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
|||||||
region.referenceImages.length > 0
|
region.referenceImages.length > 0
|
||||||
? `;该区域有 ${region.referenceImages.length} 张专属参考图,依次为后续输入图片中的“区域 ${region.index} 参考图”,只可用于该区域`
|
? `;该区域有 ${region.referenceImages.length} 张专属参考图,依次为后续输入图片中的“区域 ${region.index} 参考图”,只可用于该区域`
|
||||||
: "";
|
: "";
|
||||||
return `区域 ${region.index}(${region.colorName},编号 ${region.index},原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%${referenceNote}):${region.prompt}`;
|
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%${referenceNote}):${region.prompt}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const referenceLines = regions
|
const referenceLines = regions
|
||||||
@ -345,17 +398,17 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
||||||
"输入图片2是同一张图的彩色标注说明图,只用于理解编辑区域位置和区域提示词,不是画面参考。",
|
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
|
||||||
"输入图片3及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
|
"输入图片2及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
|
||||||
"请不要复刻、保留、描摹或生成输入图片2里的任何彩色框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
|
"最终结果必须保持输入图片1的原始画幅比例和构图边界,不要把画面压扁、拉伸、加白边或改成方图。",
|
||||||
"如果输入图片2的标注元素与输入图片1冲突,必须完全忽略标注元素,只保留输入图片1的真实画面内容。",
|
"请不要凭空生成任何彩色框、蓝框、绿框、红框、矩形边框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
|
||||||
"",
|
"",
|
||||||
"请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:",
|
"请严格根据每个区域下方的提示词,只在对应坐标区域附近完成局部编辑:",
|
||||||
regionLines,
|
regionLines,
|
||||||
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
|
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
|
||||||
"",
|
"",
|
||||||
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
|
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
|
||||||
"未被彩色框标注的区域必须尽量保持原图不变。",
|
"未被列为编辑区域的画面必须尽量保持原图不变。",
|
||||||
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
|
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
|
||||||
"如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。",
|
"如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@ -434,19 +487,25 @@ export async function POST(request: NextRequest) {
|
|||||||
const regionReferenceImageUrls = regions.flatMap((region) =>
|
const regionReferenceImageUrls = regions.flatMap((region) =>
|
||||||
region.referenceImages.map((reference) => reference.url)
|
region.referenceImages.map((reference) => reference.url)
|
||||||
);
|
);
|
||||||
|
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
||||||
|
body.aspectRatio,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight
|
||||||
|
);
|
||||||
|
|
||||||
const imageRequest: CanvasGenerateImageRequest = {
|
const imageRequest: CanvasGenerateImageRequest = {
|
||||||
aspectRatio:
|
aspectRatio: resolvedAspectRatio,
|
||||||
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
|
|
||||||
? body.aspectRatio.trim()
|
|
||||||
: "auto",
|
|
||||||
billingFeature: "repaint",
|
billingFeature: "repaint",
|
||||||
debugLabel: "canvas-local-edit",
|
debugLabel: "canvas-local-edit",
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
annotatedReferenceUrl: annotatedUpload.url,
|
annotatedReferenceUrl: annotatedUpload.url,
|
||||||
|
annotatedReferenceSentToModel: false,
|
||||||
|
resolvedAspectRatio,
|
||||||
regionReferenceImageCount: regionReferenceImageUrls.length,
|
regionReferenceImageCount: regionReferenceImageUrls.length,
|
||||||
regionCount: regions.length,
|
regionCount: regions.length,
|
||||||
|
sourceHeight,
|
||||||
sourceNodeId: body.sourceNodeId || null,
|
sourceNodeId: body.sourceNodeId || null,
|
||||||
|
sourceWidth,
|
||||||
},
|
},
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
imageSize:
|
imageSize:
|
||||||
@ -458,12 +517,11 @@ export async function POST(request: NextRequest) {
|
|||||||
? body.model.trim()
|
? body.model.trim()
|
||||||
: undefined,
|
: undefined,
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
"彩色框、蓝色边框、绿色边框、红色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框",
|
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
||||||
prompt,
|
prompt,
|
||||||
promptExtend: false,
|
promptExtend: false,
|
||||||
referenceImages: [
|
referenceImages: [
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
annotatedUpload.url,
|
|
||||||
...regionReferenceImageUrls,
|
...regionReferenceImageUrls,
|
||||||
],
|
],
|
||||||
ownerKey: session.user.id,
|
ownerKey: session.user.id,
|
||||||
@ -492,8 +550,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
annotatedReferenceUrl: annotatedUpload.url,
|
annotatedReferenceUrl: annotatedUpload.url,
|
||||||
|
aspectRatio: resolvedAspectRatio,
|
||||||
prompt,
|
prompt,
|
||||||
regions,
|
regions,
|
||||||
|
sourceHeight,
|
||||||
|
sourceWidth,
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
status: currentTask?.status || task.status,
|
status: currentTask?.status || task.status,
|
||||||
queueStatus: currentTask?.queueStatus,
|
queueStatus: currentTask?.queueStatus,
|
||||||
|
|||||||
@ -4236,6 +4236,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
generated: true,
|
generated: true,
|
||||||
generationProgress: 100,
|
generationProgress: 100,
|
||||||
generationStatus: "complete",
|
generationStatus: "complete",
|
||||||
|
localEditMode: false,
|
||||||
|
localEditRegions: [],
|
||||||
mediaUrl: generatedUrl,
|
mediaUrl: generatedUrl,
|
||||||
modelRef: task.result?.modelRef,
|
modelRef: task.result?.modelRef,
|
||||||
params: {
|
params: {
|
||||||
@ -4348,8 +4350,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceWidth = Number(response.data.sourceWidth || 0);
|
||||||
|
const sourceHeight = Number(response.data.sourceHeight || 0);
|
||||||
|
const sourceRatio =
|
||||||
|
sourceWidth > 0 && sourceHeight > 0 ? sourceWidth / sourceHeight : 0;
|
||||||
const resultWidth = anchor.width;
|
const resultWidth = anchor.width;
|
||||||
const resultHeight = anchor.height;
|
const resultHeight =
|
||||||
|
sourceRatio > 0
|
||||||
|
? Math.max(120, Math.round(resultWidth / sourceRatio))
|
||||||
|
: anchor.height;
|
||||||
const resultNode = createMediaResourceNode({
|
const resultNode = createMediaResourceNode({
|
||||||
action: "IMAGE_RESOURCE",
|
action: "IMAGE_RESOURCE",
|
||||||
height: resultHeight,
|
height: resultHeight,
|
||||||
@ -4368,26 +4377,34 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
...(resultNode.draftValues || {}),
|
...(resultNode.draftValues || {}),
|
||||||
action: "IMAGE_RESOURCE",
|
action: "IMAGE_RESOURCE",
|
||||||
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
|
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
|
||||||
|
aspectRatio: response.data.aspectRatio,
|
||||||
functionKey: "local-edit",
|
functionKey: "local-edit",
|
||||||
generated: false,
|
generated: false,
|
||||||
generationProgress: 6,
|
generationProgress: 6,
|
||||||
generationStatus: "pending",
|
generationStatus: "pending",
|
||||||
localEditRegions: validRegions,
|
localEditMode: false,
|
||||||
|
localEditRegions: [],
|
||||||
mediaType: "image",
|
mediaType: "image",
|
||||||
mediaUrl: "",
|
mediaUrl: "",
|
||||||
nodeType: "image",
|
nodeType: "image",
|
||||||
params: {
|
params: {
|
||||||
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
|
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
|
||||||
|
aspectRatio: response.data.aspectRatio,
|
||||||
mode: "local-edit",
|
mode: "local-edit",
|
||||||
prompt: response.data.prompt,
|
prompt: response.data.prompt,
|
||||||
regions: validRegions,
|
regions: validRegions,
|
||||||
sourceNodeId: anchor.id,
|
sourceNodeId: anchor.id,
|
||||||
|
sourceHeight: response.data.sourceHeight,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
|
sourceWidth: response.data.sourceWidth,
|
||||||
taskId: response.data.taskId,
|
taskId: response.data.taskId,
|
||||||
},
|
},
|
||||||
pendingLabel: "局部编辑中",
|
pendingLabel: "局部编辑中",
|
||||||
prompt: response.data.prompt,
|
prompt: response.data.prompt,
|
||||||
sourceNodeId: anchor.id,
|
sourceNodeId: anchor.id,
|
||||||
|
sourceLocalEditRegions: validRegions,
|
||||||
|
sourceNaturalHeight: response.data.sourceHeight,
|
||||||
|
sourceNaturalWidth: response.data.sourceWidth,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
toolbarOption: "局部编辑",
|
toolbarOption: "局部编辑",
|
||||||
url: [],
|
url: [],
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
OrbitControls,
|
OrbitControls,
|
||||||
useGLTF,
|
useGLTF,
|
||||||
} from "@react-three/drei";
|
} from "@react-three/drei";
|
||||||
import { Canvas, useThree } from "@react-three/fiber";
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
|
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
@ -57,6 +57,7 @@ type Model3DViewerProps = {
|
|||||||
emptyStateTitle?: string;
|
emptyStateTitle?: string;
|
||||||
settings: Model3DViewerSettings;
|
settings: Model3DViewerSettings;
|
||||||
modelRotation?: [number, number, number];
|
modelRotation?: [number, number, number];
|
||||||
|
showMovementControls?: boolean;
|
||||||
layerVisibility?: Record<string, boolean>;
|
layerVisibility?: Record<string, boolean>;
|
||||||
onLayersChange?: (layers: Model3DLayer[]) => void;
|
onLayersChange?: (layers: Model3DLayer[]) => void;
|
||||||
onStatsChange?: (stats: Model3DStats) => void;
|
onStatsChange?: (stats: Model3DStats) => void;
|
||||||
@ -64,6 +65,34 @@ type Model3DViewerProps = {
|
|||||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MovementCommand = "forward" | "backward" | "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
type MovementState = Record<MovementCommand, boolean>;
|
||||||
|
|
||||||
|
const EMPTY_MOVEMENT_STATE: MovementState = {
|
||||||
|
forward: false,
|
||||||
|
backward: false,
|
||||||
|
left: false,
|
||||||
|
right: false,
|
||||||
|
up: false,
|
||||||
|
down: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
|
||||||
|
KeyW: "forward",
|
||||||
|
ArrowUp: "forward",
|
||||||
|
KeyS: "backward",
|
||||||
|
ArrowDown: "backward",
|
||||||
|
KeyA: "left",
|
||||||
|
ArrowLeft: "left",
|
||||||
|
KeyD: "right",
|
||||||
|
ArrowRight: "right",
|
||||||
|
KeyQ: "up",
|
||||||
|
PageUp: "up",
|
||||||
|
KeyE: "down",
|
||||||
|
PageDown: "down",
|
||||||
|
};
|
||||||
|
|
||||||
const CAMERA_PRESET_POSITIONS: Record<
|
const CAMERA_PRESET_POSITIONS: Record<
|
||||||
Model3DCameraPreset,
|
Model3DCameraPreset,
|
||||||
[number, number, number]
|
[number, number, number]
|
||||||
@ -141,6 +170,19 @@ function isMesh(node: Object3D): node is Mesh {
|
|||||||
return (node as Mesh).isMesh === true;
|
return (node as Mesh).isMesh === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMovementState(): MovementState {
|
||||||
|
return { ...EMPTY_MOVEMENT_STATE };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableKeyboardTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
|
||||||
|
const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']";
|
||||||
|
if (target.matches(editableSelector)) return true;
|
||||||
|
return Boolean(target.closest(editableSelector));
|
||||||
|
}
|
||||||
|
|
||||||
function collectSceneMeta(scene: Object3D): {
|
function collectSceneMeta(scene: Object3D): {
|
||||||
layers: Model3DLayer[];
|
layers: Model3DLayer[];
|
||||||
stats: Model3DStats;
|
stats: Model3DStats;
|
||||||
@ -316,6 +358,161 @@ function ViewerCaptureBridge({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModelMovementControls({
|
||||||
|
enabled,
|
||||||
|
movementStateRef,
|
||||||
|
orbitControlsRef,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
movementStateRef: React.MutableRefObject<MovementState>;
|
||||||
|
orbitControlsRef: React.MutableRefObject<any>;
|
||||||
|
}) {
|
||||||
|
const { camera } = useThree();
|
||||||
|
const directionRef = useRef(new THREE.Vector3());
|
||||||
|
const rightRef = useRef(new THREE.Vector3());
|
||||||
|
const upRef = useRef(new THREE.Vector3());
|
||||||
|
const moveRef = useRef(new THREE.Vector3());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
movementStateRef.current = createMovementState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const movement = KEY_TO_MOVEMENT[event.code];
|
||||||
|
if (!movement) return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
if (isEditableKeyboardTarget(event.target)) {
|
||||||
|
movementStateRef.current = createMovementState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
movementStateRef.current[movement] = true;
|
||||||
|
};
|
||||||
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
const movement = KEY_TO_MOVEMENT[event.code];
|
||||||
|
if (!movement) return;
|
||||||
|
event.preventDefault();
|
||||||
|
movementStateRef.current[movement] = false;
|
||||||
|
};
|
||||||
|
const handleBlur = () => {
|
||||||
|
movementStateRef.current = createMovementState();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
window.addEventListener("blur", handleBlur);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
window.removeEventListener("blur", handleBlur);
|
||||||
|
};
|
||||||
|
}, [enabled, movementStateRef]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const pressed = movementStateRef.current;
|
||||||
|
if (!Object.values(pressed).some(Boolean)) return;
|
||||||
|
|
||||||
|
camera.getWorldDirection(directionRef.current);
|
||||||
|
directionRef.current.setY(0);
|
||||||
|
if (directionRef.current.lengthSq() < 0.0001) {
|
||||||
|
directionRef.current.set(0, 0, -1);
|
||||||
|
}
|
||||||
|
directionRef.current.normalize();
|
||||||
|
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
|
||||||
|
upRef.current.copy(camera.up).normalize();
|
||||||
|
moveRef.current.set(0, 0, 0);
|
||||||
|
|
||||||
|
if (pressed.forward) moveRef.current.add(directionRef.current);
|
||||||
|
if (pressed.backward) moveRef.current.addScaledVector(directionRef.current, -1);
|
||||||
|
if (pressed.left) moveRef.current.addScaledVector(rightRef.current, -1);
|
||||||
|
if (pressed.right) moveRef.current.add(rightRef.current);
|
||||||
|
if (pressed.up) moveRef.current.add(upRef.current);
|
||||||
|
if (pressed.down) moveRef.current.addScaledVector(upRef.current, -1);
|
||||||
|
if (moveRef.current.lengthSq() === 0) return;
|
||||||
|
|
||||||
|
const distance = 2.6 * delta;
|
||||||
|
moveRef.current.normalize().multiplyScalar(distance);
|
||||||
|
camera.position.add(moveRef.current);
|
||||||
|
if (orbitControlsRef.current?.target) {
|
||||||
|
orbitControlsRef.current.target.add(moveRef.current);
|
||||||
|
orbitControlsRef.current.update?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelMovementPad({
|
||||||
|
movementStateRef,
|
||||||
|
}: {
|
||||||
|
movementStateRef: React.MutableRefObject<MovementState>;
|
||||||
|
}) {
|
||||||
|
const setPressed = React.useCallback(
|
||||||
|
(command: MovementCommand, pressed: boolean) => {
|
||||||
|
movementStateRef.current[command] = pressed;
|
||||||
|
},
|
||||||
|
[movementStateRef]
|
||||||
|
);
|
||||||
|
const clearMovement = React.useCallback(() => {
|
||||||
|
movementStateRef.current = createMovementState();
|
||||||
|
}, [movementStateRef]);
|
||||||
|
const buttonClass =
|
||||||
|
"flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95";
|
||||||
|
const buttonProps = (command: MovementCommand) => ({
|
||||||
|
"aria-label": command,
|
||||||
|
className: buttonClass,
|
||||||
|
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
setPressed(command, true);
|
||||||
|
},
|
||||||
|
onPointerUp: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setPressed(command, false);
|
||||||
|
},
|
||||||
|
onPointerCancel: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
clearMovement();
|
||||||
|
},
|
||||||
|
onPointerLeave: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPressed(command, false);
|
||||||
|
},
|
||||||
|
type: "button" as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="nodrag nopan nowheel pointer-events-auto absolute bottom-3 left-3 z-20 flex items-end gap-2"
|
||||||
|
data-canvas-no-drag="true"
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onPointerUp={(event) => event.stopPropagation()}
|
||||||
|
onWheel={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-1 rounded-[12px] border border-white/10 bg-black/26 p-1.5 backdrop-blur-md">
|
||||||
|
<span />
|
||||||
|
<button {...buttonProps("forward")}>W</button>
|
||||||
|
<span />
|
||||||
|
<button {...buttonProps("left")}>A</button>
|
||||||
|
<button {...buttonProps("backward")}>S</button>
|
||||||
|
<button {...buttonProps("right")}>D</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 rounded-[12px] border border-white/10 bg-black/26 p-1.5 backdrop-blur-md">
|
||||||
|
<button {...buttonProps("up")}>Q</button>
|
||||||
|
<button {...buttonProps("down")}>E</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isGlbOrGltf(url?: string | null) {
|
function isGlbOrGltf(url?: string | null) {
|
||||||
const value = String(url || "").toLowerCase();
|
const value = String(url || "").toLowerCase();
|
||||||
return /\.(gltf|glb)(\?|#|$)/.test(value);
|
return /\.(gltf|glb)(\?|#|$)/.test(value);
|
||||||
@ -359,6 +556,7 @@ export function Model3DViewer({
|
|||||||
emptyStateTitle = "准备生成 3D 资产",
|
emptyStateTitle = "准备生成 3D 资产",
|
||||||
settings,
|
settings,
|
||||||
modelRotation,
|
modelRotation,
|
||||||
|
showMovementControls = false,
|
||||||
layerVisibility,
|
layerVisibility,
|
||||||
onLayersChange,
|
onLayersChange,
|
||||||
onStatsChange,
|
onStatsChange,
|
||||||
@ -366,6 +564,7 @@ export function Model3DViewer({
|
|||||||
onCaptureReady,
|
onCaptureReady,
|
||||||
}: Model3DViewerProps) {
|
}: Model3DViewerProps) {
|
||||||
const controlsRef = useRef<any>(null);
|
const controlsRef = useRef<any>(null);
|
||||||
|
const movementStateRef = useRef<MovementState>(createMovementState());
|
||||||
const resolvedModelUrl = useMemo(() => {
|
const resolvedModelUrl = useMemo(() => {
|
||||||
return buildThreeAssetUrl(modelUrl);
|
return buildThreeAssetUrl(modelUrl);
|
||||||
}, [modelUrl]);
|
}, [modelUrl]);
|
||||||
@ -414,93 +613,101 @@ export function Model3DViewer({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Canvas
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
camera={{
|
<Canvas
|
||||||
position:
|
camera={{
|
||||||
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
|
position:
|
||||||
CAMERA_PRESET_POSITIONS.iso,
|
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
|
||||||
fov: 45,
|
CAMERA_PRESET_POSITIONS.iso,
|
||||||
}}
|
fov: 45,
|
||||||
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
|
|
||||||
gl={{ preserveDrawingBuffer: true }}
|
|
||||||
onCreated={({ gl }) => {
|
|
||||||
gl.outputColorSpace = THREE.SRGBColorSpace;
|
|
||||||
gl.toneMapping = THREE.ACESFilmicToneMapping;
|
|
||||||
gl.toneMappingExposure = 1;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ViewerCaptureBridge onCaptureReady={onCaptureReady} />
|
|
||||||
<CameraPresetController
|
|
||||||
controlsRef={controlsRef}
|
|
||||||
preset={settings.cameraPreset}
|
|
||||||
resetToken={resetViewToken}
|
|
||||||
/>
|
|
||||||
<ambientLight intensity={0.5 * settings.lightIntensity} />
|
|
||||||
<directionalLight
|
|
||||||
intensity={1.2 * settings.lightIntensity}
|
|
||||||
position={[5, 8, 5]}
|
|
||||||
shadow-bias={-0.0001}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.showGrid ? (
|
|
||||||
<Grid
|
|
||||||
args={[20, 20]}
|
|
||||||
cellColor="#3f3f46"
|
|
||||||
cellSize={0.5}
|
|
||||||
fadeDistance={30}
|
|
||||||
fadeStrength={1}
|
|
||||||
position={[0, -0.01, 0]}
|
|
||||||
sectionColor="#52525b"
|
|
||||||
sectionSize={2}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[0.5, 0.5, 0.5]} />
|
|
||||||
<meshStandardMaterial color="#27272a" wireframe />
|
|
||||||
</mesh>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<group position={[0, 0, 0]}>
|
|
||||||
<ModelScene
|
|
||||||
layerVisibility={layerVisibility}
|
|
||||||
modelRotation={modelRotation}
|
|
||||||
onLayersChange={onLayersChange}
|
|
||||||
onStatsChange={onStatsChange}
|
|
||||||
settings={settings}
|
|
||||||
url={resolvedModelUrl}
|
|
||||||
/>
|
|
||||||
<ContactShadows
|
|
||||||
blur={2}
|
|
||||||
color="#000000"
|
|
||||||
far={4}
|
|
||||||
opacity={0.4}
|
|
||||||
resolution={256}
|
|
||||||
scale={10}
|
|
||||||
/>
|
|
||||||
</group>
|
|
||||||
<Environment preset={settings.envPreset} />
|
|
||||||
</Suspense>
|
|
||||||
<OrbitControls
|
|
||||||
autoRotate={settings.autoRotate}
|
|
||||||
dampingFactor={0.08}
|
|
||||||
enableDamping
|
|
||||||
enablePan={false}
|
|
||||||
enableRotate
|
|
||||||
enableZoom
|
|
||||||
makeDefault
|
|
||||||
maxPolarAngle={Math.PI / 1.75}
|
|
||||||
mouseButtons={{
|
|
||||||
LEFT: THREE.MOUSE.ROTATE,
|
|
||||||
MIDDLE: THREE.MOUSE.DOLLY,
|
|
||||||
RIGHT: THREE.MOUSE.ROTATE,
|
|
||||||
}}
|
}}
|
||||||
minPolarAngle={0}
|
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
|
||||||
ref={controlsRef}
|
gl={{ preserveDrawingBuffer: true }}
|
||||||
/>
|
onCreated={({ gl }) => {
|
||||||
</Canvas>
|
gl.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
gl.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
gl.toneMappingExposure = 1;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ViewerCaptureBridge onCaptureReady={onCaptureReady} />
|
||||||
|
<CameraPresetController
|
||||||
|
controlsRef={controlsRef}
|
||||||
|
preset={settings.cameraPreset}
|
||||||
|
resetToken={resetViewToken}
|
||||||
|
/>
|
||||||
|
<ModelMovementControls
|
||||||
|
enabled={showMovementControls}
|
||||||
|
movementStateRef={movementStateRef}
|
||||||
|
orbitControlsRef={controlsRef}
|
||||||
|
/>
|
||||||
|
<ambientLight intensity={0.5 * settings.lightIntensity} />
|
||||||
|
<directionalLight
|
||||||
|
intensity={1.2 * settings.lightIntensity}
|
||||||
|
position={[5, 8, 5]}
|
||||||
|
shadow-bias={-0.0001}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{settings.showGrid ? (
|
||||||
|
<Grid
|
||||||
|
args={[20, 20]}
|
||||||
|
cellColor="#3f3f46"
|
||||||
|
cellSize={0.5}
|
||||||
|
fadeDistance={30}
|
||||||
|
fadeStrength={1}
|
||||||
|
position={[0, -0.01, 0]}
|
||||||
|
sectionColor="#52525b"
|
||||||
|
sectionSize={2}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.5, 0.5, 0.5]} />
|
||||||
|
<meshStandardMaterial color="#27272a" wireframe />
|
||||||
|
</mesh>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<group position={[0, 0, 0]}>
|
||||||
|
<ModelScene
|
||||||
|
layerVisibility={layerVisibility}
|
||||||
|
modelRotation={modelRotation}
|
||||||
|
onLayersChange={onLayersChange}
|
||||||
|
onStatsChange={onStatsChange}
|
||||||
|
settings={settings}
|
||||||
|
url={resolvedModelUrl}
|
||||||
|
/>
|
||||||
|
<ContactShadows
|
||||||
|
blur={2}
|
||||||
|
color="#000000"
|
||||||
|
far={4}
|
||||||
|
opacity={0.4}
|
||||||
|
resolution={256}
|
||||||
|
scale={10}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
<Environment preset={settings.envPreset} />
|
||||||
|
</Suspense>
|
||||||
|
<OrbitControls
|
||||||
|
autoRotate={settings.autoRotate}
|
||||||
|
dampingFactor={0.08}
|
||||||
|
enableDamping
|
||||||
|
enablePan={false}
|
||||||
|
enableRotate
|
||||||
|
enableZoom
|
||||||
|
makeDefault
|
||||||
|
maxPolarAngle={Math.PI / 1.75}
|
||||||
|
mouseButtons={{
|
||||||
|
LEFT: THREE.MOUSE.ROTATE,
|
||||||
|
MIDDLE: THREE.MOUSE.DOLLY,
|
||||||
|
RIGHT: THREE.MOUSE.ROTATE,
|
||||||
|
}}
|
||||||
|
minPolarAngle={0}
|
||||||
|
ref={controlsRef}
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
{showMovementControls ? <ModelMovementPad movementStateRef={movementStateRef} /> : null}
|
||||||
|
</div>
|
||||||
</ViewerErrorBoundary>
|
</ViewerErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3813,7 +3813,8 @@ export function ImageNode({
|
|||||||
metadata: {
|
metadata: {
|
||||||
editMode: "local-edit",
|
editMode: "local-edit",
|
||||||
sourceNodeId: node.id,
|
sourceNodeId: node.id,
|
||||||
localEditRegions: validRegions,
|
localEditMode: false,
|
||||||
|
sourceLocalEditRegions: validRegions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
connections: [node.id],
|
connections: [node.id],
|
||||||
@ -3844,7 +3845,21 @@ export function ImageNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taskId = submitResult.data.taskId;
|
const taskId = submitResult.data.taskId;
|
||||||
|
const localEditSourceWidth = Number(submitResult.data.sourceWidth || 0);
|
||||||
|
const localEditSourceHeight = Number(submitResult.data.sourceHeight || 0);
|
||||||
|
const localEditSourceRatio =
|
||||||
|
localEditSourceWidth > 0 && localEditSourceHeight > 0
|
||||||
|
? localEditSourceWidth / localEditSourceHeight
|
||||||
|
: 0;
|
||||||
|
const localEditResultSize =
|
||||||
|
localEditSourceRatio > 0
|
||||||
|
? {
|
||||||
|
width: node.size.width,
|
||||||
|
height: Math.max(80, Math.round(node.size.width / localEditSourceRatio)),
|
||||||
|
}
|
||||||
|
: node.size;
|
||||||
updateNode(newNodeId, {
|
updateNode(newNodeId, {
|
||||||
|
size: localEditResultSize,
|
||||||
data: {
|
data: {
|
||||||
src: "",
|
src: "",
|
||||||
status: "generating",
|
status: "generating",
|
||||||
@ -3854,9 +3869,13 @@ export function ImageNode({
|
|||||||
metadata: {
|
metadata: {
|
||||||
editMode: "local-edit",
|
editMode: "local-edit",
|
||||||
sourceNodeId: node.id,
|
sourceNodeId: node.id,
|
||||||
|
aspectRatio: submitResult.data.aspectRatio,
|
||||||
taskId,
|
taskId,
|
||||||
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
|
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
|
||||||
localEditRegions: validRegions,
|
localEditMode: false,
|
||||||
|
sourceHeight: submitResult.data.sourceHeight,
|
||||||
|
sourceLocalEditRegions: validRegions,
|
||||||
|
sourceWidth: submitResult.data.sourceWidth,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -3889,9 +3908,14 @@ export function ImageNode({
|
|||||||
metadata: {
|
metadata: {
|
||||||
editMode: "local-edit",
|
editMode: "local-edit",
|
||||||
sourceNodeId: node.id,
|
sourceNodeId: node.id,
|
||||||
|
aspectRatio: submitResult.data.aspectRatio,
|
||||||
taskId,
|
taskId,
|
||||||
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
|
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
|
||||||
localEditRegions: validRegions,
|
localEditMode: false,
|
||||||
|
localEditRegions: [],
|
||||||
|
sourceHeight: submitResult.data.sourceHeight,
|
||||||
|
sourceLocalEditRegions: validRegions,
|
||||||
|
sourceWidth: submitResult.data.sourceWidth,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -3920,7 +3944,8 @@ export function ImageNode({
|
|||||||
editMode: "local-edit",
|
editMode: "local-edit",
|
||||||
sourceNodeId: node.id,
|
sourceNodeId: node.id,
|
||||||
error: message,
|
error: message,
|
||||||
localEditRegions: validRegions,
|
localEditMode: false,
|
||||||
|
sourceLocalEditRegions: validRegions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -6671,7 +6696,7 @@ export function ImageNode({
|
|||||||
prompt: params.promptText,
|
prompt: params.promptText,
|
||||||
modelVersion: params.modelVersion,
|
modelVersion: params.modelVersion,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image",
|
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
|
||||||
firstPerson: false,
|
firstPerson: false,
|
||||||
hotspots: [],
|
hotspots: [],
|
||||||
semanticsMetadata:
|
semanticsMetadata:
|
||||||
|
|||||||
@ -66,6 +66,7 @@ const VIEW_META: Array<{
|
|||||||
toolbarKey: CanvasToolbarItemKey;
|
toolbarKey: CanvasToolbarItemKey;
|
||||||
}> = [
|
}> = [
|
||||||
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
|
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
|
||||||
|
{ id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" },
|
||||||
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
|
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -76,52 +77,40 @@ const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360;
|
|||||||
|
|
||||||
function resolveAvailableViews(data: World3DNodeData) {
|
function resolveAvailableViews(data: World3DNodeData) {
|
||||||
const views: World3DViewMode[] = [];
|
const views: World3DViewMode[] = [];
|
||||||
|
const hasSplat = isArchivedWorldLabsAssetUrl(data.splatUrl);
|
||||||
|
const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl);
|
||||||
|
|
||||||
if (data.imageUrl || data.previewImage) views.push("image");
|
if (data.imageUrl || data.previewImage) views.push("image");
|
||||||
if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh");
|
if (hasSplat) {
|
||||||
if (views.length === 0 && isArchivedWorldLabsAssetUrl(data.splatUrl)) {
|
|
||||||
views.push("splat");
|
views.push("splat");
|
||||||
|
} else if (hasMesh) {
|
||||||
|
views.push("mesh");
|
||||||
}
|
}
|
||||||
return views;
|
return views;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveActiveView(data: World3DNodeData) {
|
function resolveActiveView(data: World3DNodeData) {
|
||||||
const availableViews = resolveAvailableViews(data);
|
const availableViews = resolveAvailableViews(data);
|
||||||
|
const hasSplat = availableViews.includes("splat");
|
||||||
if (
|
if (
|
||||||
data.activeView &&
|
data.activeView &&
|
||||||
data.activeView !== "splat" &&
|
availableViews.includes(data.activeView) &&
|
||||||
availableViews.includes(data.activeView)
|
!(data.activeView === "mesh" && hasSplat)
|
||||||
) {
|
) {
|
||||||
return data.activeView;
|
return data.activeView;
|
||||||
}
|
}
|
||||||
|
if (hasSplat) {
|
||||||
|
return "splat";
|
||||||
|
}
|
||||||
if (availableViews.includes("mesh")) {
|
if (availableViews.includes("mesh")) {
|
||||||
return "mesh";
|
return "mesh";
|
||||||
}
|
}
|
||||||
return availableViews[0] || "image";
|
return availableViews[0] || "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
|
function inferModelDownloadTarget(data: World3DNodeData) {
|
||||||
const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null;
|
|
||||||
const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null;
|
const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null;
|
||||||
|
return meshUrl ? { url: meshUrl, filename: "world.glb" } : null;
|
||||||
if (activeView === "splat" && splatUrl) {
|
|
||||||
return { url: splatUrl, filename: "world.spz" };
|
|
||||||
}
|
|
||||||
if (activeView === "mesh" && meshUrl) {
|
|
||||||
return { url: meshUrl, filename: "world.glb" };
|
|
||||||
}
|
|
||||||
if (splatUrl) {
|
|
||||||
return { url: splatUrl, filename: "world.spz" };
|
|
||||||
}
|
|
||||||
if (meshUrl) {
|
|
||||||
return { url: meshUrl, filename: "world.glb" };
|
|
||||||
}
|
|
||||||
if (data.imageUrl) {
|
|
||||||
return { url: data.imageUrl, filename: "world.jpg" };
|
|
||||||
}
|
|
||||||
if (data.worldUrl) {
|
|
||||||
return { url: data.worldUrl, filename: "world.url" };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
|
function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
|
||||||
@ -261,7 +250,7 @@ export function World3DNode({
|
|||||||
[data.modelVersion, publicModelAliasConfig.worldModels],
|
[data.modelVersion, publicModelAliasConfig.worldModels],
|
||||||
);
|
);
|
||||||
const primaryUrl = resolvePrimaryUrl(data, activeView);
|
const primaryUrl = resolvePrimaryUrl(data, activeView);
|
||||||
const downloadTarget = inferDownloadTarget(data, activeView);
|
const modelDownloadTarget = inferModelDownloadTarget(data);
|
||||||
const hotspotCount = data.hotspots?.length || 0;
|
const hotspotCount = data.hotspots?.length || 0;
|
||||||
const placeholderSizeScale = useMemo(
|
const placeholderSizeScale = useMemo(
|
||||||
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
|
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
|
||||||
@ -525,7 +514,7 @@ export function World3DNode({
|
|||||||
prompt: promptText,
|
prompt: promptText,
|
||||||
modelVersion,
|
modelVersion,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image",
|
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
|
||||||
firstPerson: data.firstPerson ?? false,
|
firstPerson: data.firstPerson ?? false,
|
||||||
hotspots: data.hotspots || [],
|
hotspots: data.hotspots || [],
|
||||||
semanticsMetadata:
|
semanticsMetadata:
|
||||||
@ -670,7 +659,14 @@ export function World3DNode({
|
|||||||
splatUrl: splatUrl || renderableSplatUrl,
|
splatUrl: splatUrl || renderableSplatUrl,
|
||||||
previewImage: previewImageUrl || imageUrl || data.previewImage,
|
previewImage: previewImageUrl || imageUrl || data.previewImage,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : data.activeView,
|
activeView:
|
||||||
|
splatUrl || renderableSplatUrl
|
||||||
|
? "splat"
|
||||||
|
: meshUrl || renderableMeshUrl
|
||||||
|
? "mesh"
|
||||||
|
: imageUrl
|
||||||
|
? "image"
|
||||||
|
: data.activeView,
|
||||||
firstPerson: data.firstPerson ?? false,
|
firstPerson: data.firstPerson ?? false,
|
||||||
semanticsMetadata:
|
semanticsMetadata:
|
||||||
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
|
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
|
||||||
@ -783,15 +779,15 @@ export function World3DNode({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownloadModel = () => {
|
||||||
if (!downloadTarget) {
|
if (!modelDownloadTarget) {
|
||||||
toast.error("暂无可下载的世界资产");
|
toast.error("暂无可下载的 GLB 模型");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = downloadTarget.url;
|
link.href = modelDownloadTarget.url;
|
||||||
link.download = downloadTarget.filename;
|
link.download = modelDownloadTarget.filename;
|
||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -952,6 +948,10 @@ export function World3DNode({
|
|||||||
splatUrl={renderableSplatUrl}
|
splatUrl={renderableSplatUrl}
|
||||||
worldUrl={data.worldUrl}
|
worldUrl={data.worldUrl}
|
||||||
onCaptureReady={handleViewerCaptureReady}
|
onCaptureReady={handleViewerCaptureReady}
|
||||||
|
showMovementControls={
|
||||||
|
(activeView === "splat" || activeView === "mesh") &&
|
||||||
|
(isSelected || isHovered)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1041,6 +1041,25 @@ export function World3DNode({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{modelDownloadTarget ? (
|
||||||
|
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
|
||||||
|
<Button
|
||||||
|
className="h-5 rounded-full px-1.5"
|
||||||
|
onClick={handleDownloadModel}
|
||||||
|
size="sm"
|
||||||
|
title="下载模型"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="h-2.5 w-2.5"
|
||||||
|
fallback={Download}
|
||||||
|
item={world3dDownloadItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -1085,14 +1104,16 @@ export function World3DNode({
|
|||||||
{world3dOpenImageItem.label}
|
{world3dOpenImageItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenuItem onClick={handleDownload}>
|
{modelDownloadTarget ? (
|
||||||
<CanvasToolbarIcon
|
<DropdownMenuItem onClick={handleDownloadModel}>
|
||||||
className="mr-2 h-4 w-4"
|
<CanvasToolbarIcon
|
||||||
fallback={Download}
|
className="mr-2 h-4 w-4"
|
||||||
item={world3dDownloadItem}
|
fallback={Download}
|
||||||
/>
|
item={world3dDownloadItem}
|
||||||
{world3dDownloadItem.label}
|
/>
|
||||||
</DropdownMenuItem>
|
{world3dDownloadItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
<DropdownMenuItem onClick={handleCopy}>
|
<DropdownMenuItem onClick={handleCopy}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="mr-2 h-4 w-4"
|
className="mr-2 h-4 w-4"
|
||||||
|
|||||||
@ -82,6 +82,7 @@ type World3DViewerProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
resetViewToken?: number;
|
resetViewToken?: number;
|
||||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||||
|
showMovementControls?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||||
@ -173,8 +174,8 @@ const SPLAT_FAST_LOAD_SKIP_REVEAL_MS = 1200;
|
|||||||
const SPARK_INLINE_LOD_BUILD_THRESHOLD = 180_000;
|
const SPARK_INLINE_LOD_BUILD_THRESHOLD = 180_000;
|
||||||
const SPARK_INLINE_LOD_SPLAT_SCALE = 0.78;
|
const SPARK_INLINE_LOD_SPLAT_SCALE = 0.78;
|
||||||
const SPARK_INLINE_LOD_RENDER_SCALE = 1.8;
|
const SPARK_INLINE_LOD_RENDER_SCALE = 1.8;
|
||||||
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.6;
|
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.82;
|
||||||
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 2.4;
|
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 1.6;
|
||||||
const SPARK_MIN_SORT_INTERVAL_MS = 24;
|
const SPARK_MIN_SORT_INTERVAL_MS = 24;
|
||||||
const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40;
|
const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40;
|
||||||
|
|
||||||
@ -744,10 +745,12 @@ function createMovementState(): MovementState {
|
|||||||
function CameraMovementControls({
|
function CameraMovementControls({
|
||||||
enabled,
|
enabled,
|
||||||
firstPerson,
|
firstPerson,
|
||||||
|
movementStateRef,
|
||||||
orbitControlsRef,
|
orbitControlsRef,
|
||||||
}: {
|
}: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
firstPerson: boolean;
|
firstPerson: boolean;
|
||||||
|
movementStateRef?: React.MutableRefObject<MovementState>;
|
||||||
orbitControlsRef: React.MutableRefObject<any>;
|
orbitControlsRef: React.MutableRefObject<any>;
|
||||||
}) {
|
}) {
|
||||||
const { camera } = useThree();
|
const { camera } = useThree();
|
||||||
@ -760,6 +763,9 @@ function CameraMovementControls({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
pressedKeysRef.current = createMovementState();
|
pressedKeysRef.current = createMovementState();
|
||||||
|
if (movementStateRef) {
|
||||||
|
movementStateRef.current = createMovementState();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -799,16 +805,19 @@ function CameraMovementControls({
|
|||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
window.removeEventListener("focusin", handleFocusIn);
|
window.removeEventListener("focusin", handleFocusIn);
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled, movementStateRef]);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
const pressed = pressedKeysRef.current;
|
const pressed = pressedKeysRef.current;
|
||||||
const hasMovement = Object.values(pressed).some(Boolean);
|
const padPressed = movementStateRef?.current;
|
||||||
|
const hasMovement =
|
||||||
|
Object.values(pressed).some(Boolean) ||
|
||||||
|
Boolean(padPressed && Object.values(padPressed).some(Boolean));
|
||||||
if (!hasMovement) return;
|
if (!hasMovement) return;
|
||||||
|
|
||||||
const moveSpeed = 3.8;
|
const moveSpeed = firstPerson ? 3.8 : 2.8;
|
||||||
const distance = moveSpeed * delta;
|
const distance = moveSpeed * delta;
|
||||||
|
|
||||||
camera.getWorldDirection(directionRef.current);
|
camera.getWorldDirection(directionRef.current);
|
||||||
@ -818,22 +827,22 @@ function CameraMovementControls({
|
|||||||
|
|
||||||
moveRef.current.set(0, 0, 0);
|
moveRef.current.set(0, 0, 0);
|
||||||
|
|
||||||
if (pressed.forward) {
|
if (pressed.forward || padPressed?.forward) {
|
||||||
moveRef.current.add(directionRef.current);
|
moveRef.current.add(directionRef.current);
|
||||||
}
|
}
|
||||||
if (pressed.backward) {
|
if (pressed.backward || padPressed?.backward) {
|
||||||
moveRef.current.addScaledVector(directionRef.current, -1);
|
moveRef.current.addScaledVector(directionRef.current, -1);
|
||||||
}
|
}
|
||||||
if (pressed.left) {
|
if (pressed.left || padPressed?.left) {
|
||||||
moveRef.current.addScaledVector(rightRef.current, -1);
|
moveRef.current.addScaledVector(rightRef.current, -1);
|
||||||
}
|
}
|
||||||
if (pressed.right) {
|
if (pressed.right || padPressed?.right) {
|
||||||
moveRef.current.add(rightRef.current);
|
moveRef.current.add(rightRef.current);
|
||||||
}
|
}
|
||||||
if (pressed.up) {
|
if (pressed.up || padPressed?.up) {
|
||||||
moveRef.current.add(upRef.current);
|
moveRef.current.add(upRef.current);
|
||||||
}
|
}
|
||||||
if (pressed.down) {
|
if (pressed.down || padPressed?.down) {
|
||||||
moveRef.current.addScaledVector(upRef.current, -1);
|
moveRef.current.addScaledVector(upRef.current, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -851,6 +860,73 @@ function CameraMovementControls({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorldMovementPad({
|
||||||
|
movementStateRef,
|
||||||
|
}: {
|
||||||
|
movementStateRef: React.MutableRefObject<MovementState>;
|
||||||
|
}) {
|
||||||
|
const setPressed = React.useCallback(
|
||||||
|
(command: MovementCommand, pressed: boolean) => {
|
||||||
|
movementStateRef.current[command] = pressed;
|
||||||
|
},
|
||||||
|
[movementStateRef]
|
||||||
|
);
|
||||||
|
const clearMovement = React.useCallback(() => {
|
||||||
|
movementStateRef.current = createMovementState();
|
||||||
|
}, [movementStateRef]);
|
||||||
|
const buttonClass =
|
||||||
|
"flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95";
|
||||||
|
const buttonProps = (command: MovementCommand) => ({
|
||||||
|
"aria-label": command,
|
||||||
|
className: buttonClass,
|
||||||
|
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
setPressed(command, true);
|
||||||
|
},
|
||||||
|
onPointerUp: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setPressed(command, false);
|
||||||
|
},
|
||||||
|
onPointerCancel: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
clearMovement();
|
||||||
|
},
|
||||||
|
onPointerLeave: (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setPressed(command, false);
|
||||||
|
},
|
||||||
|
type: "button" as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="nodrag nopan nowheel pointer-events-auto absolute bottom-3 left-3 z-30 flex items-end gap-2"
|
||||||
|
data-canvas-no-drag="true"
|
||||||
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onPointerUp={(event) => event.stopPropagation()}
|
||||||
|
onWheel={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
|
||||||
|
<span />
|
||||||
|
<button {...buttonProps("forward")}>W</button>
|
||||||
|
<span />
|
||||||
|
<button {...buttonProps("left")}>A</button>
|
||||||
|
<button {...buttonProps("backward")}>S</button>
|
||||||
|
<button {...buttonProps("right")}>D</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
|
||||||
|
<button {...buttonProps("up")}>Q</button>
|
||||||
|
<button {...buttonProps("down")}>E</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FirstPersonLookControls({
|
function FirstPersonLookControls({
|
||||||
enabled,
|
enabled,
|
||||||
resetToken,
|
resetToken,
|
||||||
@ -983,6 +1059,8 @@ function WorldSplatScene({
|
|||||||
onRevealComplete,
|
onRevealComplete,
|
||||||
resetViewToken,
|
resetViewToken,
|
||||||
showInfoOverlay = true,
|
showInfoOverlay = true,
|
||||||
|
movementStateRef,
|
||||||
|
movementEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
splatUrl: string;
|
splatUrl: string;
|
||||||
revealPhase?: SplatLoadingPhase;
|
revealPhase?: SplatLoadingPhase;
|
||||||
@ -997,6 +1075,8 @@ function WorldSplatScene({
|
|||||||
onRevealComplete?: () => void;
|
onRevealComplete?: () => void;
|
||||||
resetViewToken?: number;
|
resetViewToken?: number;
|
||||||
showInfoOverlay?: boolean;
|
showInfoOverlay?: boolean;
|
||||||
|
movementStateRef?: React.MutableRefObject<MovementState>;
|
||||||
|
movementEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const renderer = useThree((state) => state.gl);
|
const renderer = useThree((state) => state.gl);
|
||||||
@ -1263,8 +1343,9 @@ function WorldSplatScene({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<CameraMovementControls
|
<CameraMovementControls
|
||||||
enabled={interactionMode === "navigate" && firstPerson}
|
enabled={movementEnabled && interactionMode === "navigate"}
|
||||||
firstPerson={firstPerson}
|
firstPerson={firstPerson}
|
||||||
|
movementStateRef={movementStateRef}
|
||||||
orbitControlsRef={orbitControlsRef}
|
orbitControlsRef={orbitControlsRef}
|
||||||
/>
|
/>
|
||||||
<FirstPersonLookControls
|
<FirstPersonLookControls
|
||||||
@ -1392,6 +1473,7 @@ function WorldSplatViewer({
|
|||||||
minimalFrame = false,
|
minimalFrame = false,
|
||||||
onCaptureReady,
|
onCaptureReady,
|
||||||
showInfoOverlay = true,
|
showInfoOverlay = true,
|
||||||
|
showMovementControls = false,
|
||||||
}: {
|
}: {
|
||||||
splatUrl?: string | null;
|
splatUrl?: string | null;
|
||||||
worldUrl?: string | null;
|
worldUrl?: string | null;
|
||||||
@ -1406,11 +1488,13 @@ function WorldSplatViewer({
|
|||||||
resetViewToken?: number;
|
resetViewToken?: number;
|
||||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||||
showInfoOverlay?: boolean;
|
showInfoOverlay?: boolean;
|
||||||
|
showMovementControls?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
|
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
|
||||||
const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
|
const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
|
||||||
const loadStartedAtRef = useRef(0);
|
const loadStartedAtRef = useRef(0);
|
||||||
|
const movementStateRef = useRef<MovementState>(createMovementState());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStartedAtRef.current = performance.now();
|
loadStartedAtRef.current = performance.now();
|
||||||
@ -1418,6 +1502,12 @@ function WorldSplatViewer({
|
|||||||
setOverlayPhase("loading");
|
setOverlayPhase("loading");
|
||||||
}, [resolvedSplatUrl]);
|
}, [resolvedSplatUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showMovementControls) {
|
||||||
|
movementStateRef.current = createMovementState();
|
||||||
|
}
|
||||||
|
}, [showMovementControls]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState === "ready") {
|
if (loadState === "ready") {
|
||||||
const elapsed = performance.now() - loadStartedAtRef.current;
|
const elapsed = performance.now() - loadStartedAtRef.current;
|
||||||
@ -1472,10 +1562,10 @@ function WorldSplatViewer({
|
|||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 1.6, 4], fov: 55 }}
|
camera={{ position: [0, 1.6, 4], fov: 55 }}
|
||||||
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
|
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
|
||||||
dpr={minimalFrame ? [0.85, 1] : [1, 1]}
|
dpr={minimalFrame ? [1, 1.5] : [1, 2]}
|
||||||
frameloop="always"
|
frameloop="always"
|
||||||
gl={{
|
gl={{
|
||||||
antialias: false,
|
antialias: true,
|
||||||
powerPreference: "high-performance",
|
powerPreference: "high-performance",
|
||||||
preserveDrawingBuffer: Boolean(onCaptureReady),
|
preserveDrawingBuffer: Boolean(onCaptureReady),
|
||||||
}}
|
}}
|
||||||
@ -1495,8 +1585,13 @@ function WorldSplatViewer({
|
|||||||
semanticsMetadata={semanticsMetadata}
|
semanticsMetadata={semanticsMetadata}
|
||||||
showInfoOverlay={showInfoOverlay}
|
showInfoOverlay={showInfoOverlay}
|
||||||
splatUrl={resolvedSplatUrl}
|
splatUrl={resolvedSplatUrl}
|
||||||
|
movementStateRef={movementStateRef}
|
||||||
|
movementEnabled={showMovementControls}
|
||||||
/>
|
/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
{showMovementControls && loadState === "ready" ? (
|
||||||
|
<WorldMovementPad movementStateRef={movementStateRef} />
|
||||||
|
) : null}
|
||||||
{overlayPhase !== "hidden" ? (
|
{overlayPhase !== "hidden" ? (
|
||||||
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
|
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
|
||||||
) : null}
|
) : null}
|
||||||
@ -1526,6 +1621,7 @@ export function World3DViewer({
|
|||||||
className,
|
className,
|
||||||
resetViewToken,
|
resetViewToken,
|
||||||
onCaptureReady,
|
onCaptureReady,
|
||||||
|
showMovementControls = false,
|
||||||
}: World3DViewerProps) {
|
}: World3DViewerProps) {
|
||||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||||
|
|
||||||
@ -1548,6 +1644,7 @@ export function World3DViewer({
|
|||||||
previewImageUrl={previewImageUrl}
|
previewImageUrl={previewImageUrl}
|
||||||
resetViewToken={resetViewToken}
|
resetViewToken={resetViewToken}
|
||||||
settings={meshSettings || DEFAULT_MESH_SETTINGS}
|
settings={meshSettings || DEFAULT_MESH_SETTINGS}
|
||||||
|
showMovementControls={showMovementControls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1625,6 +1722,7 @@ export function World3DViewer({
|
|||||||
resetViewToken={resetViewToken}
|
resetViewToken={resetViewToken}
|
||||||
semanticsMetadata={semanticsMetadata}
|
semanticsMetadata={semanticsMetadata}
|
||||||
showInfoOverlay={!minimalFrame}
|
showInfoOverlay={!minimalFrame}
|
||||||
|
showMovementControls={showMovementControls}
|
||||||
splatUrl={splatUrl}
|
splatUrl={splatUrl}
|
||||||
worldUrl={worldUrl}
|
worldUrl={worldUrl}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -22,6 +22,12 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
@ -142,7 +148,7 @@ type StyleProWorkspaceProps = {
|
|||||||
|
|
||||||
const STYLE_PRO_HISTORY_LIMIT = 4;
|
const STYLE_PRO_HISTORY_LIMIT = 4;
|
||||||
const STYLE_PRO_TASK_POLL_INTERVAL_MS = 1600;
|
const STYLE_PRO_TASK_POLL_INTERVAL_MS = 1600;
|
||||||
const STYLE_PRO_TASK_TIMEOUT_MS = 4 * 60 * 1000;
|
const STYLE_PRO_TASK_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
type StyleProTaskState = {
|
type StyleProTaskState = {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@ -645,10 +651,12 @@ function StyleProAccountActions() {
|
|||||||
function ImagePreview({
|
function ImagePreview({
|
||||||
alt,
|
alt,
|
||||||
className,
|
className,
|
||||||
|
fit = "contain",
|
||||||
imageUrl,
|
imageUrl,
|
||||||
}: {
|
}: {
|
||||||
alt: string;
|
alt: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
fit?: "contain" | "cover";
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
@ -667,7 +675,11 @@ function ImagePreview({
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={cn("h-full w-full rounded-lg object-contain", className)}
|
className={cn(
|
||||||
|
"h-full w-full rounded-lg",
|
||||||
|
fit === "cover" ? "object-cover" : "object-contain",
|
||||||
|
className
|
||||||
|
)}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
/>
|
/>
|
||||||
@ -889,6 +901,7 @@ function BeforeAfterSlider({
|
|||||||
<ImagePreview
|
<ImagePreview
|
||||||
alt="生成后"
|
alt="生成后"
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
|
fit="cover"
|
||||||
imageUrl={afterUrl}
|
imageUrl={afterUrl}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@ -898,6 +911,7 @@ function BeforeAfterSlider({
|
|||||||
<ImagePreview
|
<ImagePreview
|
||||||
alt="生成前"
|
alt="生成前"
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
|
fit="cover"
|
||||||
imageUrl={beforeUrl}
|
imageUrl={beforeUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1371,7 +1385,15 @@ export function StyleProWorkspace({
|
|||||||
].slice(0, 4);
|
].slice(0, 4);
|
||||||
setHistory(nextHistory);
|
setHistory(nextHistory);
|
||||||
setCurrentResult(item);
|
setCurrentResult(item);
|
||||||
await persistState({ sourceImageUrl, history: nextHistory });
|
try {
|
||||||
|
await persistState({ sourceImageUrl, history: nextHistory });
|
||||||
|
} catch (persistError) {
|
||||||
|
console.error(
|
||||||
|
"[Style Pro] Failed to persist generated result:",
|
||||||
|
persistError
|
||||||
|
);
|
||||||
|
toast.warning("结果已生成,历史记录稍后同步");
|
||||||
|
}
|
||||||
setGenerationTaskState(null);
|
setGenerationTaskState(null);
|
||||||
toast.success("风格化 PRO 生成完成");
|
toast.success("风格化 PRO 生成完成");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1711,38 +1733,64 @@ export function StyleProWorkspace({
|
|||||||
{generationMode === "style" ? (
|
{generationMode === "style" ? (
|
||||||
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
|
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
|
||||||
<span className="text-black/45">模型</span>
|
<span className="text-black/45">模型</span>
|
||||||
<select
|
{modelOptions.length > 0 ? (
|
||||||
aria-label="选择生图模型"
|
<Select
|
||||||
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
|
disabled={
|
||||||
disabled={
|
modelsLoading ||
|
||||||
modelsLoading ||
|
selectableModelOptions.length === 0 ||
|
||||||
selectableModelOptions.length === 0 ||
|
generating
|
||||||
generating
|
}
|
||||||
}
|
onValueChange={setSelectedModelId}
|
||||||
onChange={(event) =>
|
value={selectedModel?.id || selectedModelId}
|
||||||
setSelectedModelId(event.target.value)
|
>
|
||||||
}
|
<SelectTrigger
|
||||||
value={selectedModel?.id || selectedModelId}
|
aria-label="选择生图模型"
|
||||||
>
|
className="h-7 w-[190px] rounded-[10px] border-0 bg-transparent px-1 py-0 text-[12px] font-semibold text-black/75 shadow-none ring-offset-0 focus:ring-0 focus:ring-offset-0 disabled:opacity-60 sm:w-[230px]"
|
||||||
{modelOptions.length > 0 ? (
|
>
|
||||||
modelOptions.map((model) => (
|
<span className="min-w-0 flex-1 truncate text-left">
|
||||||
<option
|
{selectedModel?.displayName || "选择模型"}
|
||||||
disabled={model.selectable === false}
|
</span>
|
||||||
key={model.id}
|
</SelectTrigger>
|
||||||
value={model.id}
|
<SelectContent className="z-[50000] max-h-[360px] min-w-[340px] rounded-2xl border-black/10 bg-white p-1.5 text-black shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
|
||||||
>
|
{modelOptions.map((model) => (
|
||||||
{model.displayName}
|
<SelectItem
|
||||||
{model.availabilityLabel
|
className="min-h-10 rounded-xl py-2 pl-8 pr-3 text-[13px] focus:bg-black/[0.04]"
|
||||||
? `(${model.availabilityLabel})`
|
disabled={model.selectable === false}
|
||||||
: ""}
|
key={model.id}
|
||||||
</option>
|
title={model.disabledReason || model.displayName}
|
||||||
))
|
value={model.id}
|
||||||
) : (
|
>
|
||||||
<option value="">
|
<div className="flex w-full min-w-0 items-center gap-2">
|
||||||
{modelsLoading ? "加载模型" : "暂无模型"}
|
{model.avatarUrl ? (
|
||||||
</option>
|
<img
|
||||||
)}
|
alt=""
|
||||||
</select>
|
className="h-5 w-5 shrink-0 rounded-full object-cover"
|
||||||
|
src={model.avatarUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 flex-1 whitespace-normal break-words font-semibold leading-snug text-black/80">
|
||||||
|
{model.displayName}
|
||||||
|
</span>
|
||||||
|
{model.availabilityLabel ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-600">
|
||||||
|
{model.availabilityLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{model.creditDiscountLabel ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||||
|
{model.creditDiscountLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="h-6 max-w-[210px] truncate bg-transparent font-semibold text-black/40">
|
||||||
|
{modelsLoading ? "加载模型" : "暂无模型"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
|
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
|
||||||
{currentCostLabel}
|
{currentCostLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -502,8 +502,8 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
|||||||
{
|
{
|
||||||
key: "world3dDownload",
|
key: "world3dDownload",
|
||||||
group: "节点工具栏",
|
group: "节点工具栏",
|
||||||
label: "下载当前资产",
|
label: "下载模型",
|
||||||
description: "下载 3D 世界当前资产",
|
description: "下载 3D 世界 GLB 模型资源",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "world3dCopy",
|
key: "world3dCopy",
|
||||||
|
|||||||
@ -66,6 +66,21 @@ const VIDEO_MODEL_KEYWORDS = [
|
|||||||
"i2v",
|
"i2v",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const EXPLICIT_IMAGE_MODEL_KEYWORDS = [
|
||||||
|
"/generate/image/",
|
||||||
|
"gpt-image",
|
||||||
|
"sora_image",
|
||||||
|
"qwen-image",
|
||||||
|
"seedream",
|
||||||
|
"nano-banana",
|
||||||
|
"banana",
|
||||||
|
"flux",
|
||||||
|
"z-image",
|
||||||
|
"image-edit",
|
||||||
|
"gen4_image",
|
||||||
|
"gen-4 image",
|
||||||
|
];
|
||||||
|
|
||||||
function hasVideoGenerationConfig(value: unknown) {
|
function hasVideoGenerationConfig(value: unknown) {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
const config = value as Record<string, unknown>;
|
const config = value as Record<string, unknown>;
|
||||||
@ -81,7 +96,36 @@ function hasVideoGenerationConfig(value: unknown) {
|
|||||||
function hasVideoCapabilities(value: unknown) {
|
function hasVideoCapabilities(value: unknown) {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
const serialized = JSON.stringify(value).toLowerCase();
|
const serialized = JSON.stringify(value).toLowerCase();
|
||||||
return serialized.includes("text-to-video") || serialized.includes("image-to-video");
|
return (
|
||||||
|
serialized.includes("text-to-video") ||
|
||||||
|
serialized.includes("image-to-video")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExplicitImageGenerationModel(
|
||||||
|
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
||||||
|
) {
|
||||||
|
const searchable = [
|
||||||
|
entry.model.modelId,
|
||||||
|
entry.model.displayName,
|
||||||
|
entry.model.description,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
searchable.includes("image-to-video") ||
|
||||||
|
searchable.includes("text-to-video") ||
|
||||||
|
searchable.includes("i2v") ||
|
||||||
|
searchable.includes("t2v")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return EXPLICIT_IMAGE_MODEL_KEYWORDS.some((keyword) =>
|
||||||
|
searchable.includes(keyword)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoLikeModel(
|
function isVideoLikeModel(
|
||||||
@ -111,7 +155,9 @@ function isVideoLikeModel(
|
|||||||
export function isStyleProImageModel(
|
export function isStyleProImageModel(
|
||||||
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
||||||
) {
|
) {
|
||||||
return Boolean(entry.model.supportsImageGen) && !isVideoLikeModel(entry);
|
if (!entry.model.supportsImageGen) return false;
|
||||||
|
if (isExplicitImageGenerationModel(entry)) return true;
|
||||||
|
return !isVideoLikeModel(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStyleProImageModelEntries(options?: {
|
export async function getStyleProImageModelEntries(options?: {
|
||||||
|
|||||||
@ -320,8 +320,11 @@ export const imageGenerationService = {
|
|||||||
}): Promise<
|
}): Promise<
|
||||||
ApiResponse<{
|
ApiResponse<{
|
||||||
annotatedReferenceUrl: string;
|
annotatedReferenceUrl: string;
|
||||||
|
aspectRatio?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
queueStatus?: unknown;
|
queueStatus?: unknown;
|
||||||
|
sourceHeight?: number;
|
||||||
|
sourceWidth?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
}>
|
}>
|
||||||
@ -338,8 +341,11 @@ export const imageGenerationService = {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
annotatedReferenceUrl: data.annotatedReferenceUrl,
|
annotatedReferenceUrl: data.annotatedReferenceUrl,
|
||||||
|
aspectRatio: data.aspectRatio,
|
||||||
prompt: data.prompt,
|
prompt: data.prompt,
|
||||||
queueStatus: data.queueStatus,
|
queueStatus: data.queueStatus,
|
||||||
|
sourceHeight: data.sourceHeight,
|
||||||
|
sourceWidth: data.sourceWidth,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
taskId: data.taskId,
|
taskId: data.taskId,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user