lovart3/components/qwen-camera-widget.tsx

651 lines
22 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import * as THREE from "three";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
// 相机状态接口
export interface CameraState {
azimuth: number; // 方位角 0-360
elevation: number; // 仰角 -30 到 60
distance: number; // 距离 0-10
}
interface QwenCameraWidgetProps {
initialState?: Partial<CameraState>;
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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const animationIdRef = useRef<number | null>(null);
// 控制对象引用
const azimuthHandleRef = useRef<THREE.Mesh | null>(null);
const elevationHandleRef = useRef<THREE.Mesh | null>(null);
const distanceHandleRef = useRef<THREE.Mesh | null>(null);
const cameraIndicatorRef = useRef<THREE.Mesh | null>(null);
const azimuthRingRef = useRef<THREE.Mesh | null>(null);
const elevationArcRef = useRef<THREE.Mesh | null>(null);
const imageCardRef = useRef<THREE.Mesh | null>(null);
// 状态
const [state, setState] = useState<CameraState>({
azimuth: initialState?.azimuth ?? 45,
elevation: initialState?.elevation ?? 0,
distance: initialState?.distance ?? 5,
});
// 交互状态
const isDraggingRef = useRef(false);
const dragTargetRef = useRef<string | null>(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 `<sks> ${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(50, width / height, 0.1, 1000);
camera.position.set(4.5, 4, 4.5);
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.6);
scene.add(ambientLight);
const mainLight = new THREE.DirectionalLight(0xffffff, 1.0);
mainLight.position.set(5, 10, 5);
scene.add(mainLight);
const fillLight = new THREE.DirectionalLight(0xE93D82, 0.4);
fillLight.position.set(-5, 5, -5);
scene.add(fillLight);
// 网格 - 调整颜色以适应浅色/深色主题
// 使用更中性的颜色
const gridHelper = new THREE.GridHelper(5, 20, 0x888888, 0x444444);
gridHelper.position.y = -0.01;
(gridHelper.material as THREE.Material).transparent = true;
(gridHelper.material as THREE.Material).opacity = 0.2;
scene.add(gridHelper);
// 创建图片卡片
const cardGeo = new THREE.BoxGeometry(1.2, 1.2, 0.02);
const cardMat = new THREE.MeshBasicMaterial({ color: 0xcccccc });
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();
// 监听容器大小变化
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width && height && cameraRef.current && rendererRef.current) {
cameraRef.current.aspect = width / height;
cameraRef.current.updateProjectionMatrix();
rendererRef.current.setSize(width, height);
}
}
});
resizeObserver.observe(container);
// 清理
return () => {
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
}
resizeObserver.disconnect();
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: string) => {
const val = Number(value);
setState(prev => {
const newState = { ...prev, azimuth: val };
onStateChange?.(newState);
return newState;
});
}, [onStateChange]);
const handleElevationChange = useCallback((value: string) => {
const val = Number(value);
setState(prev => {
const newState = { ...prev, elevation: val };
onStateChange?.(newState);
return newState;
});
}, [onStateChange]);
const handleDistanceChange = useCallback((value: string) => {
const val = Number(value);
setState(prev => {
const newState = { ...prev, distance: val };
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.toString();
};
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.toString();
};
const findClosestDistance = (value: number) => {
if (value < 3) return "1";
if (value < 7) return "5";
return "8";
};
return (
<div className={cn("flex flex-col bg-transparent", className)} ref={containerRef}>
{/* 3D 画布 */}
<div className="relative w-full aspect-square bg-muted/20 rounded-lg overflow-hidden border">
<canvas
ref={canvasRef}
className="w-full h-full block cursor-grab active:cursor-grabbing"
onMouseDown={handlePointerDown}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
/>
{/* 提示词浮层 */}
<div className="absolute top-2 left-2 right-2 bg-background/80 backdrop-blur-md border rounded px-2 py-1.5 text-[10px] font-mono text-center shadow-sm select-all opacity-70 hover:opacity-100 transition-opacity truncate">
{generatePrompt(state)}
</div>
</div>
{/* 控制面板 */}
<div className="mt-3 space-y-3">
<div className="grid grid-cols-1 gap-2.5">
<div className="flex items-center gap-2">
<label className="text-[10px] font-semibold text-muted-foreground w-8">水平</label>
<Select value={findClosestAzimuth(state.azimuth)} onValueChange={handleAzimuthChange}>
<SelectTrigger className="h-6 text-[10px] border-primary/20 focus:ring-primary/20 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AZIMUTH_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value.toString()} className="text-[10px]">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" className="h-6 px-1.5 text-[10px] font-mono text-primary border-primary/30 bg-primary/5 min-w-[36px] justify-center">
{Math.round(state.azimuth)}°
</Badge>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] font-semibold text-muted-foreground w-8">垂直</label>
<Select value={findClosestElevation(state.elevation)} onValueChange={handleElevationChange}>
<SelectTrigger className="h-6 text-[10px] border-[#00FFD0]/30 focus:ring-[#00FFD0]/20 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ELEVATION_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value.toString()} className="text-[10px]">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" className="h-6 px-1.5 text-[10px] font-mono text-[#00b894] border-[#00b894]/30 bg-[#00b894]/5 min-w-[36px] justify-center">
{Math.round(state.elevation)}°
</Badge>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] font-semibold text-muted-foreground w-8">距离</label>
<Select value={findClosestDistance(state.distance)} onValueChange={handleDistanceChange}>
<SelectTrigger className="h-6 text-[10px] border-[#FFB800]/30 focus:ring-[#FFB800]/20 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISTANCE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value.toString()} className="text-[10px]">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" className="h-6 px-1.5 text-[10px] font-mono text-[#d49a00] border-[#d49a00]/30 bg-[#d49a00]/5 min-w-[36px] justify-center">
{state.distance.toFixed(1)}
</Badge>
</div>
</div>
</div>
</div>
);
}
export default QwenCameraWidget;