460 lines
17 KiB
HTML
460 lines
17 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="zh-CN">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>AI 3D Camera Visualizer</title>
|
||
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.158.0/three.min.js"></script>
|
||
|
|
<style>
|
||
|
|
:root {
|
||
|
|
--bg-body: #f8fafc;
|
||
|
|
--bg-card: #ffffff;
|
||
|
|
--primary: #4f46e5;
|
||
|
|
--primary-hover: #4338ca;
|
||
|
|
--text-main: #1e293b;
|
||
|
|
--text-dim: #64748b;
|
||
|
|
--border: #e2e8f0;
|
||
|
|
--accent-cyan: #06b6d4;
|
||
|
|
--accent-pink: #d946ef;
|
||
|
|
--accent-orange: #f59e0b;
|
||
|
|
}
|
||
|
|
|
||
|
|
body {
|
||
|
|
margin: 0;
|
||
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
|
|
background-color: transparent;
|
||
|
|
color: var(--text-main);
|
||
|
|
display: flex;
|
||
|
|
justify-content: center;
|
||
|
|
align-items: flex-start;
|
||
|
|
padding: 0;
|
||
|
|
overflow-x: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.container {
|
||
|
|
width: 100%;
|
||
|
|
max-width: 100%;
|
||
|
|
background: transparent;
|
||
|
|
padding: 20px;
|
||
|
|
box-sizing: border-box;
|
||
|
|
}
|
||
|
|
|
||
|
|
header h2 {
|
||
|
|
margin: 0 0 4px 0;
|
||
|
|
font-size: 1rem;
|
||
|
|
font-weight: 800;
|
||
|
|
color: #0f172a;
|
||
|
|
}
|
||
|
|
.legend {
|
||
|
|
font-size: 11px;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--text-dim);
|
||
|
|
margin-bottom: 20px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
|
||
|
|
|
||
|
|
/* 3D 预览区 */
|
||
|
|
#viewport {
|
||
|
|
width: 100%;
|
||
|
|
height: 280px;
|
||
|
|
background: #f1f5f9;
|
||
|
|
border-radius: 24px;
|
||
|
|
position: relative;
|
||
|
|
overflow: hidden;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
margin-bottom: 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.prompt-overlay {
|
||
|
|
position: absolute;
|
||
|
|
bottom: 12px;
|
||
|
|
left: 12px;
|
||
|
|
right: 12px;
|
||
|
|
background: rgba(255, 255, 255, 0.8);
|
||
|
|
backdrop-filter: blur(8px);
|
||
|
|
padding: 8px 12px;
|
||
|
|
border-radius: 12px;
|
||
|
|
color: var(--primary);
|
||
|
|
font-weight: 700;
|
||
|
|
font-size: 10px;
|
||
|
|
text-align: center;
|
||
|
|
pointer-events: none;
|
||
|
|
border: 1px solid rgba(79, 70, 229, 0.1);
|
||
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* 控制器样式 */
|
||
|
|
.controls {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 20px;
|
||
|
|
}
|
||
|
|
.control-group { position: relative; }
|
||
|
|
.label-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||
|
|
|
||
|
|
.tag {
|
||
|
|
font-size: 10px;
|
||
|
|
font-weight: 800;
|
||
|
|
padding: 2px 8px;
|
||
|
|
border-radius: 6px;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.5px;
|
||
|
|
}
|
||
|
|
.val-display { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 700; color: var(--text-main); }
|
||
|
|
|
||
|
|
input[type=range] {
|
||
|
|
width: 100%;
|
||
|
|
height: 4px;
|
||
|
|
background: #e2e8f0;
|
||
|
|
border-radius: 10px;
|
||
|
|
outline: none;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
input[type=range]::-webkit-slider-thumb {
|
||
|
|
-webkit-appearance: none;
|
||
|
|
width: 16px;
|
||
|
|
height: 16px;
|
||
|
|
background: white;
|
||
|
|
border: 2px solid var(--primary);
|
||
|
|
border-radius: 50%;
|
||
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
|
|
transition: transform 0.1s;
|
||
|
|
}
|
||
|
|
input[type=range]:active::-webkit-slider-thumb { transform: scale(1.2); }
|
||
|
|
|
||
|
|
.range-labels {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
font-size: 9px;
|
||
|
|
font-weight: 700;
|
||
|
|
color: #94a3b8;
|
||
|
|
margin-top: 6px;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
.generate-btn {
|
||
|
|
background: var(--primary);
|
||
|
|
color: white;
|
||
|
|
border: none;
|
||
|
|
padding: 16px;
|
||
|
|
width: 100%;
|
||
|
|
border-radius: 16px;
|
||
|
|
font-size: 14px;
|
||
|
|
font-weight: 800;
|
||
|
|
cursor: pointer;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
gap: 10px;
|
||
|
|
transition: all 0.2s;
|
||
|
|
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
|
||
|
|
margin-top: 10px;
|
||
|
|
}
|
||
|
|
.generate-btn:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
||
|
|
.generate-btn:active { transform: translateY(0); }
|
||
|
|
|
||
|
|
/* 输出结果 (简化) */
|
||
|
|
.output-section {
|
||
|
|
margin-top: 24px;
|
||
|
|
background: #f8fafc;
|
||
|
|
padding: 12px;
|
||
|
|
border-radius: 16px;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
display: none; /* 简洁考虑,默认隐藏预览 */
|
||
|
|
}
|
||
|
|
|
||
|
|
/* 颜色类 */
|
||
|
|
.bg-cyan { background: #ecfeff; color: #0891b2; border: 1px solid #cffafe; }
|
||
|
|
.bg-pink { background: #fdf2f8; color: #db2777; border: 1px solid #fce7f3; }
|
||
|
|
.bg-orange { background: #fffbeb; color: #d97706; border: 1px solid #fef3c7; }
|
||
|
|
.dot-cyan { background: var(--accent-cyan); }
|
||
|
|
.dot-pink { background: var(--accent-pink); }
|
||
|
|
.dot-orange { background: var(--accent-orange); }
|
||
|
|
|
||
|
|
.file-input-wrapper { margin-bottom: 20px; text-align: left; }
|
||
|
|
.file-label {
|
||
|
|
font-size: 11px;
|
||
|
|
font-weight: 800;
|
||
|
|
color: var(--primary);
|
||
|
|
cursor: pointer;
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 4px;
|
||
|
|
background: #f5f3ff;
|
||
|
|
padding: 6px 12px;
|
||
|
|
border-radius: 8px;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
.file-label:hover { background: #ede9fe; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
|
||
|
|
<div class="container">
|
||
|
|
<div class="file-input-wrapper">
|
||
|
|
<label class="file-label" for="fileInput">
|
||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||
|
|
更换参考图
|
||
|
|
</label>
|
||
|
|
<input type="file" id="fileInput" hidden accept="image/*">
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="viewport">
|
||
|
|
<div class="prompt-overlay" id="overlayPrompt">准备就绪</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="controls">
|
||
|
|
<!-- Azimuth -->
|
||
|
|
<div class="control-group">
|
||
|
|
<div class="label-row">
|
||
|
|
<span class="tag bg-cyan">Azimuth (水平)</span>
|
||
|
|
<span class="val-display" id="val-azimuth">0°</span>
|
||
|
|
</div>
|
||
|
|
<input type="range" id="slider-azimuth" min="0" max="315" step="45" value="0">
|
||
|
|
<div class="range-labels">
|
||
|
|
<span>正前</span><span>右侧</span><span>正后</span><span>左侧</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Elevation -->
|
||
|
|
<div class="control-group">
|
||
|
|
<div class="label-row">
|
||
|
|
<span class="tag bg-pink">Elevation (高度)</span>
|
||
|
|
<span class="val-display" id="val-elevation">0°</span>
|
||
|
|
</div>
|
||
|
|
<input type="range" id="slider-elevation" min="-90" max="90" step="5" value="0">
|
||
|
|
<div class="range-labels">
|
||
|
|
<span>仰拍</span><span>平视</span><span>俯拍</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Distance -->
|
||
|
|
<div class="control-group">
|
||
|
|
<div class="label-row">
|
||
|
|
<span class="tag bg-orange">Distance (距离)</span>
|
||
|
|
<span class="val-display" id="val-distance">1.0</span>
|
||
|
|
</div>
|
||
|
|
<input type="range" id="slider-distance" min="2" max="25" step="1" value="10">
|
||
|
|
<div class="range-labels">
|
||
|
|
<span>特写</span><span>标准</span><span>远景</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button class="generate-btn" id="applyBtn">
|
||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7"/></svg>
|
||
|
|
确认拍摄角度
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="output-section">
|
||
|
|
<div class="output-content" id="finalPrompt">...</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
// --- 1. Three.js 初始化 ---
|
||
|
|
const viewport = document.getElementById('viewport');
|
||
|
|
const scene = new THREE.Scene();
|
||
|
|
const camera = new THREE.PerspectiveCamera(40, viewport.clientWidth / viewport.clientHeight, 0.1, 1000);
|
||
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
|
|
renderer.setSize(viewport.clientWidth, viewport.clientHeight);
|
||
|
|
renderer.setClearColor(0xf1f5f9, 1); // 设置背景色与 viewport 一致
|
||
|
|
viewport.appendChild(renderer.domElement);
|
||
|
|
|
||
|
|
camera.position.set(6, 5, 8);
|
||
|
|
camera.lookAt(0, 0.5, 0);
|
||
|
|
|
||
|
|
scene.add(new THREE.AmbientLight(0xffffff, 1.2));
|
||
|
|
const grid = new THREE.GridHelper(10, 20, 0xccd6e0, 0xe2e8f0);
|
||
|
|
scene.add(grid);
|
||
|
|
|
||
|
|
// --- 2. 场景物体 ---
|
||
|
|
// 参考图 Plane
|
||
|
|
const targetGeom = new THREE.PlaneGeometry(1.5, 2);
|
||
|
|
const targetMat = new THREE.MeshBasicMaterial({ color: 0x22222a, side: THREE.DoubleSide });
|
||
|
|
const targetPlane = new THREE.Mesh(targetGeom, targetMat);
|
||
|
|
targetPlane.position.y = 1;
|
||
|
|
scene.add(targetPlane);
|
||
|
|
|
||
|
|
// 相机小模型
|
||
|
|
const camGroup = new THREE.Group();
|
||
|
|
const body = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.35, 0.4), new THREE.MeshStandardMaterial({color: 0x3b82f6}));
|
||
|
|
const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.2), new THREE.MeshStandardMaterial({color: 0x111111}));
|
||
|
|
lens.rotation.x = Math.PI / 2;
|
||
|
|
lens.position.z = -0.25;
|
||
|
|
camGroup.add(body, lens);
|
||
|
|
scene.add(camGroup);
|
||
|
|
|
||
|
|
// 轨道可视化
|
||
|
|
const aziRing = new THREE.LineLoop(
|
||
|
|
new THREE.BufferGeometry().setFromPoints(new THREE.EllipseCurve(0,0, 3,3).getPoints(64)),
|
||
|
|
new THREE.LineBasicMaterial({ color: 0x2dd4bf })
|
||
|
|
);
|
||
|
|
aziRing.rotation.x = Math.PI/2;
|
||
|
|
scene.add(aziRing);
|
||
|
|
|
||
|
|
let elevArc;
|
||
|
|
const distLine = new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xfbbf24 }));
|
||
|
|
scene.add(distLine);
|
||
|
|
|
||
|
|
// --- 3. 逻辑控制 ---
|
||
|
|
const sAzi = document.getElementById('slider-azimuth');
|
||
|
|
const sElev = document.getElementById('slider-elevation');
|
||
|
|
const sDist = document.getElementById('slider-distance');
|
||
|
|
|
||
|
|
function update() {
|
||
|
|
const azi = parseInt(sAzi.value);
|
||
|
|
const elev = parseInt(sElev.value);
|
||
|
|
const distVal = parseInt(sDist.value) / 10;
|
||
|
|
|
||
|
|
document.getElementById('val-azimuth').innerText = azi + '°';
|
||
|
|
document.getElementById('val-elevation').innerText = elev + '°';
|
||
|
|
document.getElementById('val-distance').innerText = distVal.toFixed(1);
|
||
|
|
|
||
|
|
// 计算坐标
|
||
|
|
const visualR = distVal * 3;
|
||
|
|
const phi = (90 - elev) * (Math.PI / 180);
|
||
|
|
const theta = (azi) * (Math.PI / 180);
|
||
|
|
|
||
|
|
const x = visualR * Math.sin(phi) * Math.sin(theta);
|
||
|
|
const y = visualR * Math.cos(phi);
|
||
|
|
const z = visualR * Math.sin(phi) * Math.cos(theta);
|
||
|
|
|
||
|
|
camGroup.position.set(x, y, z);
|
||
|
|
camGroup.lookAt(0, 1, 0);
|
||
|
|
|
||
|
|
// 更新距离线
|
||
|
|
distLine.geometry.setFromPoints([new THREE.Vector3(0,0,0), new THREE.Vector3(x,y,z)]);
|
||
|
|
|
||
|
|
// 动态绘制高度弧线
|
||
|
|
if(elevArc) scene.remove(elevArc);
|
||
|
|
const arcPoints = [];
|
||
|
|
for(let i=-30; i<=90; i+=5){
|
||
|
|
const p = (90-i)*(Math.PI/180);
|
||
|
|
arcPoints.push(new THREE.Vector3(visualR*Math.sin(p)*Math.sin(theta), visualR*Math.cos(p), visualR*Math.sin(p)*Math.cos(theta)));
|
||
|
|
}
|
||
|
|
elevArc = new THREE.Line(new THREE.BufferGeometry().setFromPoints(arcPoints), new THREE.LineBasicMaterial({color: 0xe879f9}));
|
||
|
|
scene.add(elevArc);
|
||
|
|
|
||
|
|
renderPrompt(azi, elev, distVal);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getAziDesc(a) {
|
||
|
|
if (a === 0) return "no horizontal rotation";
|
||
|
|
if (a > 0 && a < 180) return `rotate ${a}° clockwise to the right`;
|
||
|
|
if (a === 180) return "rotate 180° to the opposite side";
|
||
|
|
if (a > 180) return `rotate ${360 - a}° counter-clockwise to the left`;
|
||
|
|
return "angled view";
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPrompt(azi, elev, dist) {
|
||
|
|
// 1. 生成最纯粹的相对位移指令
|
||
|
|
let aziCmd = azi === 0 ? "no horizontal rotation" : `rotate camera ${azi}° clockwise around the subject`;
|
||
|
|
if (azi > 180) {
|
||
|
|
aziCmd = `rotate camera ${360 - azi}° counter-clockwise to the left`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const elevCmd = elev === 0 ? "maintain height" : (elev > 0 ? `tilt camera up ${elev}°` : `tilt camera down ${Math.abs(elev)}°`);
|
||
|
|
|
||
|
|
// 2. 核心视角标签 (用于辅助定位)
|
||
|
|
let viewTag = "front view";
|
||
|
|
if (azi === 45) viewTag = "front-right 3/4 view";
|
||
|
|
else if (azi === 90) viewTag = "right side view";
|
||
|
|
else if (azi === 135) viewTag = "back-right view";
|
||
|
|
else if (azi === 180) viewTag = "back view";
|
||
|
|
else if (azi === 225) viewTag = "back-left view";
|
||
|
|
else if (azi === 270) viewTag = "left side view";
|
||
|
|
else if (azi === 315) viewTag = "front-left 3/4 view";
|
||
|
|
|
||
|
|
let elevTag = "eye-level shot";
|
||
|
|
if (elev >= 75) elevTag = "top-down view";
|
||
|
|
else if (elev > 20) elevTag = "high angle shot";
|
||
|
|
else if (elev < -75) elevTag = "bottom-up view";
|
||
|
|
else if (elev < -20) elevTag = "low angle shot";
|
||
|
|
|
||
|
|
let distTag = "medium shot";
|
||
|
|
if (dist <= 0.4) distTag = "close-up shot";
|
||
|
|
else if (dist >= 1.6) distTag = "wide shot";
|
||
|
|
|
||
|
|
// 3. 构建提示词块
|
||
|
|
// 格式:[机位移动指令]. <sks> [目标视角标签], level horizon.
|
||
|
|
const positive = `Camera adjustment from reference: ${aziCmd} and ${elevCmd}. <sks> ${viewTag}, ${elevTag}, ${distTag}, level horizon`;
|
||
|
|
|
||
|
|
const visualizer = `<Visualizer> Azimuth:${azi}| Elevation:${elev} | Distance:${dist.toFixed(1)}`;
|
||
|
|
const negative = `[Negative Prompt]: tilted, rotated, distorted viewpoint, inconsistent angle`;
|
||
|
|
|
||
|
|
const fullPrompt = `${positive}
|
||
|
|
|
||
|
|
${visualizer}
|
||
|
|
|
||
|
|
${negative}`;
|
||
|
|
|
||
|
|
document.getElementById('overlayPrompt').innerText = positive;
|
||
|
|
document.getElementById('finalPrompt').innerText = fullPrompt;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 图片上传
|
||
|
|
document.getElementById('fileInput').onchange = (e) => {
|
||
|
|
const file = e.target.files[0];
|
||
|
|
if (file) {
|
||
|
|
const reader = new FileReader();
|
||
|
|
reader.onload = (ev) => {
|
||
|
|
applyTexture(ev.target.result);
|
||
|
|
};
|
||
|
|
reader.readAsDataURL(file);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
function applyTexture(dataUrl) {
|
||
|
|
new THREE.TextureLoader().load(dataUrl, (tex) => {
|
||
|
|
targetPlane.material = new THREE.MeshBasicMaterial({ map: tex, transparent: true });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 监听来自主页面的图片同步消息
|
||
|
|
window.addEventListener('message', (e) => {
|
||
|
|
if (e.data.type === 'sync_image' && e.data.dataUrl) {
|
||
|
|
applyTexture(e.data.dataUrl);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
[sAzi, sElev, sDist].forEach(s => s.addEventListener('input', update));
|
||
|
|
|
||
|
|
function animate() {
|
||
|
|
requestAnimationFrame(animate);
|
||
|
|
renderer.render(scene, camera);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 应用到主窗口
|
||
|
|
document.getElementById('applyBtn').onclick = () => {
|
||
|
|
const prompt = document.getElementById('finalPrompt').innerText;
|
||
|
|
window.parent.postMessage({ type: 'apply_prompt', prompt: prompt }, '*');
|
||
|
|
// 提示用户已应用
|
||
|
|
const btn = document.getElementById('applyBtn');
|
||
|
|
const oldContent = btn.innerHTML;
|
||
|
|
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg> 已应用同步';
|
||
|
|
btn.style.background = '#10b981';
|
||
|
|
btn.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.2)';
|
||
|
|
setTimeout(() => {
|
||
|
|
btn.innerHTML = oldContent;
|
||
|
|
btn.style.background = '';
|
||
|
|
btn.style.boxShadow = '';
|
||
|
|
}, 1500);
|
||
|
|
};
|
||
|
|
|
||
|
|
update();
|
||
|
|
animate();
|
||
|
|
|
||
|
|
window.onresize = () => {
|
||
|
|
camera.aspect = viewport.clientWidth / viewport.clientHeight;
|
||
|
|
camera.updateProjectionMatrix();
|
||
|
|
renderer.setSize(viewport.clientWidth, viewport.clientHeight);
|
||
|
|
};
|
||
|
|
</script>
|
||
|
|
|
||
|
|
</body>
|
||
|
|
</html>
|