Ai-Studio / static /angle.html
dx8152's picture
Upload 8 files
e51fed7 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/static/logo.png" type="image/png">
<title>Angle Control | 视角重塑</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=JetBrains+Mono:wght@400;700&display=swap');
:root {
--accent: #111827;
--bg: #f9fafb;
--card: #ffffff;
--easing: cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- 极简悬浮浅灰滚动条 (无底色/左移) --- */
*::-webkit-scrollbar {
width: 10px !important;
height: 10px !important;
background: transparent !important;
}
*::-webkit-scrollbar-track {
background: transparent !important;
border: none !important;
}
*::-webkit-scrollbar-thumb {
background-color: #d8d8d8 !important;
border: 3px solid transparent !important;
border-right-width: 5px !important;
/* 增加右侧间距,使滚动条向左位移 */
background-clip: padding-box !important;
border-radius: 10px !important;
}
*::-webkit-scrollbar-thumb:hover {
background-color: #c0c0c0 !important;
}
*::-webkit-scrollbar-corner {
background: transparent !important;
}
* {
scrollbar-width: thin !important;
scrollbar-color: #d8d8d8 transparent !important;
}
body {
background-color: var(--bg);
font-family: 'Inter', -apple-system, sans-serif;
color: var(--accent);
-webkit-font-smoothing: antialiased;
}
.container-box {
max-width: 1280px;
margin: 0 auto;
padding: 0 40px;
margin-top: 50px;
}
/* 统一组件风格 */
.glass-btn {
background: #111827;
transition: all 0.3s var(--easing);
}
.glass-btn:hover {
background: #000;
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
.glass-btn:active {
transform: scale(0.98);
}
.upload-item {
background: var(--card);
border: 1px dashed #e2e8f0;
transition: all 0.4s var(--easing);
}
.upload-item:hover {
border-color: #000;
background: #fff;
transform: translateY(-2px);
}
.result-frame {
background: #ffffff;
border-radius: 32px;
border: 1px solid #f1f5f9;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.02);
}
.masonry-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
}
@media (min-width: 768px) {
.masonry-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.masonry-item {
aspect-ratio: 1 / 1;
background: #fff;
border: 1px solid #f1f5f9;
border-radius: 24px;
overflow: hidden;
transition: all 0.5s var(--easing);
position: relative;
}
.masonry-item:hover {
transform: translateY(-6px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
}
.nano-input {
background: #ffffff;
border-radius: 16px;
transition: all 0.3s ease;
border: 1px solid #e5e7eb;
}
.nano-input:focus {
background: #ffffff;
box-shadow: 0 0 0 2px #000;
border-color: transparent;
}
@keyframes b-loading {
0% {
transform: scale(1);
background: #000;
}
50% {
transform: scale(1.15);
background: #444;
}
100% {
transform: scale(1);
background: #000;
}
}
.loading-box {
width: 10px;
height: 10px;
animation: b-loading 1s infinite var(--easing);
}
/* 复合切换组件样式 - 来自 zimage */
.mode-switcher {
position: relative;
background: #f1f1f1;
padding: 4px;
border-radius: 14px;
display: flex;
width: 100%;
}
.mode-btn {
position: relative;
z-index: 10;
flex: 1;
padding: 8px 0;
text-align: center;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
color: #999;
transition: color 0.3s ease;
cursor: pointer;
}
.mode-btn.active {
color: #000;
}
.mode-glider {
position: absolute;
height: calc(100% - 8px);
width: calc(50% - 4px);
background: #fff;
border-radius: 11px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
transition: transform 0.3s var(--easing);
z-index: 1;
}
</style>
</head>
<body class="selection:bg-black selection:text-white">
<div class="container-box">
<header class="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
<div class="space-y-1">
<h1 class="text-4xl font-extrabold tracking-[-0.05em] flex items-center">
ANGLE CONTROL<span class="text-base mt-3 ml-1">®</span>
</h1>
<p class="text-[10px] font-bold uppercase tracking-[0.5em] text-gray-400">Camera & Perspective Control
</p>
</div>
<nav class="flex gap-8 text-[11px] font-bold uppercase tracking-widest text-gray-500">
<span class="text-black border-b-2 border-black pb-1">Angle</span>
</nav>
</header>
<main class="space-y-12">
<!-- Row 1: Upload and 3D Control -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 items-start">
<section class="group w-full">
<h3 class="text-[9px] font-black uppercase tracking-[0.3em] mb-5 text-gray-400">01. Input Source
</h3>
<div id="dropzone"
class="upload-item relative overflow-hidden rounded-2xl aspect-[4/3] flex flex-col items-center justify-center cursor-pointer">
<input type="file" id="fileInput" class="hidden" accept="image/*">
<div id="uploadContent" class="text-center space-y-4">
<div
class="w-14 h-14 rounded-full border border-gray-200 bg-white flex items-center justify-center mx-auto group-hover:bg-black group-hover:text-white group-hover:border-black transition-all duration-500">
<i data-lucide="arrow-up" class="w-5 h-5"></i>
</div>
<p class="text-[11px] font-bold uppercase tracking-tight">Drop image here</p>
</div>
<img id="previewImg" class="hidden absolute inset-0 w-full h-full object-cover">
<div id="changeOverlay"
class="hidden absolute inset-0 bg-black/10 backdrop-blur-sm items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<span
class="bg-white px-5 py-2 rounded-full text-[10px] font-bold uppercase tracking-widest shadow-2xl">Change</span>
</div>
</div>
</section>
<section id="cameraControl" class="space-y-6 w-full">
<h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">02. Camera Control</h3>
<div
class="w-full aspect-[4/3] flex flex-col md:flex-row bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<!-- 3D View -->
<div id="threeContainer" class="relative flex-1 bg-[#222] h-full min-h-0"></div>
<!-- Controls -->
<div
class="w-full md:w-64 flex-shrink-0 p-5 flex flex-col justify-center gap-4 border-l border-gray-100 bg-white overflow-y-auto">
<!-- Horizontal -->
<div class="space-y-3">
<div
class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
<div class="flex items-center gap-2">
<i data-lucide="move-horizontal" class="w-3 h-3"></i>
<span>Rotation</span>
</div>
<div class="flex items-center gap-1.5">
<button onclick="resetControl('h')"
class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
<i data-lucide="rotate-ccw" class="w-3 h-3"></i>
</button>
<div class="flex items-center bg-gray-100 rounded-md px-2">
<input type="number" id="val-horizontal" value="0"
class="w-10 bg-transparent py-1 text-black text-center outline-none border-none p-0 text-[10px] font-bold [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
oninput="syncInput('h')">
<span class="text-gray-400 select-none text-[10px]">°</span>
</div>
</div>
</div>
<input type="range" id="rotate-h" min="-90" max="90" value="0"
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
</div>
<!-- Vertical -->
<div class="space-y-3">
<div
class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
<div class="flex items-center gap-2">
<i data-lucide="move-vertical" class="w-3 h-3"></i>
<span>Pitch</span>
</div>
<div class="flex items-center gap-1.5">
<button onclick="resetControl('v')"
class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
<i data-lucide="rotate-ccw" class="w-3 h-3"></i>
</button>
<div class="flex items-center bg-gray-100 rounded-md px-2">
<input type="number" id="val-vertical" value="0"
class="w-10 bg-transparent py-1 text-black text-center outline-none border-none p-0 text-[10px] font-bold [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
oninput="syncInput('v')">
<span class="text-gray-400 select-none text-[10px]">°</span>
</div>
</div>
</div>
<input type="range" id="rotate-v" min="-90" max="90" value="0"
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
</div>
<!-- Distance -->
<div class="space-y-3">
<div
class="flex justify-between items-center text-[10px] font-bold uppercase tracking-wider text-gray-500">
<div class="flex items-center gap-2">
<i data-lucide="zoom-in" class="w-3 h-3"></i>
<span>Distance</span>
</div>
<div class="flex items-center gap-1.5">
<button onclick="resetControl('d')"
class="text-gray-300 hover:text-black transition-colors p-1" title="Reset">
<i data-lucide="rotate-ccw" class="w-3 h-3"></i>
</button>
<div class="flex items-center bg-gray-100 rounded-md px-2">
<input type="number" id="val-distance" value="4.0" step="0.1"
class="w-10 bg-transparent py-1 text-black text-center outline-none border-none p-0 text-[10px] font-bold [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
oninput="syncInput('d')">
</div>
</div>
</div>
<input type="range" id="distance" min="0.1" max="8" value="4" step="0.1"
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black">
</div>
</div>
</div>
</section>
</div>
<!-- Row 2: Parameters & Result -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 items-stretch">
<section class="flex flex-col space-y-6">
<h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">03. Parameters</h3>
<div class="space-y-3 flex-1 flex flex-col">
<div class="flex items-center gap-2 text-gray-800 ml-1">
<i data-lucide="text-quote" class="w-3 h-3"></i>
<span class="text-[10px] font-bold uppercase tracking-widest">Prompt</span>
</div>
<textarea id="promptInput"
class="nano-input w-full flex-1 p-5 text-sm outline-none resize-none placeholder-gray-300"
placeholder="请通过右侧控制器调整,或输入提示词"></textarea>
</div>
<!-- Engine Switcher -->
<div class="mode-switcher">
<div id="modeLocal" class="mode-btn active flex items-center justify-center gap-1.5"
onclick="switchEngine('local')">
<i data-lucide="monitor" class="w-3 h-3"></i>
<span>Local</span>
</div>
<div id="modeCloud" class="mode-btn flex items-center justify-center"
onclick="switchEngine('cloud')">
<img src="/static/modelscope.gif"
class="h-4 object-contain opacity-50 transition-opacity group-hover:opacity-100"
style="filter: grayscale(100%);" id="msLogo">
</div>
<div id="glider" class="mode-glider"></div>
</div>
<button id="genBtn" onclick="handleGenerate()"
class="glass-btn w-full py-5 text-white rounded-xl font-bold text-[11px] uppercase tracking-[0.4em] flex items-center justify-center gap-3 shadow-xl shadow-black/10 disabled:opacity-50 disabled:cursor-not-allowed">
<i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
<span id="btnText">Generate New Angle</span>
</button>
</section>
<section class="space-y-6 flex flex-col">
<h3 class="text-[9px] font-black uppercase tracking-[0.3em] text-gray-400">04. Result Preview</h3>
<div id="resultBox"
class="result-frame relative aspect-[4/3] w-full flex items-center justify-center overflow-hidden group">
<div id="emptyState" class="text-center space-y-4 opacity-20">
<i data-lucide="camera" class="w-12 h-12 mx-auto stroke-[1px]"></i>
<p class="text-[10px] font-black tracking-[0.5em] uppercase">Canvas Ready</p>
</div>
<div id="loadingState" class="hidden flex flex-col items-center gap-5 w-full max-w-[80%]">
<div class="loading-box"></div>
<p class="text-[10px] font-bold uppercase tracking-[0.4em] animate-pulse">Processing...</p>
<!-- Progress Bar -->
<div id="cloud-progress-container" class="hidden w-full mt-4">
<div class="flex justify-between text-[9px] font-bold text-gray-400 mb-1 uppercase tracking-widest">
<span id="cloud-status-text">Pending...</span>
<span id="cloud-percent">0%</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-1.5 overflow-hidden">
<div id="cloud-progress-bar" class="bg-black h-full rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
</div>
<div id="textResult"
class="hidden w-full h-full p-12 flex flex-col items-center justify-center text-center space-y-8">
<i data-lucide="terminal" class="w-12 h-12 text-gray-300 mx-auto"></i>
<div class="space-y-4 max-w-md">
<p class="text-[10px] font-bold uppercase tracking-[0.5em] text-gray-400">Generated
Command
</p>
<h2 id="generatedText" class="text-2xl font-bold leading-relaxed text-gray-900"></h2>
</div>
<button onclick="copyText()"
class="px-8 py-3 bg-gray-100 hover:bg-black hover:text-white rounded-full text-[10px] font-bold uppercase tracking-widest transition-all flex items-center gap-2">
<i data-lucide="copy" class="w-3 h-3"></i> Copy
</button>
</div>
<img id="outputImg"
class="hidden w-full h-full object-contain p-8 cursor-zoom-in transition-all duration-700 hover:scale-[1.02]"
onclick="zoomImage()">
<a id="downloadBtn" href="#" download
class="hidden absolute bottom-8 right-8 w-14 h-14 bg-white shadow-2xl rounded-2xl flex items-center justify-center hover:bg-black hover:text-white transition-all duration-500 border border-gray-100">
<i data-lucide="download" class="w-5 h-5"></i>
</a>
</div>
</section>
</div>
</main>
<section class="mt-32">
<div class="flex items-center gap-6 mb-10">
<h2 class="text-[11px] font-black uppercase tracking-[0.5em]">Archive</h2>
<div class="h-px flex-1 bg-black/5"></div>
</div>
<div id="masonry" class="masonry-grid"></div>
<div id="loadMoreTrigger"
class="py-16 text-center opacity-20 text-[10px] font-bold uppercase tracking-widest">
End of Archive
</div>
</section>
</div>
<div id="lightbox" onclick="handleOutsideClick(event)"
class="hidden fixed inset-0 z-50 bg-white/95 backdrop-blur-3xl flex items-center justify-center p-8">
<button onclick="closeLightbox()"
class="absolute top-10 right-10 p-2 hover:rotate-90 transition-transform duration-500">
<i data-lucide="x" class="w-8 h-8"></i>
</button>
<div class="max-w-6xl w-full h-full flex flex-col items-center justify-center">
<div class="relative">
<div id="lightboxRes"
class="absolute top-4 left-4 bg-black/30 backdrop-blur-md border border-white/20 text-white px-3 py-1.5 rounded-full text-[10px] font-medium tracking-wider opacity-0 transition-opacity duration-300 pointer-events-none">
</div>
<img id="lightboxImg" src="" class="hidden max-h-[80vh] rounded-3xl shadow-2xl">
</div>
<div class="mt-8">
<button onclick="downloadLightboxImage()"
class="px-10 py-4 bg-black text-white rounded-full text-[10px] font-black uppercase tracking-widest flex items-center gap-3 shadow-xl">
<i data-lucide="save" class="w-4 h-4"></i> Save Master
</button>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
// Initialize WebSocket Listener for Cloud Progress
window.addEventListener('message', function(event) {
const data = event.data;
if (data && data.type === 'cloud_status') {
updateCloudProgress(data);
}
});
function updateCloudProgress(data) {
const container = document.getElementById('cloud-progress-container');
const statusText = document.getElementById('cloud-status-text');
const progressBar = document.getElementById('cloud-progress-bar');
const percentText = document.getElementById('cloud-percent');
if (!container || !statusText || !progressBar) return;
// Show container if hidden
if (container.classList.contains('hidden')) {
container.classList.remove('hidden');
}
// Update UI
if (data.status) {
// Simplify status text (remove internal details if needed)
let displayStatus = data.status;
if (displayStatus.includes("PENDING")) displayStatus = "Queueing...";
if (displayStatus.includes("RUNNING")) displayStatus = "Generating...";
statusText.innerText = displayStatus;
}
if (typeof data.progress !== 'undefined' && typeof data.total !== 'undefined') {
const percent = Math.min(100, Math.round((data.progress / data.total) * 100));
progressBar.style.width = `${percent}%`;
percentText.innerText = `${percent}%`;
}
}
const container = document.getElementById('threeContainer');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);
// Camera setup
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
// Renderer setup
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// Objects
const geometry = new THREE.PlaneGeometry(3, 3);
const material = new THREE.MeshStandardMaterial({
color: 0x444444,
side: THREE.DoubleSide
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x333333);
scene.add(gridHelper);
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const pointLight = new THREE.DirectionalLight(0xffffff, 1);
pointLight.position.set(5, 10, 7);
scene.add(pointLight);
// Camera Logic
const sliderH = document.getElementById('rotate-h');
const sliderV = document.getElementById('rotate-v');
const sliderD = document.getElementById('distance');
const valH = document.getElementById('val-horizontal');
const valV = document.getElementById('val-vertical');
const valD = document.getElementById('val-distance');
window.updateCamera = function () {
const lon = parseFloat(sliderH.value);
const lat = parseFloat(sliderV.value);
const dist = parseFloat(sliderD.value);
// Sync inputs (avoid overwriting if active)
if (document.activeElement !== valH) valH.value = lon;
if (document.activeElement !== valV) valV.value = lat;
if (document.activeElement !== valD) valD.value = dist.toFixed(1);
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon);
camera.position.x = dist * Math.sin(phi) * Math.sin(theta);
camera.position.y = dist * Math.cos(phi);
camera.position.z = dist * Math.sin(phi) * Math.cos(theta);
camera.lookAt(0, 0, 0);
// Real-time update prompt
updatePromptWithAngle(lon, lat, dist);
}
window.syncInput = (type) => {
if (type === 'h') {
let v = parseFloat(valH.value);
if (!isNaN(v)) sliderH.value = v;
} else if (type === 'v') {
let v = parseFloat(valV.value);
if (!isNaN(v)) sliderV.value = v;
} else if (type === 'd') {
let v = parseFloat(valD.value);
if (!isNaN(v)) sliderD.value = v;
}
window.updateCamera();
};
window.resetControl = (type) => {
if (type === 'h') {
sliderH.value = 0;
valH.value = 0;
} else if (type === 'v') {
sliderV.value = 0;
valV.value = 0;
} else if (type === 'd') {
sliderD.value = 4;
valD.value = 4;
}
window.updateCamera();
};
function updatePromptWithAngle(h, v, d) {
let parts = [];
if (h !== 0) {
const dir = h > 0 ? "向右" : "向左";
parts.push(`${dir}旋转${Math.abs(h)}度`);
}
if (v !== 0) {
const dir = v > 0 ? "俯视" : "仰视";
parts.push(`${dir}${Math.abs(v)}度`);
}
// Distance logic
let lensText = "";
if (d > 4) {
lensText = "使用广角镜头";
} else if (d < 4) {
lensText = "使用特写镜头";
}
// Removed "保持原位" default text to show placeholder
let resultText = "";
if (parts.length > 0) {
resultText = `将相机${parts.join(",")}`;
}
if (lensText) {
resultText += (resultText ? "," : "将相机") + lensText;
}
const promptInput = document.getElementById('promptInput');
let currentText = promptInput.value;
// Regex to find existing angle command (including lens info)
const regex = /将相机.*?(?=(\n|$))/g;
if (regex.test(currentText)) {
// Replace existing
promptInput.value = currentText.replace(regex, resultText);
} else {
// Append if not exists and resultText is not empty
if (resultText) {
if (currentText.trim()) {
promptInput.value = currentText.trim() + '\n' + resultText;
} else {
promptInput.value = resultText;
}
}
}
}
sliderH.addEventListener('input', window.updateCamera);
sliderV.addEventListener('input', window.updateCamera);
sliderD.addEventListener('input', window.updateCamera);
window.updateCamera();
// Animation Loop
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
// Handle Resize
const resizeObserver = new ResizeObserver(() => {
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
resizeObserver.observe(container);
// Expose function to update texture
window.update3DTexture = (url) => {
new THREE.TextureLoader().load(url, (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
// Adjust plane aspect ratio to match image
const imageAspect = texture.image.width / texture.image.height;
cube.scale.set(1, 1 / imageAspect, 1);
if (imageAspect > 1) {
cube.scale.set(1, 1 / imageAspect, 1);
// Reset base scale to 3
cube.geometry.dispose();
cube.geometry = new THREE.PlaneGeometry(3, 3 / imageAspect);
cube.scale.set(1, 1, 1);
} else {
cube.geometry.dispose();
cube.geometry = new THREE.PlaneGeometry(3 * imageAspect, 3);
cube.scale.set(1, 1, 1);
}
cube.material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide
});
cube.material.needsUpdate = true;
// Show control panel (already visible, but keep logic safe)
document.getElementById('cameraControl').classList.remove('hidden');
// Force resize check after texture load
setTimeout(() => {
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}, 100);
});
};
</script>
<script>
lucide.createIcons();
// --- Engine Switcher Logic (UI Only) ---
let currentEngine = 'local';
const ENGINE_MODE_KEY = 'angle_engine_mode';
window.switchEngine = function(mode) {
currentEngine = mode;
localStorage.setItem(ENGINE_MODE_KEY, mode);
const glider = document.getElementById('glider');
const localBtn = document.getElementById('modeLocal');
const cloudBtn = document.getElementById('modeCloud');
const msLogo = document.getElementById('msLogo');
if (mode === 'local') {
glider.style.transform = 'translateX(0)';
localBtn.classList.add('active');
cloudBtn.classList.remove('active');
if (msLogo) {
msLogo.classList.add('opacity-50');
msLogo.style.filter = 'grayscale(100%)';
}
} else {
glider.style.transform = 'translateX(100%)';
cloudBtn.classList.add('active');
localBtn.classList.remove('active');
if (msLogo) {
msLogo.classList.remove('opacity-50');
msLogo.style.filter = 'none';
}
}
};
function generateUUID() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
try { return crypto.randomUUID(); } catch (e) { }
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
const CLIENT_ID = localStorage.getItem("client_id") || generateUUID();
localStorage.setItem("client_id", CLIENT_ID);
let uploadedPath = "";
let uploadedFile = null; // Store raw file for cloud upload
let currentResult = null;
let allHistory = [];
let currentIndex = 0;
const PAGE_SIZE = 30;
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const previewImg = document.getElementById('previewImg');
const promptInput = document.getElementById('promptInput');
dropzone.onclick = () => fileInput.click();
fileInput.onchange = (e) => handleFile(e.target.files[0]);
// Drag and Drop
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('border-black', 'bg-gray-50');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('border-black', 'bg-gray-50');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('border-black', 'bg-gray-50');
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
});
// Paste support
let isHovering = false;
dropzone.addEventListener('mouseenter', () => isHovering = true);
dropzone.addEventListener('mouseleave', () => isHovering = false);
window.addEventListener('paste', (e) => {
if (!isHovering) return;
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (let item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
handleFile(file);
break;
}
}
});
async function handleFile(file) {
if (!file) return;
uploadedFile = file; // Store for cloud usage
const btn = document.getElementById('genBtn');
const btnText = document.getElementById('btnText');
btn.disabled = true;
btnText.innerText = "Uploading...";
const reader = new FileReader();
reader.onload = (e) => {
previewImg.src = e.target.result;
previewImg.classList.remove('hidden');
document.getElementById('uploadContent').classList.add('opacity-0');
document.getElementById('changeOverlay').classList.replace('hidden', 'flex');
// Automatically apply to 3D scene
if (window.update3DTexture) {
window.update3DTexture(e.target.result);
}
};
reader.readAsDataURL(file);
const formData = new FormData();
formData.append('files', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
uploadedPath = data.files[0].comfy_name;
btn.disabled = false;
btnText.innerText = "Generate New Angle";
} catch (err) {
console.error("Upload error");
btnText.innerText = "Upload Failed";
btn.disabled = false;
}
}
function applyAngleToPrompt() {
const h = parseInt(document.getElementById('rotate-h').value);
const v = parseInt(document.getElementById('rotate-v').value);
let parts = [];
if (h !== 0) {
const dir = h > 0 ? "向右" : "向左";
parts.push(`${dir}旋转${Math.abs(h)}度`);
}
if (v !== 0) {
const dir = v > 0 ? "俯视" : "仰视";
parts.push(`${dir}${Math.abs(v)}度`);
}
if (parts.length === 0) {
parts.push("保持原位");
}
const resultText = `将相机${parts.join(",")}`;
const promptInput = document.getElementById('promptInput');
// Check if there is existing content, if so append new line
if (promptInput.value.trim()) {
promptInput.value += '\n' + resultText;
} else {
promptInput.value = resultText;
}
// Visual feedback
promptInput.style.transition = "0.2s";
promptInput.style.borderColor = "#000";
promptInput.style.boxShadow = "0 0 0 2px rgba(0,0,0,0.1)";
setTimeout(() => {
promptInput.style.borderColor = "";
promptInput.style.boxShadow = "";
}, 500);
}
async function runCloudTask() {
if (!uploadedFile) throw new Error("Please upload an image first");
// Get token from centralized management (Personal -> Global)
let token = localStorage.getItem('modelscope_api_token');
if (!token) {
try {
const res = await fetch('/api/config/token');
if (res.ok) {
const data = await res.json();
if (data.token) token = data.token;
}
} catch (e) {
console.warn("Failed to fetch global token", e);
}
}
if (!token) {
if (window.parent && typeof window.parent.openTokenModal === 'function') {
window.parent.openTokenModal();
// Allow the error to propagate so user sees the message
// const err = new Error("请先点击右上角设置 ModelScope Token");
// err.silent = true;
// throw err;
}
throw new Error("请先点击右上角设置 ModelScope Token");
}
// Convert image to Base64
const toBase64 = file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
const dataUri = await toBase64(uploadedFile);
console.log("DataURI generated, length:", dataUri.length);
// Submit task via dedicated Angle endpoint
// Get Client ID from parent if available
let clientId = null;
try {
if (window.parent && window.parent.CID) {
clientId = window.parent.CID;
}
} catch (e) { console.warn("Cannot access parent CID", e); }
const payload = {
"prompt": promptInput.value,
"api_key": token,
"type": "angle",
"model": "Qwen/Qwen-Image-Edit-2511",
"image_urls": [dataUri],
"client_id": clientId
};
// Reset Progress Bar
document.getElementById('cloud-progress-container').classList.add('hidden');
document.getElementById('cloud-progress-bar').style.width = '0%';
document.getElementById('cloud-percent').innerText = '0%';
let response;
try {
response = await fetch('/api/angle/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} catch (netErr) {
throw new Error(`Network Error: ${netErr.message}`);
}
// Handle Timeout / Continue Loop
while (response.ok) {
const data = await response.json();
// If success with URL
if (data.url) {
return { images: [data.url] };
}
// If timeout status, ask user
if (data.status === 'timeout') {
const taskId = data.task_id;
const userContinue = confirm("Cloud generation is taking longer than expected (300s). The queue might be full.\n\nDo you want to continue waiting?");
if (userContinue) {
// Call poll endpoint
const pollPayload = {
"task_id": taskId,
"api_key": token,
"client_id": clientId
};
// Update UI to show we are still waiting
updateCloudProgress({status: "Resuming...", progress: 0, total: 150});
response = await fetch('/api/angle/poll_status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pollPayload)
});
continue; // Loop back to check response
} else {
throw new Error("User cancelled waiting.");
}
}
// Unknown success response
throw new Error("Unknown response format");
}
if (!response.ok) {
const errText = await response.text();
// Try to parse JSON error if possible
try {
const errJson = JSON.parse(errText);
if (errJson.detail) throw new Error(errJson.detail);
} catch (e) {}
throw new Error(`Generation Failed: ${errText}`);
}
const data = await response.json();
if (data.url) {
return {
images: [data.url]
};
} else {
throw new Error("No image URL in response");
}
}
async function handleGenerate() {
if (!uploadedPath && currentEngine === 'local') {
// ... existing local check ...
const dropzone = document.getElementById('dropzone');
dropzone.style.transition = "0.2s";
dropzone.style.borderColor = "#ef4444";
dropzone.style.transform = "scale(0.98)";
setTimeout(() => {
dropzone.style.borderColor = "";
dropzone.style.transform = "scale(1)";
}, 300);
return;
}
if (!uploadedFile && currentEngine === 'cloud') {
alert("Please upload an image first");
return;
}
// Allow manual prompt even if angle not applied, but require prompt input
if (!promptInput.value.trim()) {
promptInput.style.transition = "0.2s";
promptInput.style.borderColor = "#ef4444";
setTimeout(() => {
promptInput.style.borderColor = "";
}, 300);
return;
}
const btn = document.getElementById('genBtn');
const btnText = document.getElementById('btnText');
btn.disabled = true;
btn.style.backgroundColor = '#333';
btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.4em] text-[11px] uppercase">Processing...</span>`;
lucide.createIcons();
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('outputImg').classList.add('hidden');
document.getElementById('textResult').classList.add('hidden'); // Ensure text result is hidden
document.getElementById('loadingState').classList.remove('hidden');
try {
let data;
if (currentEngine === 'cloud') {
// Cloud Logic
data = await runCloudTask();
} else {
// Local Logic
const seed = Math.floor(Math.random() * 1000000000000000);
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflow_json: "2511.json",
params: {
"31": { "image": uploadedPath },
"11": { "prompt": promptInput.value },
"14": { "seed": seed }
},
type: "angle",
client_id: CLIENT_ID
})
});
data = await res.json();
if (data.error) throw new Error(data.error);
if (!data.images?.length) throw new Error("No images returned");
}
currentResult = data;
const outputImg = document.getElementById('outputImg');
const downloadBtn = document.getElementById('downloadBtn');
outputImg.src = data.images[0];
outputImg.classList.remove('hidden');
document.getElementById('loadingState').classList.add('hidden');
downloadBtn.href = data.images[0];
downloadBtn.classList.remove('hidden');
downloadBtn.download = `Angle-${Date.now()}.png`;
btn.style.backgroundColor = '';
btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Generate New Angle</span>`;
btn.disabled = false;
lucide.createIcons();
// Add to history
renderImageCard({
images: data.images,
prompt: promptInput.value,
timestamp: Date.now(),
is_cloud: (currentEngine === 'cloud')
}, true);
} catch (err) {
console.error(err);
btn.style.backgroundColor = '';
btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span id="btnText">Generation Failed</span>`;
lucide.createIcons();
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('emptyState').classList.remove('hidden');
btn.disabled = false;
if (!err.silent) {
alert(err.message);
}
}
}
window.copyText = () => {
const text = document.getElementById('generatedText').innerText;
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('#textResult button');
const originalHTML = btn.innerHTML;
btn.innerHTML = `<i data-lucide="check" class="w-3 h-3"></i> Copied`;
setTimeout(() => {
btn.innerHTML = originalHTML;
lucide.createIcons();
}, 2000);
});
};
// History Management
function renderImageCard(data, isNew = false) {
const masonry = document.getElementById('masonry');
const imgUrl = data.images ? data.images[0] : '';
if (!imgUrl) return;
const card = document.createElement('div');
card.className = "masonry-item relative group cursor-pointer";
card.onclick = () => openLightbox(imgUrl);
// ModelScope Badge
const isCloud = data.is_cloud || (imgUrl && imgUrl.includes('cloud_angle'));
const badgeHtml = isCloud ? `
<div class="absolute top-3 left-3 z-10">
<img src="/static/modelscope.gif" class="h-4 w-auto object-contain bg-white/90 rounded-full p-0.5 shadow-sm">
</div>
` : '';
card.innerHTML = `
<img src="${imgUrl}" class="w-full h-full object-cover block transform group-hover:scale-105 transition-transform duration-[1.5s]">
${badgeHtml}
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 p-6 flex flex-col justify-end pointer-events-none">
<p class="text-white text-[10px] font-bold uppercase tracking-widest line-clamp-2">${data.prompt || "Angle Control"}</p>
</div>
`;
if (isNew) masonry.prepend(card);
else masonry.appendChild(card);
}
function loadNextPage() {
const batch = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
if (batch.length === 0) {
const el = document.getElementById('loadMoreTrigger');
if (el) el.innerText = "End of Archive";
return;
}
batch.forEach(item => renderImageCard(item, false));
currentIndex += PAGE_SIZE;
}
async function loadHistory() {
try {
const res = await fetch('/api/history?type=angle');
const history = await res.json();
if (history && Array.isArray(history)) {
allHistory = history;
document.getElementById('masonry').innerHTML = '';
currentIndex = 0;
loadNextPage();
}
} catch (e) { console.error(e); }
}
// Lightbox
function openLightbox(url) {
const img = document.getElementById('lightboxImg');
const resPill = document.getElementById('lightboxRes');
resPill.style.opacity = '0';
img.src = url;
const lb = document.getElementById('lightbox');
lb.classList.replace('hidden', 'flex');
img.classList.remove('hidden');
document.body.style.overflow = 'hidden';
const updateRes = () => {
if (img.naturalWidth) {
resPill.innerText = `${img.naturalWidth} x ${img.naturalHeight}`;
resPill.style.opacity = '1';
}
};
img.onload = updateRes;
if (img.complete) updateRes();
}
function closeLightbox() {
const lb = document.getElementById('lightbox');
lb.classList.replace('flex', 'hidden');
document.body.style.overflow = 'auto';
}
function handleOutsideClick(e) {
if (e.target.id === 'lightbox') closeLightbox();
}
function downloadLightboxImage() {
const imgUrl = document.getElementById('lightboxImg').src;
const link = document.createElement('a');
link.href = imgUrl;
link.download = `Angle-Master-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function zoomImage() {
if (currentResult && currentResult.images && currentResult.images[0]) {
openLightbox(currentResult.images[0]);
}
}
// Init
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && allHistory.length > 0) {
loadNextPage();
}
}, { threshold: 0.1 });
window.onload = () => {
// Restore engine mode
const savedMode = localStorage.getItem(ENGINE_MODE_KEY);
if (savedMode && (savedMode === 'local' || savedMode === 'cloud')) {
switchEngine(savedMode);
}
loadHistory();
observer.observe(document.getElementById('loadMoreTrigger'));
};
// WebSocket for real-time updates (optional but good for multi-tab sync)
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/stats?client_id=${CLIENT_ID}`;
const socket = new WebSocket(wsUrl);
socket.onopen = () => {
setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send("ping");
}, 30000);
};
</script>
</body>
</html>