Download static/angle.html from dx8152/Ai-Studio: direct link, hf CLI and curl.
- Browser
- Download file 55.3 kB
-
https://huggingface.co/spaces/dx8152/Ai-Studio/resolve/main/static/angle.html
- Command line
-
hf download hf://spaces/dx8152/Ai-Studio/static/angle.html
-
curl -L -o angle.html https://huggingface.co/spaces/dx8152/Ai-Studio/resolve/main/static/angle.html
55.3 kB
| <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 ; | |
| height: 10px ; | |
| background: transparent ; | |
| } | |
| *::-webkit-scrollbar-track { | |
| background: transparent ; | |
| border: none ; | |
| } | |
| *::-webkit-scrollbar-thumb { | |
| background-color: #d8d8d8 ; | |
| border: 3px solid transparent ; | |
| border-right-width: 5px ; | |
| /* 增加右侧间距,使滚动条向左位移 */ | |
| background-clip: padding-box ; | |
| border-radius: 10px ; | |
| } | |
| *::-webkit-scrollbar-thumb:hover { | |
| background-color: #c0c0c0 ; | |
| } | |
| *::-webkit-scrollbar-corner { | |
| background: transparent ; | |
| } | |
| * { | |
| scrollbar-width: thin ; | |
| scrollbar-color: #d8d8d8 transparent ; | |
| } | |
| 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> |