fix: improve canvas local edit output
This commit is contained in:
parent
ce283d287f
commit
61da591a5c
@ -86,11 +86,64 @@ const DEFAULT_REGION_COLORS = [
|
||||
"#ff2d55",
|
||||
"#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) {
|
||||
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) {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
@ -331,7 +384,7 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
||||
region.referenceImages.length > 0
|
||||
? `;该区域有 ${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");
|
||||
const referenceLines = regions
|
||||
@ -345,17 +398,17 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
||||
|
||||
return [
|
||||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
||||
"输入图片2是同一张图的彩色标注说明图,只用于理解编辑区域位置和区域提示词,不是画面参考。",
|
||||
"输入图片3及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
|
||||
"请不要复刻、保留、描摹或生成输入图片2里的任何彩色框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
|
||||
"如果输入图片2的标注元素与输入图片1冲突,必须完全忽略标注元素,只保留输入图片1的真实画面内容。",
|
||||
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
|
||||
"输入图片2及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
|
||||
"最终结果必须保持输入图片1的原始画幅比例和构图边界,不要把画面压扁、拉伸、加白边或改成方图。",
|
||||
"请不要凭空生成任何彩色框、蓝框、绿框、红框、矩形边框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
|
||||
"",
|
||||
"请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:",
|
||||
"请严格根据每个区域下方的提示词,只在对应坐标区域附近完成局部编辑:",
|
||||
regionLines,
|
||||
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
|
||||
"",
|
||||
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
|
||||
"未被彩色框标注的区域必须尽量保持原图不变。",
|
||||
"未被列为编辑区域的画面必须尽量保持原图不变。",
|
||||
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
|
||||
"如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。",
|
||||
].join("\n");
|
||||
@ -434,19 +487,25 @@ export async function POST(request: NextRequest) {
|
||||
const regionReferenceImageUrls = regions.flatMap((region) =>
|
||||
region.referenceImages.map((reference) => reference.url)
|
||||
);
|
||||
const resolvedAspectRatio = resolveLocalEditAspectRatio(
|
||||
body.aspectRatio,
|
||||
sourceWidth,
|
||||
sourceHeight
|
||||
);
|
||||
|
||||
const imageRequest: CanvasGenerateImageRequest = {
|
||||
aspectRatio:
|
||||
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
|
||||
? body.aspectRatio.trim()
|
||||
: "auto",
|
||||
aspectRatio: resolvedAspectRatio,
|
||||
billingFeature: "repaint",
|
||||
debugLabel: "canvas-local-edit",
|
||||
debugMeta: {
|
||||
annotatedReferenceUrl: annotatedUpload.url,
|
||||
annotatedReferenceSentToModel: false,
|
||||
resolvedAspectRatio,
|
||||
regionReferenceImageCount: regionReferenceImageUrls.length,
|
||||
regionCount: regions.length,
|
||||
sourceHeight,
|
||||
sourceNodeId: body.sourceNodeId || null,
|
||||
sourceWidth,
|
||||
},
|
||||
imageCount: 1,
|
||||
imageSize:
|
||||
@ -458,12 +517,11 @@ export async function POST(request: NextRequest) {
|
||||
? body.model.trim()
|
||||
: undefined,
|
||||
negativePrompt:
|
||||
"彩色框、蓝色边框、绿色边框、红色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框",
|
||||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImages: [
|
||||
sourceImageUrl,
|
||||
annotatedUpload.url,
|
||||
...regionReferenceImageUrls,
|
||||
],
|
||||
ownerKey: session.user.id,
|
||||
@ -492,8 +550,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
annotatedReferenceUrl: annotatedUpload.url,
|
||||
aspectRatio: resolvedAspectRatio,
|
||||
prompt,
|
||||
regions,
|
||||
sourceHeight,
|
||||
sourceWidth,
|
||||
taskId: task.id,
|
||||
status: currentTask?.status || task.status,
|
||||
queueStatus: currentTask?.queueStatus,
|
||||
|
||||
@ -4236,6 +4236,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
generated: true,
|
||||
generationProgress: 100,
|
||||
generationStatus: "complete",
|
||||
localEditMode: false,
|
||||
localEditRegions: [],
|
||||
mediaUrl: generatedUrl,
|
||||
modelRef: task.result?.modelRef,
|
||||
params: {
|
||||
@ -4348,8 +4350,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
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 resultHeight = anchor.height;
|
||||
const resultHeight =
|
||||
sourceRatio > 0
|
||||
? Math.max(120, Math.round(resultWidth / sourceRatio))
|
||||
: anchor.height;
|
||||
const resultNode = createMediaResourceNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: resultHeight,
|
||||
@ -4368,26 +4377,34 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
...(resultNode.draftValues || {}),
|
||||
action: "IMAGE_RESOURCE",
|
||||
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
|
||||
aspectRatio: response.data.aspectRatio,
|
||||
functionKey: "local-edit",
|
||||
generated: false,
|
||||
generationProgress: 6,
|
||||
generationStatus: "pending",
|
||||
localEditRegions: validRegions,
|
||||
localEditMode: false,
|
||||
localEditRegions: [],
|
||||
mediaType: "image",
|
||||
mediaUrl: "",
|
||||
nodeType: "image",
|
||||
params: {
|
||||
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
|
||||
aspectRatio: response.data.aspectRatio,
|
||||
mode: "local-edit",
|
||||
prompt: response.data.prompt,
|
||||
regions: validRegions,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceHeight: response.data.sourceHeight,
|
||||
sourceUrl,
|
||||
sourceWidth: response.data.sourceWidth,
|
||||
taskId: response.data.taskId,
|
||||
},
|
||||
pendingLabel: "局部编辑中",
|
||||
prompt: response.data.prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceLocalEditRegions: validRegions,
|
||||
sourceNaturalHeight: response.data.sourceHeight,
|
||||
sourceNaturalWidth: response.data.sourceWidth,
|
||||
sourceUrl,
|
||||
toolbarOption: "局部编辑",
|
||||
url: [],
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
OrbitControls,
|
||||
useGLTF,
|
||||
} 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 React, { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
@ -57,6 +57,7 @@ type Model3DViewerProps = {
|
||||
emptyStateTitle?: string;
|
||||
settings: Model3DViewerSettings;
|
||||
modelRotation?: [number, number, number];
|
||||
showMovementControls?: boolean;
|
||||
layerVisibility?: Record<string, boolean>;
|
||||
onLayersChange?: (layers: Model3DLayer[]) => void;
|
||||
onStatsChange?: (stats: Model3DStats) => void;
|
||||
@ -64,6 +65,34 @@ type Model3DViewerProps = {
|
||||
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<
|
||||
Model3DCameraPreset,
|
||||
[number, number, number]
|
||||
@ -141,6 +170,19 @@ function isMesh(node: Object3D): node is Mesh {
|
||||
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): {
|
||||
layers: Model3DLayer[];
|
||||
stats: Model3DStats;
|
||||
@ -316,6 +358,161 @@ function ViewerCaptureBridge({
|
||||
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) {
|
||||
const value = String(url || "").toLowerCase();
|
||||
return /\.(gltf|glb)(\?|#|$)/.test(value);
|
||||
@ -359,6 +556,7 @@ export function Model3DViewer({
|
||||
emptyStateTitle = "准备生成 3D 资产",
|
||||
settings,
|
||||
modelRotation,
|
||||
showMovementControls = false,
|
||||
layerVisibility,
|
||||
onLayersChange,
|
||||
onStatsChange,
|
||||
@ -366,6 +564,7 @@ export function Model3DViewer({
|
||||
onCaptureReady,
|
||||
}: Model3DViewerProps) {
|
||||
const controlsRef = useRef<any>(null);
|
||||
const movementStateRef = useRef<MovementState>(createMovementState());
|
||||
const resolvedModelUrl = useMemo(() => {
|
||||
return buildThreeAssetUrl(modelUrl);
|
||||
}, [modelUrl]);
|
||||
@ -414,93 +613,101 @@ export function Model3DViewer({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Canvas
|
||||
camera={{
|
||||
position:
|
||||
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
|
||||
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,
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<Canvas
|
||||
camera={{
|
||||
position:
|
||||
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
|
||||
CAMERA_PRESET_POSITIONS.iso,
|
||||
fov: 45,
|
||||
}}
|
||||
minPolarAngle={0}
|
||||
ref={controlsRef}
|
||||
/>
|
||||
</Canvas>
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3813,7 +3813,8 @@ export function ImageNode({
|
||||
metadata: {
|
||||
editMode: "local-edit",
|
||||
sourceNodeId: node.id,
|
||||
localEditRegions: validRegions,
|
||||
localEditMode: false,
|
||||
sourceLocalEditRegions: validRegions,
|
||||
},
|
||||
},
|
||||
connections: [node.id],
|
||||
@ -3844,7 +3845,21 @@ export function ImageNode({
|
||||
}
|
||||
|
||||
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, {
|
||||
size: localEditResultSize,
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
@ -3854,9 +3869,13 @@ export function ImageNode({
|
||||
metadata: {
|
||||
editMode: "local-edit",
|
||||
sourceNodeId: node.id,
|
||||
aspectRatio: submitResult.data.aspectRatio,
|
||||
taskId,
|
||||
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: {
|
||||
editMode: "local-edit",
|
||||
sourceNodeId: node.id,
|
||||
aspectRatio: submitResult.data.aspectRatio,
|
||||
taskId,
|
||||
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",
|
||||
sourceNodeId: node.id,
|
||||
error: message,
|
||||
localEditRegions: validRegions,
|
||||
localEditMode: false,
|
||||
sourceLocalEditRegions: validRegions,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -6671,7 +6696,7 @@ export function ImageNode({
|
||||
prompt: params.promptText,
|
||||
modelVersion: params.modelVersion,
|
||||
status: "completed",
|
||||
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image",
|
||||
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
|
||||
firstPerson: false,
|
||||
hotspots: [],
|
||||
semanticsMetadata:
|
||||
|
||||
@ -66,6 +66,7 @@ const VIEW_META: Array<{
|
||||
toolbarKey: CanvasToolbarItemKey;
|
||||
}> = [
|
||||
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
|
||||
{ id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" },
|
||||
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
|
||||
];
|
||||
|
||||
@ -76,52 +77,40 @@ const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360;
|
||||
|
||||
function resolveAvailableViews(data: World3DNodeData) {
|
||||
const views: World3DViewMode[] = [];
|
||||
const hasSplat = isArchivedWorldLabsAssetUrl(data.splatUrl);
|
||||
const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl);
|
||||
|
||||
if (data.imageUrl || data.previewImage) views.push("image");
|
||||
if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh");
|
||||
if (views.length === 0 && isArchivedWorldLabsAssetUrl(data.splatUrl)) {
|
||||
if (hasSplat) {
|
||||
views.push("splat");
|
||||
} else if (hasMesh) {
|
||||
views.push("mesh");
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
function resolveActiveView(data: World3DNodeData) {
|
||||
const availableViews = resolveAvailableViews(data);
|
||||
const hasSplat = availableViews.includes("splat");
|
||||
if (
|
||||
data.activeView &&
|
||||
data.activeView !== "splat" &&
|
||||
availableViews.includes(data.activeView)
|
||||
availableViews.includes(data.activeView) &&
|
||||
!(data.activeView === "mesh" && hasSplat)
|
||||
) {
|
||||
return data.activeView;
|
||||
}
|
||||
if (hasSplat) {
|
||||
return "splat";
|
||||
}
|
||||
if (availableViews.includes("mesh")) {
|
||||
return "mesh";
|
||||
}
|
||||
return availableViews[0] || "image";
|
||||
}
|
||||
|
||||
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
|
||||
const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null;
|
||||
function inferModelDownloadTarget(data: World3DNodeData) {
|
||||
const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : 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;
|
||||
return meshUrl ? { url: meshUrl, filename: "world.glb" } : null;
|
||||
}
|
||||
|
||||
function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
|
||||
@ -261,7 +250,7 @@ export function World3DNode({
|
||||
[data.modelVersion, publicModelAliasConfig.worldModels],
|
||||
);
|
||||
const primaryUrl = resolvePrimaryUrl(data, activeView);
|
||||
const downloadTarget = inferDownloadTarget(data, activeView);
|
||||
const modelDownloadTarget = inferModelDownloadTarget(data);
|
||||
const hotspotCount = data.hotspots?.length || 0;
|
||||
const placeholderSizeScale = useMemo(
|
||||
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
|
||||
@ -525,7 +514,7 @@ export function World3DNode({
|
||||
prompt: promptText,
|
||||
modelVersion,
|
||||
status: "completed",
|
||||
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image",
|
||||
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
|
||||
firstPerson: data.firstPerson ?? false,
|
||||
hotspots: data.hotspots || [],
|
||||
semanticsMetadata:
|
||||
@ -670,7 +659,14 @@ export function World3DNode({
|
||||
splatUrl: splatUrl || renderableSplatUrl,
|
||||
previewImage: previewImageUrl || imageUrl || data.previewImage,
|
||||
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,
|
||||
semanticsMetadata:
|
||||
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
|
||||
@ -783,15 +779,15 @@ export function World3DNode({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadTarget) {
|
||||
toast.error("暂无可下载的世界资产");
|
||||
const handleDownloadModel = () => {
|
||||
if (!modelDownloadTarget) {
|
||||
toast.error("暂无可下载的 GLB 模型");
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadTarget.url;
|
||||
link.download = downloadTarget.filename;
|
||||
link.href = modelDownloadTarget.url;
|
||||
link.download = modelDownloadTarget.filename;
|
||||
link.click();
|
||||
};
|
||||
|
||||
@ -952,6 +948,10 @@ export function World3DNode({
|
||||
splatUrl={renderableSplatUrl}
|
||||
worldUrl={data.worldUrl}
|
||||
onCaptureReady={handleViewerCaptureReady}
|
||||
showMovementControls={
|
||||
(activeView === "splat" || activeView === "mesh") &&
|
||||
(isSelected || isHovered)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1041,6 +1041,25 @@ export function World3DNode({
|
||||
</Button>
|
||||
</div>
|
||||
) : 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>
|
||||
) : null}
|
||||
|
||||
@ -1085,14 +1104,16 @@ export function World3DNode({
|
||||
{world3dOpenImageItem.label}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Download}
|
||||
item={world3dDownloadItem}
|
||||
/>
|
||||
{world3dDownloadItem.label}
|
||||
</DropdownMenuItem>
|
||||
{modelDownloadTarget ? (
|
||||
<DropdownMenuItem onClick={handleDownloadModel}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Download}
|
||||
item={world3dDownloadItem}
|
||||
/>
|
||||
{world3dDownloadItem.label}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
|
||||
@ -82,6 +82,7 @@ type World3DViewerProps = {
|
||||
className?: string;
|
||||
resetViewToken?: number;
|
||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||
showMovementControls?: boolean;
|
||||
};
|
||||
|
||||
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_SPLAT_SCALE = 0.78;
|
||||
const SPARK_INLINE_LOD_RENDER_SCALE = 1.8;
|
||||
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.6;
|
||||
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 2.4;
|
||||
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.82;
|
||||
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 1.6;
|
||||
const SPARK_MIN_SORT_INTERVAL_MS = 24;
|
||||
const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40;
|
||||
|
||||
@ -744,10 +745,12 @@ function createMovementState(): MovementState {
|
||||
function CameraMovementControls({
|
||||
enabled,
|
||||
firstPerson,
|
||||
movementStateRef,
|
||||
orbitControlsRef,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
firstPerson: boolean;
|
||||
movementStateRef?: React.MutableRefObject<MovementState>;
|
||||
orbitControlsRef: React.MutableRefObject<any>;
|
||||
}) {
|
||||
const { camera } = useThree();
|
||||
@ -760,6 +763,9 @@ function CameraMovementControls({
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
pressedKeysRef.current = createMovementState();
|
||||
if (movementStateRef) {
|
||||
movementStateRef.current = createMovementState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -799,16 +805,19 @@ function CameraMovementControls({
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("focusin", handleFocusIn);
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, movementStateRef]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!enabled) return;
|
||||
|
||||
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;
|
||||
|
||||
const moveSpeed = 3.8;
|
||||
const moveSpeed = firstPerson ? 3.8 : 2.8;
|
||||
const distance = moveSpeed * delta;
|
||||
|
||||
camera.getWorldDirection(directionRef.current);
|
||||
@ -818,22 +827,22 @@ function CameraMovementControls({
|
||||
|
||||
moveRef.current.set(0, 0, 0);
|
||||
|
||||
if (pressed.forward) {
|
||||
if (pressed.forward || padPressed?.forward) {
|
||||
moveRef.current.add(directionRef.current);
|
||||
}
|
||||
if (pressed.backward) {
|
||||
if (pressed.backward || padPressed?.backward) {
|
||||
moveRef.current.addScaledVector(directionRef.current, -1);
|
||||
}
|
||||
if (pressed.left) {
|
||||
if (pressed.left || padPressed?.left) {
|
||||
moveRef.current.addScaledVector(rightRef.current, -1);
|
||||
}
|
||||
if (pressed.right) {
|
||||
if (pressed.right || padPressed?.right) {
|
||||
moveRef.current.add(rightRef.current);
|
||||
}
|
||||
if (pressed.up) {
|
||||
if (pressed.up || padPressed?.up) {
|
||||
moveRef.current.add(upRef.current);
|
||||
}
|
||||
if (pressed.down) {
|
||||
if (pressed.down || padPressed?.down) {
|
||||
moveRef.current.addScaledVector(upRef.current, -1);
|
||||
}
|
||||
|
||||
@ -851,6 +860,73 @@ function CameraMovementControls({
|
||||
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({
|
||||
enabled,
|
||||
resetToken,
|
||||
@ -983,6 +1059,8 @@ function WorldSplatScene({
|
||||
onRevealComplete,
|
||||
resetViewToken,
|
||||
showInfoOverlay = true,
|
||||
movementStateRef,
|
||||
movementEnabled = false,
|
||||
}: {
|
||||
splatUrl: string;
|
||||
revealPhase?: SplatLoadingPhase;
|
||||
@ -997,6 +1075,8 @@ function WorldSplatScene({
|
||||
onRevealComplete?: () => void;
|
||||
resetViewToken?: number;
|
||||
showInfoOverlay?: boolean;
|
||||
movementStateRef?: React.MutableRefObject<MovementState>;
|
||||
movementEnabled?: boolean;
|
||||
}) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const renderer = useThree((state) => state.gl);
|
||||
@ -1263,8 +1343,9 @@ function WorldSplatScene({
|
||||
/>
|
||||
))}
|
||||
<CameraMovementControls
|
||||
enabled={interactionMode === "navigate" && firstPerson}
|
||||
enabled={movementEnabled && interactionMode === "navigate"}
|
||||
firstPerson={firstPerson}
|
||||
movementStateRef={movementStateRef}
|
||||
orbitControlsRef={orbitControlsRef}
|
||||
/>
|
||||
<FirstPersonLookControls
|
||||
@ -1392,6 +1473,7 @@ function WorldSplatViewer({
|
||||
minimalFrame = false,
|
||||
onCaptureReady,
|
||||
showInfoOverlay = true,
|
||||
showMovementControls = false,
|
||||
}: {
|
||||
splatUrl?: string | null;
|
||||
worldUrl?: string | null;
|
||||
@ -1406,11 +1488,13 @@ function WorldSplatViewer({
|
||||
resetViewToken?: number;
|
||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||
showInfoOverlay?: boolean;
|
||||
showMovementControls?: boolean;
|
||||
}) {
|
||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
|
||||
const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
|
||||
const loadStartedAtRef = useRef(0);
|
||||
const movementStateRef = useRef<MovementState>(createMovementState());
|
||||
|
||||
useEffect(() => {
|
||||
loadStartedAtRef.current = performance.now();
|
||||
@ -1418,6 +1502,12 @@ function WorldSplatViewer({
|
||||
setOverlayPhase("loading");
|
||||
}, [resolvedSplatUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMovementControls) {
|
||||
movementStateRef.current = createMovementState();
|
||||
}
|
||||
}, [showMovementControls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadState === "ready") {
|
||||
const elapsed = performance.now() - loadStartedAtRef.current;
|
||||
@ -1472,10 +1562,10 @@ function WorldSplatViewer({
|
||||
<Canvas
|
||||
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%)]"
|
||||
dpr={minimalFrame ? [0.85, 1] : [1, 1]}
|
||||
dpr={minimalFrame ? [1, 1.5] : [1, 2]}
|
||||
frameloop="always"
|
||||
gl={{
|
||||
antialias: false,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
preserveDrawingBuffer: Boolean(onCaptureReady),
|
||||
}}
|
||||
@ -1495,8 +1585,13 @@ function WorldSplatViewer({
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
showInfoOverlay={showInfoOverlay}
|
||||
splatUrl={resolvedSplatUrl}
|
||||
movementStateRef={movementStateRef}
|
||||
movementEnabled={showMovementControls}
|
||||
/>
|
||||
</Canvas>
|
||||
{showMovementControls && loadState === "ready" ? (
|
||||
<WorldMovementPad movementStateRef={movementStateRef} />
|
||||
) : null}
|
||||
{overlayPhase !== "hidden" ? (
|
||||
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
|
||||
) : null}
|
||||
@ -1526,6 +1621,7 @@ export function World3DViewer({
|
||||
className,
|
||||
resetViewToken,
|
||||
onCaptureReady,
|
||||
showMovementControls = false,
|
||||
}: World3DViewerProps) {
|
||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||
|
||||
@ -1548,6 +1644,7 @@ export function World3DViewer({
|
||||
previewImageUrl={previewImageUrl}
|
||||
resetViewToken={resetViewToken}
|
||||
settings={meshSettings || DEFAULT_MESH_SETTINGS}
|
||||
showMovementControls={showMovementControls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -1625,6 +1722,7 @@ export function World3DViewer({
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
showInfoOverlay={!minimalFrame}
|
||||
showMovementControls={showMovementControls}
|
||||
splatUrl={splatUrl}
|
||||
worldUrl={worldUrl}
|
||||
/>
|
||||
|
||||
@ -22,6 +22,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import type {
|
||||
@ -142,7 +148,7 @@ type StyleProWorkspaceProps = {
|
||||
|
||||
const STYLE_PRO_HISTORY_LIMIT = 4;
|
||||
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 = {
|
||||
taskId: string;
|
||||
@ -645,10 +651,12 @@ function StyleProAccountActions() {
|
||||
function ImagePreview({
|
||||
alt,
|
||||
className,
|
||||
fit = "contain",
|
||||
imageUrl,
|
||||
}: {
|
||||
alt: string;
|
||||
className?: string;
|
||||
fit?: "contain" | "cover";
|
||||
imageUrl?: string | null;
|
||||
}) {
|
||||
if (!imageUrl) {
|
||||
@ -667,7 +675,11 @@ function ImagePreview({
|
||||
return (
|
||||
<img
|
||||
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}
|
||||
src={imageUrl}
|
||||
/>
|
||||
@ -889,6 +901,7 @@ function BeforeAfterSlider({
|
||||
<ImagePreview
|
||||
alt="生成后"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
fit="cover"
|
||||
imageUrl={afterUrl}
|
||||
/>
|
||||
<div
|
||||
@ -898,6 +911,7 @@ function BeforeAfterSlider({
|
||||
<ImagePreview
|
||||
alt="生成前"
|
||||
className="h-full w-full"
|
||||
fit="cover"
|
||||
imageUrl={beforeUrl}
|
||||
/>
|
||||
</div>
|
||||
@ -1371,7 +1385,15 @@ export function StyleProWorkspace({
|
||||
].slice(0, 4);
|
||||
setHistory(nextHistory);
|
||||
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);
|
||||
toast.success("风格化 PRO 生成完成");
|
||||
} catch (error) {
|
||||
@ -1711,38 +1733,64 @@ export function StyleProWorkspace({
|
||||
{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">
|
||||
<span className="text-black/45">模型</span>
|
||||
<select
|
||||
aria-label="选择生图模型"
|
||||
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
|
||||
disabled={
|
||||
modelsLoading ||
|
||||
selectableModelOptions.length === 0 ||
|
||||
generating
|
||||
}
|
||||
onChange={(event) =>
|
||||
setSelectedModelId(event.target.value)
|
||||
}
|
||||
value={selectedModel?.id || selectedModelId}
|
||||
>
|
||||
{modelOptions.length > 0 ? (
|
||||
modelOptions.map((model) => (
|
||||
<option
|
||||
disabled={model.selectable === false}
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
>
|
||||
{model.displayName}
|
||||
{model.availabilityLabel
|
||||
? `(${model.availabilityLabel})`
|
||||
: ""}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">
|
||||
{modelsLoading ? "加载模型" : "暂无模型"}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
{modelOptions.length > 0 ? (
|
||||
<Select
|
||||
disabled={
|
||||
modelsLoading ||
|
||||
selectableModelOptions.length === 0 ||
|
||||
generating
|
||||
}
|
||||
onValueChange={setSelectedModelId}
|
||||
value={selectedModel?.id || selectedModelId}
|
||||
>
|
||||
<SelectTrigger
|
||||
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]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selectedModel?.displayName || "选择模型"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<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) => (
|
||||
<SelectItem
|
||||
className="min-h-10 rounded-xl py-2 pl-8 pr-3 text-[13px] focus:bg-black/[0.04]"
|
||||
disabled={model.selectable === false}
|
||||
key={model.id}
|
||||
title={model.disabledReason || model.displayName}
|
||||
value={model.id}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
{model.avatarUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
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">
|
||||
{currentCostLabel}
|
||||
</span>
|
||||
|
||||
@ -502,8 +502,8 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
||||
{
|
||||
key: "world3dDownload",
|
||||
group: "节点工具栏",
|
||||
label: "下载当前资产",
|
||||
description: "下载 3D 世界当前资产",
|
||||
label: "下载模型",
|
||||
description: "下载 3D 世界 GLB 模型资源",
|
||||
},
|
||||
{
|
||||
key: "world3dCopy",
|
||||
|
||||
@ -66,6 +66,21 @@ const VIDEO_MODEL_KEYWORDS = [
|
||||
"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) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const config = value as Record<string, unknown>;
|
||||
@ -81,7 +96,36 @@ function hasVideoGenerationConfig(value: unknown) {
|
||||
function hasVideoCapabilities(value: unknown) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
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(
|
||||
@ -111,7 +155,9 @@ function isVideoLikeModel(
|
||||
export function isStyleProImageModel(
|
||||
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?: {
|
||||
|
||||
@ -320,8 +320,11 @@ export const imageGenerationService = {
|
||||
}): Promise<
|
||||
ApiResponse<{
|
||||
annotatedReferenceUrl: string;
|
||||
aspectRatio?: string;
|
||||
prompt: string;
|
||||
queueStatus?: unknown;
|
||||
sourceHeight?: number;
|
||||
sourceWidth?: number;
|
||||
status?: string;
|
||||
taskId: string;
|
||||
}>
|
||||
@ -338,8 +341,11 @@ export const imageGenerationService = {
|
||||
success: true,
|
||||
data: {
|
||||
annotatedReferenceUrl: data.annotatedReferenceUrl,
|
||||
aspectRatio: data.aspectRatio,
|
||||
prompt: data.prompt,
|
||||
queueStatus: data.queueStatus,
|
||||
sourceHeight: data.sourceHeight,
|
||||
sourceWidth: data.sourceWidth,
|
||||
status: data.status,
|
||||
taskId: data.taskId,
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user