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