diff --git a/components/angle-edit-dialog.tsx b/components/angle-edit-dialog.tsx index 91cde9112..78d8abae8 100644 --- a/components/angle-edit-dialog.tsx +++ b/components/angle-edit-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { Dialog, DialogContent, @@ -13,7 +13,7 @@ import { Slider } from "@/components/ui/slider"; import { Label } from "@/components/ui/label"; import { Loader2, Download, Check, RotateCcw, Move3D, Sparkles } from "lucide-react"; import { toast } from "sonner"; -import { AngleController3D } from "./angle-controller-3d"; +import { QwenCameraWidget, type CameraState } from "./qwen-camera-widget"; import { cn } from "@/lib/utils"; interface AngleEditDialogProps { @@ -23,37 +23,58 @@ interface AngleEditDialogProps { onComplete?: (resultUrl: string) => void; } -interface AngleParams { - azimuth: string; - elevation: string; - distance: string; - azimuthDegree: number; - elevationDegree: number; - distanceFactor: number; +// 根据角度值生成 API 所需的字符串参数 +function getAzimuthString(azimuth: number): string { + const hAngle = azimuth % 360; + if (hAngle < 22.5 || hAngle >= 337.5) return "front"; + if (hAngle < 67.5) return "front-right"; + if (hAngle < 112.5) return "right"; + if (hAngle < 157.5) return "back-right"; + if (hAngle < 202.5) return "back"; + if (hAngle < 247.5) return "back-left"; + if (hAngle < 292.5) return "left"; + return "front-left"; +} + +function getElevationString(elevation: number): string { + if (elevation < -15) return "low"; + if (elevation < 15) return "eye-level"; + if (elevation < 45) return "elevated"; + return "high"; +} + +function getDistanceString(distance: number): string { + if (distance < 3) return "wide"; + if (distance < 7) return "medium"; + return "close"; } export function AngleEditDialog({ open, onOpenChange, imageUrl, - onComplete, }: AngleEditDialogProps) { const [isProcessing, setIsProcessing] = useState(false); const [resultUrl, setResultUrl] = useState(null); const [loraScale, setLoraScale] = useState(0.9); - const [currentAngles, setCurrentAngles] = useState({ - azimuth: "front-right", - elevation: "eye-level", - distance: "medium", - azimuthDegree: 45, - elevationDegree: 0, - distanceFactor: 1.0, + const [cameraState, setCameraState] = useState({ + azimuth: 45, + elevation: 0, + distance: 5, }); - // 处理角度变化 - const handleAngleChange = useCallback((angles: AngleParams) => { - setCurrentAngles(angles); - // 清除之前的结果,因为角度变了 + // 当对话框关闭时重置状态 + useEffect(() => { + if (!open) { + setResultUrl(null); + setIsProcessing(false); + } + }, [open]); + + // 处理相机状态变化 + const handleCameraStateChange = useCallback((state: CameraState) => { + setCameraState(state); + // 角度变化时清除之前的结果 setResultUrl(null); }, []); @@ -68,9 +89,9 @@ export function AngleEditDialog({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image_url: imageUrl, - azimuth: currentAngles.azimuth, - elevation: currentAngles.elevation, - distance: currentAngles.distance, + azimuth: getAzimuthString(cameraState.azimuth), + elevation: getElevationString(cameraState.elevation), + distance: getDistanceString(cameraState.distance), lora_scale: loraScale, }), }); @@ -89,7 +110,7 @@ export function AngleEditDialog({ } finally { setIsProcessing(false); } - }, [imageUrl, currentAngles, loraScale]); + }, [imageUrl, cameraState, loraScale]); // 下载结果图片 const handleDownload = useCallback(async () => { @@ -101,7 +122,7 @@ export function AngleEditDialog({ const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `angle-edit-${currentAngles.azimuth}-${currentAngles.elevation}-${Date.now()}.png`; + a.download = `angle-edit-${cameraState.azimuth}-${cameraState.elevation}-${Date.now()}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -110,33 +131,31 @@ export function AngleEditDialog({ } catch { toast.error("下载失败"); } - }, [resultUrl, currentAngles]); + }, [resultUrl, cameraState]); - // 使用此图片 + // 使用此图片(直接发送到聊天) const handleUseImage = useCallback(() => { if (resultUrl) { - onComplete?.(resultUrl); + // 关闭对话框,图片已经通过 API 保存到数据库 onOpenChange(false); + toast.success("图片已添加到对话"); } - }, [resultUrl, onComplete, onOpenChange]); + }, [resultUrl, onOpenChange]); // 重置 const handleReset = useCallback(() => { setResultUrl(null); - setCurrentAngles({ - azimuth: "front-right", - elevation: "eye-level", - distance: "medium", - azimuthDegree: 45, - elevationDegree: 0, - distanceFactor: 1.0, + setCameraState({ + azimuth: 45, + elevation: 0, + distance: 5, }); setLoraScale(0.9); }, []); return ( - +
@@ -146,7 +165,7 @@ export function AngleEditDialog({
多角度编辑 - 拖拽 3D 控制器选择相机角度,AI 将生成对应视角的图像 + 拖拽 3D 控制器中的彩色手柄调整相机角度,AI 将生成对应视角的图像
@@ -154,20 +173,20 @@ export function AngleEditDialog({
-
+
{/* 左侧:3D 控制器 (占 5 列) */} -
-
- +
+
- {/* LoRA 强度控制 - 放入卡片 */} -
+ {/* LoRA 强度控制 */} +
@@ -248,7 +267,7 @@ export function AngleEditDialog({

准备就绪

-

调整左侧角度后点击生成

+

拖拽左侧彩色手柄调整角度后点击生成

)}
@@ -309,4 +328,3 @@ export function AngleEditDialog({ } export default AngleEditDialog; - diff --git a/components/qwen-camera-widget.tsx b/components/qwen-camera-widget.tsx new file mode 100644 index 000000000..da878ea76 --- /dev/null +++ b/components/qwen-camera-widget.tsx @@ -0,0 +1,743 @@ +"use client"; + +import { useEffect, useRef, useCallback, useState } from "react"; +import * as THREE from "three"; + +// 相机状态接口 +export interface CameraState { + azimuth: number; // 方位角 0-360 + elevation: number; // 仰角 -30 到 60 + distance: number; // 距离 0-10 +} + +interface QwenCameraWidgetProps { + initialState?: Partial; + imageUrl?: string | null; + onStateChange?: (state: CameraState) => void; + className?: string; +} + +// 下拉选项定义 +const AZIMUTH_OPTIONS = [ + { label: "正前方", value: 0 }, + { label: "右前方", value: 45 }, + { label: "正右方", value: 90 }, + { label: "右后方", value: 135 }, + { label: "正后方", value: 180 }, + { label: "左后方", value: 225 }, + { label: "正左方", value: 270 }, + { label: "左前方", value: 315 }, +]; + +const ELEVATION_OPTIONS = [ + { label: "低角度", value: -30 }, + { label: "平视", value: 0 }, + { label: "微俯视", value: 30 }, + { label: "高角度", value: 60 }, +]; + +const DISTANCE_OPTIONS = [ + { label: "远景", value: 1 }, + { label: "中景", value: 5 }, + { label: "近景", value: 8 }, +]; + +export function QwenCameraWidget({ + initialState, + imageUrl, + onStateChange, + className, +}: QwenCameraWidgetProps) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const rendererRef = useRef(null); + const animationIdRef = useRef(null); + + // 控制对象引用 + const azimuthHandleRef = useRef(null); + const elevationHandleRef = useRef(null); + const distanceHandleRef = useRef(null); + const cameraIndicatorRef = useRef(null); + const azimuthRingRef = useRef(null); + const elevationArcRef = useRef(null); + const imageCardRef = useRef(null); + + // 状态 + const [state, setState] = useState({ + azimuth: initialState?.azimuth ?? 45, + elevation: initialState?.elevation ?? 0, + distance: initialState?.distance ?? 5, + }); + + // 交互状态 + const isDraggingRef = useRef(false); + const dragTargetRef = useRef(null); + const raycasterRef = useRef(new THREE.Raycaster()); + const mouseRef = useRef(new THREE.Vector2()); + + // 常量 + const CENTER = new THREE.Vector3(0, 0.5, 0); + const AZIMUTH_RADIUS = 1.8; + const ELEVATION_RADIUS = 1.4; + const ELEV_ARC_X = -0.8; + + // 生成提示词 + const generatePrompt = useCallback((s: CameraState) => { + const hAngle = s.azimuth % 360; + + let hDirection: string; + if (hAngle < 22.5 || hAngle >= 337.5) hDirection = "front view"; + else if (hAngle < 67.5) hDirection = "front-right quarter view"; + else if (hAngle < 112.5) hDirection = "right side view"; + else if (hAngle < 157.5) hDirection = "back-right quarter view"; + else if (hAngle < 202.5) hDirection = "back view"; + else if (hAngle < 247.5) hDirection = "back-left quarter view"; + else if (hAngle < 292.5) hDirection = "left side view"; + else hDirection = "front-left quarter view"; + + let vDirection: string; + if (s.elevation < -15) vDirection = "low-angle shot"; + else if (s.elevation < 15) vDirection = "eye-level shot"; + else if (s.elevation < 45) vDirection = "elevated shot"; + else vDirection = "high-angle shot"; + + let distance: string; + if (s.distance < 3) distance = "wide shot"; + else if (s.distance < 7) distance = "medium shot"; + else distance = "close-up"; + + return ` ${hDirection} ${vDirection} ${distance}`; + }, []); + + // 更新场景中的对象位置 + const updateVisuals = useCallback((s: CameraState) => { + const azRad = (s.azimuth * Math.PI) / 180; + const elRad = (s.elevation * Math.PI) / 180; + const visualDist = 2.6 - (s.distance / 10) * 2.0; + + // 相机指示器位置 + if (cameraIndicatorRef.current) { + const camX = visualDist * Math.sin(azRad) * Math.cos(elRad); + const camY = CENTER.y + visualDist * Math.sin(elRad); + const camZ = visualDist * Math.cos(azRad) * Math.cos(elRad); + cameraIndicatorRef.current.position.set(camX, camY, camZ); + cameraIndicatorRef.current.lookAt(CENTER); + cameraIndicatorRef.current.rotateX(Math.PI / 2); + } + + // 方位角手柄位置 + if (azimuthHandleRef.current) { + const azX = AZIMUTH_RADIUS * Math.sin(azRad); + const azZ = AZIMUTH_RADIUS * Math.cos(azRad); + azimuthHandleRef.current.position.set(azX, 0.16, azZ); + } + + // 仰角手柄位置 + if (elevationHandleRef.current) { + const elY = CENTER.y + ELEVATION_RADIUS * Math.sin(elRad); + const elZ = ELEVATION_RADIUS * Math.cos(elRad); + elevationHandleRef.current.position.set(ELEV_ARC_X, elY, elZ); + } + + // 距离手柄位置 + if (distanceHandleRef.current && cameraIndicatorRef.current) { + const distT = 0.15 + ((10 - s.distance) / 10) * 0.7; + distanceHandleRef.current.position.lerpVectors( + CENTER, + cameraIndicatorRef.current.position, + distT + ); + } + }, []); + + // 初始化 Three.js 场景 + useEffect(() => { + if (!canvasRef.current || !containerRef.current) return; + + const container = containerRef.current; + const width = container.clientWidth || 400; + const height = container.clientHeight || 400; + + // 场景 + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0a0a0f); + sceneRef.current = scene; + + // 相机 + const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); + camera.position.set(4, 3.5, 4); + camera.lookAt(0, 0.3, 0); + cameraRef.current = camera; + + // 渲染器 + const renderer = new THREE.WebGLRenderer({ + canvas: canvasRef.current, + antialias: true, + alpha: true, + }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + rendererRef.current = renderer; + + // 光照 + const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); + scene.add(ambientLight); + + const mainLight = new THREE.DirectionalLight(0xffffff, 0.8); + mainLight.position.set(5, 10, 5); + scene.add(mainLight); + + const fillLight = new THREE.DirectionalLight(0xE93D82, 0.3); + fillLight.position.set(-5, 5, -5); + scene.add(fillLight); + + // 网格 + const gridHelper = new THREE.GridHelper(5, 20, 0x1a1a2e, 0x12121a); + gridHelper.position.y = -0.01; + scene.add(gridHelper); + + // 创建图片卡片 + const cardGeo = new THREE.BoxGeometry(1.2, 1.2, 0.02); + const cardMat = new THREE.MeshBasicMaterial({ color: 0x3a3a4a }); + const imageCard = new THREE.Mesh(cardGeo, cardMat); + imageCard.position.copy(CENTER); + scene.add(imageCard); + imageCardRef.current = imageCard; + + // 卡片边框 + const frameGeo = new THREE.EdgesGeometry(cardGeo); + const frameMat = new THREE.LineBasicMaterial({ color: 0xE93D82 }); + const frame = new THREE.LineSegments(frameGeo, frameMat); + frame.position.copy(CENTER); + scene.add(frame); + + // 方位角圆环 (粉色) + const azRingGeo = new THREE.TorusGeometry(AZIMUTH_RADIUS, 0.04, 16, 100); + const azRingMat = new THREE.MeshBasicMaterial({ + color: 0xE93D82, + transparent: true, + opacity: 0.7, + }); + const azimuthRing = new THREE.Mesh(azRingGeo, azRingMat); + azimuthRing.rotation.x = Math.PI / 2; + azimuthRing.position.y = 0.02; + scene.add(azimuthRing); + azimuthRingRef.current = azimuthRing; + + // 方位角手柄 (粉色球) + const azHandleGeo = new THREE.SphereGeometry(0.16, 32, 32); + const azHandleMat = new THREE.MeshStandardMaterial({ + color: 0xE93D82, + emissive: 0xE93D82, + emissiveIntensity: 0.6, + metalness: 0.3, + roughness: 0.4, + }); + const azimuthHandle = new THREE.Mesh(azHandleGeo, azHandleMat); + azimuthHandle.userData.type = "azimuth"; + scene.add(azimuthHandle); + azimuthHandleRef.current = azimuthHandle; + + // 仰角弧线 (青色) + const arcPoints: THREE.Vector3[] = []; + for (let i = 0; i <= 32; i++) { + const angle = (-30 + (90 * i / 32)) * Math.PI / 180; + arcPoints.push(new THREE.Vector3( + ELEV_ARC_X, + ELEVATION_RADIUS * Math.sin(angle) + CENTER.y, + ELEVATION_RADIUS * Math.cos(angle) + )); + } + const arcCurve = new THREE.CatmullRomCurve3(arcPoints); + const elArcGeo = new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false); + const elArcMat = new THREE.MeshBasicMaterial({ + color: 0x00FFD0, + transparent: true, + opacity: 0.8, + }); + const elevationArc = new THREE.Mesh(elArcGeo, elArcMat); + scene.add(elevationArc); + elevationArcRef.current = elevationArc; + + // 仰角手柄 (青色球) + const elHandleGeo = new THREE.SphereGeometry(0.16, 32, 32); + const elHandleMat = new THREE.MeshStandardMaterial({ + color: 0x00FFD0, + emissive: 0x00FFD0, + emissiveIntensity: 0.6, + metalness: 0.3, + roughness: 0.4, + }); + const elevationHandle = new THREE.Mesh(elHandleGeo, elHandleMat); + elevationHandle.userData.type = "elevation"; + scene.add(elevationHandle); + elevationHandleRef.current = elevationHandle; + + // 距离手柄 (金色球) + const distHandleGeo = new THREE.SphereGeometry(0.15, 32, 32); + const distHandleMat = new THREE.MeshStandardMaterial({ + color: 0xFFB800, + emissive: 0xFFB800, + emissiveIntensity: 0.7, + metalness: 0.5, + roughness: 0.3, + }); + const distanceHandle = new THREE.Mesh(distHandleGeo, distHandleMat); + distanceHandle.userData.type = "distance"; + scene.add(distanceHandle); + distanceHandleRef.current = distanceHandle; + + // 相机指示器 (粉色锥体) + const camGeo = new THREE.ConeGeometry(0.15, 0.4, 4); + const camMat = new THREE.MeshStandardMaterial({ + color: 0xE93D82, + emissive: 0xE93D82, + emissiveIntensity: 0.5, + metalness: 0.8, + roughness: 0.2, + }); + const cameraIndicator = new THREE.Mesh(camGeo, camMat); + scene.add(cameraIndicator); + cameraIndicatorRef.current = cameraIndicator; + + // 初始化位置 + updateVisuals(state); + + // 动画循环 + let time = 0; + const animate = () => { + animationIdRef.current = requestAnimationFrame(animate); + time += 0.01; + renderer.render(scene, camera); + }; + animate(); + + // 清理 + return () => { + if (animationIdRef.current) { + cancelAnimationFrame(animationIdRef.current); + } + renderer.dispose(); + scene.clear(); + }; + }, []); + + // 更新图片 + useEffect(() => { + if (!imageCardRef.current || !imageUrl) return; + + const loader = new THREE.TextureLoader(); + loader.load( + imageUrl, + (texture) => { + texture.colorSpace = THREE.SRGBColorSpace; + const mat = imageCardRef.current!.material as THREE.MeshBasicMaterial; + mat.map = texture; + mat.color.set(0xffffff); + mat.needsUpdate = true; + + // 调整卡片比例 + const img = texture.image as HTMLImageElement; + if (img) { + const ar = img.width / img.height; + const maxSize = 1.5; + let scaleX: number, scaleY: number; + if (ar > 1) { + scaleX = maxSize; + scaleY = maxSize / ar; + } else { + scaleY = maxSize; + scaleX = maxSize * ar; + } + imageCardRef.current!.scale.set(scaleX, scaleY, 1); + } + }, + undefined, + () => { + console.error("Failed to load image texture"); + } + ); + }, [imageUrl]); + + // 更新视觉效果当状态变化 + useEffect(() => { + updateVisuals(state); + }, [state, updateVisuals]); + + // 鼠标/触摸事件处理 + const getMousePos = useCallback((event: MouseEvent | Touch, canvas: HTMLCanvasElement) => { + const rect = canvas.getBoundingClientRect(); + mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + }, []); + + const handlePointerDown = useCallback((event: React.MouseEvent | React.TouchEvent) => { + if (!canvasRef.current || !cameraRef.current) return; + + const clientX = "touches" in event ? event.touches[0].clientX : event.clientX; + const clientY = "touches" in event ? event.touches[0].clientY : event.clientY; + getMousePos({ clientX, clientY } as MouseEvent, canvasRef.current); + + raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current); + + const handles = [ + azimuthHandleRef.current, + elevationHandleRef.current, + distanceHandleRef.current, + ].filter(Boolean) as THREE.Mesh[]; + + const intersects = raycasterRef.current.intersectObjects(handles); + if (intersects.length > 0) { + isDraggingRef.current = true; + dragTargetRef.current = intersects[0].object.userData.type; + if (canvasRef.current) { + canvasRef.current.style.cursor = "grabbing"; + } + } + }, [getMousePos]); + + const handlePointerMove = useCallback((event: React.MouseEvent | React.TouchEvent) => { + if (!canvasRef.current || !cameraRef.current || !isDraggingRef.current) return; + + const clientX = "touches" in event ? event.touches[0].clientX : event.clientX; + const clientY = "touches" in event ? event.touches[0].clientY : event.clientY; + getMousePos({ clientX, clientY } as MouseEvent, canvasRef.current); + + raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current); + + const plane = new THREE.Plane(); + const intersect = new THREE.Vector3(); + + if (dragTargetRef.current === "azimuth") { + plane.setFromNormalAndCoplanarPoint( + new THREE.Vector3(0, 1, 0), + new THREE.Vector3(0, 0, 0) + ); + if (raycasterRef.current.ray.intersectPlane(plane, intersect)) { + let angle = Math.atan2(intersect.x, intersect.z) * (180 / Math.PI); + if (angle < 0) angle += 360; + const newAzimuth = Math.max(0, Math.min(360, angle)); + setState(prev => { + const newState = { ...prev, azimuth: Math.round(newAzimuth) }; + onStateChange?.(newState); + return newState; + }); + } + } else if (dragTargetRef.current === "elevation") { + const elevPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -ELEV_ARC_X); + if (raycasterRef.current.ray.intersectPlane(elevPlane, intersect)) { + const relY = intersect.y - CENTER.y; + const relZ = intersect.z; + let angle = Math.atan2(relY, relZ) * (180 / Math.PI); + angle = Math.max(-30, Math.min(60, angle)); + setState(prev => { + const newState = { ...prev, elevation: Math.round(angle) }; + onStateChange?.(newState); + return newState; + }); + } + } else if (dragTargetRef.current === "distance") { + const newDist = 5 - mouseRef.current.y * 5; + const clampedDist = Math.max(0, Math.min(10, newDist)); + setState(prev => { + const newState = { ...prev, distance: Math.round(clampedDist * 10) / 10 }; + onStateChange?.(newState); + return newState; + }); + } + }, [getMousePos, onStateChange]); + + const handlePointerUp = useCallback(() => { + isDraggingRef.current = false; + dragTargetRef.current = null; + if (canvasRef.current) { + canvasRef.current.style.cursor = "default"; + } + }, []); + + // 下拉选择器变化处理 + const handleAzimuthChange = useCallback((value: number) => { + setState(prev => { + const newState = { ...prev, azimuth: value }; + onStateChange?.(newState); + return newState; + }); + }, [onStateChange]); + + const handleElevationChange = useCallback((value: number) => { + setState(prev => { + const newState = { ...prev, elevation: value }; + onStateChange?.(newState); + return newState; + }); + }, [onStateChange]); + + const handleDistanceChange = useCallback((value: number) => { + setState(prev => { + const newState = { ...prev, distance: value }; + onStateChange?.(newState); + return newState; + }); + }, [onStateChange]); + + // 重置 + const handleReset = useCallback(() => { + const defaultState = { azimuth: 0, elevation: 0, distance: 5 }; + setState(defaultState); + onStateChange?.(defaultState); + }, [onStateChange]); + + // 找到最接近的选项 + const findClosestAzimuth = (value: number) => { + let closest = AZIMUTH_OPTIONS[0].value; + let minDiff = 360; + for (const opt of AZIMUTH_OPTIONS) { + const diff = Math.min( + Math.abs(value - opt.value), + Math.abs(value - opt.value - 360), + Math.abs(value - opt.value + 360) + ); + if (diff < minDiff) { + minDiff = diff; + closest = opt.value; + } + } + return closest; + }; + + const findClosestElevation = (value: number) => { + let closest = ELEVATION_OPTIONS[0].value; + let minDiff = 100; + for (const opt of ELEVATION_OPTIONS) { + const diff = Math.abs(value - opt.value); + if (diff < minDiff) { + minDiff = diff; + closest = opt.value; + } + } + return closest; + }; + + const findClosestDistance = (value: number) => { + if (value < 3) return 1; + if (value < 7) return 5; + return 8; + }; + + return ( +
+ {/* 3D 画布 */} +
+ +
+ + {/* 提示词显示 */} +
+ {generatePrompt(state)} +
+ + {/* 下拉选择器 */} +
+
+ 水平 + +
+
+ 垂直 + +
+
+ 距离 + +
+
+ + {/* 数值显示和重置 */} +
+
+ 水平角度 + {Math.round(state.azimuth)}° +
+
+ 垂直角度 + {Math.round(state.elevation)}° +
+
+ 距离 + {state.distance.toFixed(1)} +
+ +
+ + {/* 内联样式 */} + +
+ ); +} + +export default QwenCameraWidget; +