fix: improve canvas local edit output
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-21 10:25:19 +08:00
parent ce283d287f
commit 61da591a5c
10 changed files with 731 additions and 202 deletions

View File

@ -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,

View File

@ -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: [],

View File

@ -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>
); );
} }

View File

@ -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:

View File

@ -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"

View File

@ -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}
/> />

View File

@ -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>

View File

@ -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",

View File

@ -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?: {

View File

@ -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,
}, },