| <!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); |
| } |
| |
| |
| .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"> |
| |
| <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"> |
| |
| <div id="threeContainer" class="relative flex-1 bg-[#222] h-full min-h-0"></div> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| |
| <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'; |
| |
| |
| 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; |
| |
| |
| if (container.classList.contains('hidden')) { |
| container.classList.remove('hidden'); |
| } |
| |
| |
| if (data.status) { |
| |
| 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); |
| |
| |
| const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000); |
| |
| |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); |
| renderer.setSize(container.clientWidth, container.clientHeight); |
| renderer.setPixelRatio(window.devicePixelRatio); |
| container.appendChild(renderer.domElement); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| 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)}度`); |
| } |
| |
| |
| let lensText = ""; |
| if (d > 4) { |
| lensText = "使用广角镜头"; |
| } else if (d < 4) { |
| lensText = "使用特写镜头"; |
| } |
| |
| |
| |
| let resultText = ""; |
| if (parts.length > 0) { |
| resultText = `将相机${parts.join(",")}`; |
| } |
| |
| if (lensText) { |
| resultText += (resultText ? "," : "将相机") + lensText; |
| } |
| |
| const promptInput = document.getElementById('promptInput'); |
| let currentText = promptInput.value; |
| |
| |
| const regex = /将相机.*?(?=(\n|$))/g; |
| |
| if (regex.test(currentText)) { |
| |
| promptInput.value = currentText.replace(regex, resultText); |
| } else { |
| |
| 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(); |
| |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| renderer.render(scene, camera); |
| } |
| animate(); |
| |
| |
| 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); |
| |
| |
| window.update3DTexture = (url) => { |
| new THREE.TextureLoader().load(url, (texture) => { |
| texture.colorSpace = THREE.SRGBColorSpace; |
| |
| |
| 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); |
| |
| 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; |
| |
| |
| document.getElementById('cameraControl').classList.remove('hidden'); |
| |
| |
| setTimeout(() => { |
| const w = container.clientWidth; |
| const h = container.clientHeight; |
| camera.aspect = w / h; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(w, h); |
| }, 100); |
| }); |
| }; |
| </script> |
|
|
| <script> |
| lucide.createIcons(); |
| |
| |
| 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; |
| 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]); |
| |
| |
| 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]); |
| } |
| }); |
| |
| |
| 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; |
| 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'); |
| |
| |
| 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'); |
| |
| if (promptInput.value.trim()) { |
| promptInput.value += '\n' + resultText; |
| } else { |
| promptInput.value = resultText; |
| } |
| |
| |
| 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"); |
| |
| |
| 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(); |
| |
| |
| |
| |
| } |
| throw new Error("请先点击右上角设置 ModelScope Token"); |
| } |
| |
| |
| 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); |
| |
| |
| |
| 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 |
| }; |
| |
| |
| 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}`); |
| } |
| |
| |
| while (response.ok) { |
| const data = await response.json(); |
| |
| |
| if (data.url) { |
| return { images: [data.url] }; |
| } |
| |
| |
| 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) { |
| |
| const pollPayload = { |
| "task_id": taskId, |
| "api_key": token, |
| "client_id": clientId |
| }; |
| |
| |
| 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; |
| } else { |
| throw new Error("User cancelled waiting."); |
| } |
| } |
| |
| |
| throw new Error("Unknown response format"); |
| } |
| |
| if (!response.ok) { |
| const errText = await response.text(); |
| |
| 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') { |
| |
| 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; |
| } |
| |
| |
| 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'); |
| document.getElementById('loadingState').classList.remove('hidden'); |
| |
| try { |
| let data; |
| |
| if (currentEngine === 'cloud') { |
| |
| data = await runCloudTask(); |
| } else { |
| |
| 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(); |
| |
| |
| 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); |
| }); |
| }; |
| |
| |
| 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); |
| |
| |
| 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); } |
| } |
| |
| |
| 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]); |
| } |
| } |
| |
| |
| const observer = new IntersectionObserver((entries) => { |
| if (entries[0].isIntersecting && allHistory.length > 0) { |
| loadNextPage(); |
| } |
| }, { threshold: 0.1 }); |
| |
| window.onload = () => { |
| |
| const savedMode = localStorage.getItem(ENGINE_MODE_KEY); |
| if (savedMode && (savedMode === 'local' || savedMode === 'cloud')) { |
| switchEngine(savedMode); |
| } |
| |
| loadHistory(); |
| observer.observe(document.getElementById('loadMoreTrigger')); |
| }; |
| |
| |
| 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> |