From 61da591a5c6cffb831a5d772a1a31f6dea9c3dfd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 21 May 2026 10:25:19 +0800 Subject: [PATCH] fix: improve canvas local edit output --- app/api/canvas/local-edit/route.ts | 87 +++- .../canvas/LiblibVideoCanvasReplica.tsx | 21 +- components/canvas/model3d/Model3DViewer.tsx | 381 ++++++++++++++---- components/canvas/nodes/ImageNode.tsx | 35 +- components/canvas/nodes/World3DNode.tsx | 105 +++-- components/canvas/world3d/World3DViewer.tsx | 126 +++++- components/style-pro/StyleProWorkspace.tsx | 118 ++++-- lib/canvas-toolbar-config.ts | 4 +- lib/style-pro/model-options.ts | 50 ++- services/canvas/index.ts | 6 + 10 files changed, 731 insertions(+), 202 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index d2c70259e..03346e427 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -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, diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index b3e0fcc10..b04e9c241 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -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: [], diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index 43c6652b8..871005c37 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -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; onLayersChange?: (layers: Model3DLayer[]) => void; onStatsChange?: (stats: Model3DStats) => void; @@ -64,6 +65,34 @@ type Model3DViewerProps = { onCaptureReady?: (capture: (() => Promise) | null) => void; }; +type MovementCommand = "forward" | "backward" | "left" | "right" | "up" | "down"; + +type MovementState = Record; + +const EMPTY_MOVEMENT_STATE: MovementState = { + forward: false, + backward: false, + left: false, + right: false, + up: false, + down: false, +}; + +const KEY_TO_MOVEMENT: Partial> = { + 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; + orbitControlsRef: React.MutableRefObject; +}) { + 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; +}) { + 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) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture?.(event.pointerId); + setPressed(command, true); + }, + onPointerUp: (event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + setPressed(command, false); + }, + onPointerCancel: (event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearMovement(); + }, + onPointerLeave: (event: React.PointerEvent) => { + event.stopPropagation(); + setPressed(command, false); + }, + type: "button" as const, + }); + + return ( +
event.preventDefault()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > +
+ + + + + + +
+
+ + +
+
+ ); +} + 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(null); + const movementStateRef = useRef(createMovementState()); const resolvedModelUrl = useMemo(() => { return buildThreeAssetUrl(modelUrl); }, [modelUrl]); @@ -414,93 +613,101 @@ export function Model3DViewer({ } > - { - gl.outputColorSpace = THREE.SRGBColorSpace; - gl.toneMapping = THREE.ACESFilmicToneMapping; - gl.toneMappingExposure = 1; - }} - > - - - - - - {settings.showGrid ? ( - - ) : null} - - - - - - } - > - - - - - - - + - + 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; + }} + > + + + + + + + {settings.showGrid ? ( + + ) : null} + + + + + + } + > + + + + + + + + + {showMovementControls ? : null} + ); } diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index bcb1e7a7a..2354958fe 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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: diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 204ecd0cc..40b4b97ba 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -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) + } /> @@ -1041,6 +1041,25 @@ export function World3DNode({ ) : null} + + {modelDownloadTarget ? ( +
+ +
+ ) : null} ) : null} @@ -1085,14 +1104,16 @@ export function World3DNode({ {world3dOpenImageItem.label} ) : null} - - - {world3dDownloadItem.label} - + {modelDownloadTarget ? ( + + + {world3dDownloadItem.label} + + ) : null} Promise) | 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; orbitControlsRef: React.MutableRefObject; }) { 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; +}) { + 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) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture?.(event.pointerId); + setPressed(command, true); + }, + onPointerUp: (event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + setPressed(command, false); + }, + onPointerCancel: (event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearMovement(); + }, + onPointerLeave: (event: React.PointerEvent) => { + event.stopPropagation(); + setPressed(command, false); + }, + type: "button" as const, + }); + + return ( +
event.preventDefault()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > +
+ + + + + + +
+
+ + +
+
+ ); +} + 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; + movementEnabled?: boolean; }) { const camera = useThree((state) => state.camera); const renderer = useThree((state) => state.gl); @@ -1263,8 +1343,9 @@ function WorldSplatScene({ /> ))} Promise) | null) => void; showInfoOverlay?: boolean; + showMovementControls?: boolean; }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [overlayPhase, setOverlayPhase] = useState("loading"); const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]); const loadStartedAtRef = useRef(0); + const movementStateRef = useRef(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({ + {showMovementControls && loadState === "ready" ? ( + + ) : null} {overlayPhase !== "hidden" ? ( ) : 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} /> ); @@ -1625,6 +1722,7 @@ export function World3DViewer({ resetViewToken={resetViewToken} semanticsMetadata={semanticsMetadata} showInfoOverlay={!minimalFrame} + showMovementControls={showMovementControls} splatUrl={splatUrl} worldUrl={worldUrl} /> diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx index a4d1988eb..a783216de 100644 --- a/components/style-pro/StyleProWorkspace.tsx +++ b/components/style-pro/StyleProWorkspace.tsx @@ -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 ( {alt} @@ -889,6 +901,7 @@ function BeforeAfterSlider({
@@ -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" ? (
模型 - + {modelOptions.length > 0 ? ( + + ) : ( + + {modelsLoading ? "加载模型" : "暂无模型"} + + )} {currentCostLabel} diff --git a/lib/canvas-toolbar-config.ts b/lib/canvas-toolbar-config.ts index 6792c013a..3e18feffb 100644 --- a/lib/canvas-toolbar-config.ts +++ b/lib/canvas-toolbar-config.ts @@ -502,8 +502,8 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [ { key: "world3dDownload", group: "节点工具栏", - label: "下载当前资产", - description: "下载 3D 世界当前资产", + label: "下载模型", + description: "下载 3D 世界 GLB 模型资源", }, { key: "world3dCopy", diff --git a/lib/style-pro/model-options.ts b/lib/style-pro/model-options.ts index 7de9bedea..ddb1cffd9 100644 --- a/lib/style-pro/model-options.ts +++ b/lib/style-pro/model-options.ts @@ -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; @@ -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?: { diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 48754280a..6686ee73e 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -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, },