| | <!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> |