Ai-Studio / static /klein.html
dx8152's picture
Upload 8 files
e51fed7 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/static/logo.png" type="image/png">
<title>Flux Klein | 极简一体化终端</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;800&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;
}
/* 精致输入框 */
.nano-input {
background: var(--card);
border: 1px solid #eef0f2;
transition: all 0.3s var(--easing);
}
.nano-input:focus {
border-color: #000;
box-shadow: 0 0 0 1px #000;
}
/* 上传组件 */
.upload-item {
background: var(--card);
border: 1px dashed #e2e8f0;
transition: all 0.4s var(--easing);
position: relative;
overflow: hidden;
}
.upload-item:hover {
border-color: #000;
background: #fff;
transform: translateY(-2px);
}
.upload-item.drag-over {
border-style: solid;
border-color: #000;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
}
.preview-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
animation: fadeIn 0.5s var(--easing);
}
/* 生成按钮 */
.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);
}
/* 结果预览框 */
.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: 20px;
}
@media (min-width: 768px) {
.masonry-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.masonry-item {
aspect-ratio: 1 / 1;
border-radius: 24px;
overflow: hidden;
background: #fff;
border: 1px solid #f1f5f9;
transition: all 0.5s var(--easing);
}
.masonry-item:hover {
transform: translateY(-6px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
</head>
<body class="antialiased transition-colors duration-300">
<!-- 使用 container-box 并调整顶部间距为 pt-10,去除 justify-center -->
<div class="container-box min-h-screen flex flex-col">
<header class="flex justify-between items-end mb-16">
<div class="space-y-1">
<h1 class="text-4xl font-extrabold tracking-tighter italic">FLUX KLEIN</h1>
<p class="text-[10px] font-bold uppercase tracking-[0.4em] text-gray-400">Next-Gen Generative Interface
</p>
</div>
<nav class="hidden md:flex gap-8 text-[11px] font-bold uppercase tracking-widest text-gray-400">
<span class="text-black border-b-2 border-black pb-1">Create</span>
</nav>
</header>
<main class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<div class="lg:col-span-5 space-y-10">
<section class="space-y-4">
<div class="flex items-center gap-2 text-gray-400">
<i data-lucide="terminal" class="w-3.5 h-3.5"></i>
<span class="text-[10px] font-black uppercase tracking-widest">Input Prompt</span>
</div>
<textarea id="promptInput" rows="5"
class="nano-input w-full p-6 rounded-3xl text-sm outline-none resize-none placeholder-gray-300"
placeholder="Describe your vision here..."></textarea>
</section>
<section class="space-y-4">
<div class="flex items-center gap-2 text-gray-400">
<i data-lucide="image" class="w-3.5 h-3.5"></i>
<span class="text-[10px] font-black uppercase tracking-widest">Reference Layers</span>
</div>
<div class="grid grid-cols-3 gap-4">
<div id="drop-zone-1" onclick="document.getElementById('file1').click()"
class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
<input type="file" id="file1" class="hidden" onchange="handleFile(this.files[0], 1)">
<i data-lucide="plus"
class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
<span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Main</span>
<img id="prev1" class="preview-img hidden">
<button id="del1" onclick="clearSlot(1, event)"
class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
</div>
<div id="drop-zone-2" onclick="document.getElementById('file2').click()"
class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
<input type="file" id="file2" class="hidden" onchange="handleFile(this.files[0], 2)">
<i data-lucide="plus"
class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
<span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Aux A</span>
<img id="prev2" class="preview-img hidden">
<button id="del2" onclick="clearSlot(2, event)"
class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
</div>
<div id="drop-zone-3" onclick="document.getElementById('file3').click()"
class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
<input type="file" id="file3" class="hidden" onchange="handleFile(this.files[0], 3)">
<i data-lucide="plus"
class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
<span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Aux B</span>
<img id="prev3" class="preview-img hidden">
<button id="del3" onclick="clearSlot(3, event)"
class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
</div>
</div>
</section>
<button id="genBtn" onclick="submitWorkflow()"
class="glass-btn w-full py-5 text-white rounded-xl font-bold flex items-center justify-center gap-3 shadow-lg">
<i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
<span id="btnText" class="tracking-[0.3em] text-[11px] uppercase">Execute Synthesis</span>
</button>
</div>
<div class="lg:col-span-7">
<div id="resultBox"
class="result-frame min-h-[500px] lg:h-full flex items-center justify-center relative overflow-hidden group">
<div id="placeholder" class="text-center space-y-4 opacity-20">
<i data-lucide="layout" 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="loader" class="hidden text-center space-y-4">
<div
class="w-8 h-8 border-2 border-black border-t-transparent rounded-full animate-spin mx-auto">
</div>
<p class="text-[10px] font-black tracking-[0.5em] uppercase">Synthesizing</p>
</div>
<img id="outputImg"
class="hidden w-full h-full object-contain p-8 cursor-zoom-in transition-all duration-700"
onclick="zoomImage()">
<a id="downloadBtn" href="#" download
class="hidden absolute top-8 right-8 w-12 h-12 bg-white/90 backdrop-blur-md shadow-2xl rounded-2xl flex items-center justify-center hover:bg-black hover:text-white active:scale-95 transition-all">
<i data-lucide="download" class="w-4 h-4"></i>
</a>
</div>
</div>
</main>
<section class="mt-32">
<div class="flex items-center gap-6 mb-12">
<h2 class="text-[11px] font-black uppercase tracking-[0.5em]">Archives</h2>
<div class="h-px flex-1 bg-gray-100"></div>
</div>
<div id="masonry" class="masonry-grid"></div>
<div id="loadMoreTrigger"
class="py-20 text-center text-gray-300 text-[10px] font-bold uppercase tracking-widest cursor-pointer hover:text-black transition-colors">
Load More Archive
</div>
</section>
</div>
<div id="lightbox" onclick="handleOutsideClick(event)"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-6 bg-white/95 backdrop-blur-3xl">
<div class="max-w-6xl w-full flex flex-col items-center relative">
<div class="relative w-full flex justify-center mb-8">
<div id="compareContainer"
class="hidden relative w-full h-[75vh] rounded-[2.5rem] overflow-hidden shadow-2xl bg-[#fafafa]">
<img id="compareGenerated" class="absolute inset-0 w-full h-full object-contain">
<div id="compareOriginalWrapper"
class="absolute inset-0 w-full h-full overflow-hidden border-r-2 border-white/50">
<img id="compareOriginal" class="absolute inset-0 w-full h-full object-contain">
</div>
<div id="compareSlider" class="absolute inset-y-0 left-1/2 w-0.5 bg-white z-20 cursor-ew-resize">
<div
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white shadow-2xl rounded-full flex items-center justify-center border border-gray-100">
<i data-lucide="move-horizontal" class="w-4 h-4 text-black"></i>
</div>
</div>
</div>
<img id="lightboxImg" src="" class="hidden max-h-[75vh] rounded-[2.5rem] shadow-2xl object-contain">
<div id="lightboxRes"
class="absolute top-6 left-6 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 z-30">
</div>
<button onclick="downloadLightboxImage()"
class="absolute top-6 right-6 bg-black text-white w-12 h-12 rounded-2xl flex items-center justify-center shadow-2xl z-30 hover:scale-105 transition-transform">
<i data-lucide="download" class="w-5 h-5"></i>
</button>
</div>
<div
class="w-full bg-white border border-gray-100 rounded-[2rem] p-8 shadow-sm flex justify-between items-center gap-8">
<div class="flex-1">
<span class="text-[9px] font-black text-gray-300 uppercase tracking-widest block mb-2">Prompt
Execution</span>
<p id="lightboxPrompt" class="text-gray-700 text-sm leading-relaxed"></p>
</div>
<button id="sameStyleBtn" onclick="applySameStyle()"
class="hidden whitespace-nowrap bg-black text-white px-8 py-3.5 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:bg-gray-800 transition-all active:scale-95 flex items-center gap-2">
<i data-lucide="copy" class="w-4 h-4"></i> Replicate
</button>
</div>
<button onclick="closeLightbox()"
class="absolute -top-12 -right-12 p-4 text-gray-400 hover:text-black transition-colors">
<i data-lucide="x" class="w-8 h-8"></i>
</button>
</div>
</div>
<script>
lucide.createIcons();
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_KEY = "client_id";
let CLIENT_ID = localStorage.getItem(CLIENT_ID_KEY) || generateUUID();
localStorage.setItem(CLIENT_ID_KEY, CLIENT_ID);
let uploadedNames = { 1: "", 2: "", 3: "" };
let allHistory = [];
let currentResult = null;
let currentLightboxData = null;
let currentIndex = 0;
const PAGE_SIZE = 24;
let isLoading = false;
// 拖拽上传
let hoveredSlot = null;
[1, 2, 3].forEach(id => {
const zone = document.getElementById(`drop-zone-${id}`);
if (!zone) return;
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('drag-over'); };
zone.ondragleave = () => { zone.classList.remove('drag-over'); };
zone.ondrop = (e) => { e.preventDefault(); zone.classList.remove('drag-over'); handleFile(e.dataTransfer.files[0], id); };
// Paste support
zone.addEventListener('mouseenter', () => hoveredSlot = id);
zone.addEventListener('mouseleave', () => { if (hoveredSlot === id) hoveredSlot = null; });
});
window.addEventListener('paste', (e) => {
if (!hoveredSlot) 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, hoveredSlot);
break;
}
}
});
async function handleFile(file, index) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const prev = document.getElementById(`prev${index}`);
prev.src = e.target.result;
prev.classList.remove('hidden');
document.getElementById(`del${index}`).classList.remove('hidden');
};
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();
uploadedNames[index] = data.files[0].comfy_name;
} catch (e) { uploadedNames[index] = file.name; }
}
function clearSlot(index, ev) {
if (ev) ev.stopPropagation();
const prev = document.getElementById(`prev${index}`);
prev.src = ""; prev.classList.add("hidden");
document.getElementById(`del${index}`).classList.add("hidden");
uploadedNames[index] = "";
}
async function submitWorkflow() {
if (!uploadedNames[1]) { alert("Please upload Main Image (Slot 1)"); return; }
const btn = document.getElementById('genBtn');
const loader = document.getElementById('loader');
const placeholder = document.getElementById('placeholder');
const outputImg = document.getElementById('outputImg');
const downloadBtn = document.getElementById('downloadBtn');
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.3em] text-[11px] uppercase">Synthesizing...</span>`;
lucide.createIcons();
placeholder.classList.add('hidden');
const payload = {
prompt: document.getElementById('promptInput').value,
workflow_json: "Flux2-Klein.json",
type: "klein",
params: {
"168": { "text": document.getElementById('promptInput').value },
"158": { "noise_seed": Math.floor(Math.random() * 1000000) },
"278": { "image": uploadedNames[1] },
"270": { "image": uploadedNames[2] || "" },
"292": { "image": uploadedNames[3] || "" },
"313": { "value": uploadedNames[2] !== "" },
"314": { "value": uploadedNames[3] !== "" }
}
};
try {
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, client_id: CLIENT_ID })
});
const result = await response.json();
if (result.images?.[0]) {
currentResult = result;
outputImg.src = result.images[0];
outputImg.classList.remove('hidden');
downloadBtn.classList.remove('hidden');
downloadBtn.href = result.images[0];
renderImageCard(result, true);
}
} catch (err) {
alert("Generation failed");
placeholder.classList.remove('hidden');
} finally {
loader.classList.add('hidden');
btn.disabled = false;
btn.style.backgroundColor = '';
btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span class="tracking-[0.3em] text-[11px] uppercase">Execute Synthesis</span>`;
lucide.createIcons();
}
}
// --- Comparison & Lightbox Logic ---
function initCompareSlider() {
const container = document.getElementById('compareContainer');
const wrapper = document.getElementById('compareOriginalWrapper');
const slider = document.getElementById('compareSlider');
let isDragging = false;
const updateSlider = (clientX) => {
const rect = container.getBoundingClientRect();
let x = clientX - rect.left;
let percent = (x / rect.width) * 100;
percent = Math.max(0, Math.min(100, percent));
wrapper.style.clipPath = `inset(0 ${100 - percent}% 0 0)`;
slider.style.left = `${percent}%`;
};
const start = (e) => { isDragging = true; e.preventDefault(); };
const end = () => isDragging = false;
const move = (e) => {
if (!isDragging) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
updateSlider(clientX);
};
container.addEventListener('mousedown', (e) => { if (e.target === slider) return; updateSlider(e.clientX); start(e); });
slider.addEventListener('mousedown', start);
window.addEventListener('mouseup', end);
window.addEventListener('mousemove', move);
slider.addEventListener('touchstart', start, { passive: false });
window.addEventListener('touchend', end);
window.addEventListener('touchmove', move, { passive: false });
}
initCompareSlider();
function openLightbox(dataOrUrl) {
const lb = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
const comp = document.getElementById('compareContainer');
const promptEl = document.getElementById('lightboxPrompt');
const sameStyleBtn = document.getElementById('sameStyleBtn');
const resPill = document.getElementById('lightboxRes');
let data = (typeof dataOrUrl === 'string') ? { images: [dataOrUrl] } : dataOrUrl;
currentLightboxData = data;
promptEl.textContent = data.prompt || "No prompt metadata found";
resPill.style.opacity = '0';
const updateRes = (target) => {
if (target.naturalWidth) {
resPill.innerText = `${target.naturalWidth} x ${target.naturalHeight}`;
resPill.style.opacity = '1';
}
};
if (data.params?.["278"]?.image) {
img.classList.add('hidden');
comp.classList.remove('hidden');
const genImg = document.getElementById('compareGenerated');
genImg.src = data.images[0];
document.getElementById('compareOriginal').src = `/api/view?filename=${encodeURIComponent(data.params["278"].image)}&type=input`;
document.getElementById('compareOriginalWrapper').style.clipPath = 'inset(0 50% 0 0)';
document.getElementById('compareSlider').style.left = '50%';
genImg.onload = () => updateRes(genImg);
if (genImg.complete) updateRes(genImg);
} else {
comp.classList.add('hidden');
img.classList.remove('hidden');
img.src = data.images[0];
img.onload = () => updateRes(img);
if (img.complete) updateRes(img);
}
sameStyleBtn.classList.toggle('hidden', !data.params);
lb.classList.replace('hidden', 'flex');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
document.getElementById('lightbox').classList.replace('flex', 'hidden');
document.body.style.overflow = 'auto';
}
function handleOutsideClick(e) { if (e.target.id === 'lightbox') closeLightbox(); }
function renderImageCard(data, isNew = false) {
const masonry = document.getElementById('masonry');
if (document.getElementById(`history-${data.timestamp}`)) return;
const card = document.createElement('div');
card.id = `history-${data.timestamp}`;
card.className = 'masonry-item group relative cursor-zoom-in';
card.onclick = () => openLightbox(data);
card.innerHTML = `
<img src="${data.images[0]}" class="w-full h-full object-cover block transform group-hover:scale-105 transition-transform duration-1000" loading="lazy">
<button onclick="deleteHistoryItem('${data.timestamp}', event)" class="absolute top-4 right-4 text-white hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-all p-6 flex flex-col justify-end">
<p class="text-white text-[10px] font-medium line-clamp-2 uppercase tracking-wider">${data.prompt || "Klein Archive"}</p>
</div>
`;
isNew ? masonry.prepend(card) : masonry.appendChild(card);
lucide.createIcons();
}
async function applySameStyle() {
if (!currentLightboxData?.params) return;
document.getElementById('promptInput').value = currentLightboxData.prompt || "";
const params = currentLightboxData.params;
const setSlot = (slotId, nodeId) => {
if (params[nodeId]?.image) {
const fname = params[nodeId].image;
uploadedNames[slotId] = fname;
const prev = document.getElementById(`prev${slotId}`);
prev.src = `/api/view?filename=${encodeURIComponent(fname)}&type=input`;
prev.classList.remove('hidden');
document.getElementById(`del${slotId}`).classList.remove('hidden');
} else { clearSlot(slotId); }
};
setSlot(1, "278"); setSlot(2, "270"); setSlot(3, "292");
closeLightbox();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function loadHistory(page = 0) {
if (isLoading) return;
const loader = document.getElementById('loadMoreTrigger');
try {
isLoading = true;
if (page === 0) {
loader.classList.remove('hidden');
loader.innerText = "Loading Archives...";
const res = await fetch('/api/history?type=klein');
allHistory = await res.json();
document.getElementById('masonry').innerHTML = '';
currentIndex = 0;
} else {
loader.innerText = "Loading...";
await new Promise(r => setTimeout(r, 400));
}
const nextData = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
nextData.forEach(item => renderImageCard(item));
currentIndex += nextData.length;
if (currentIndex >= allHistory.length) {
loader.classList.add('hidden');
} else {
loader.classList.remove('hidden');
loader.innerText = "Load More Archive";
}
} catch (e) {
console.error(e);
loader.textContent = "Error loading history";
} finally {
isLoading = false;
}
}
function zoomImage() {
if (currentResult) openLightbox(currentResult);
}
function downloadLightboxImage() {
const url = currentLightboxData?.images[0];
if (!url) return;
const link = document.createElement('a');
link.href = url;
link.download = `Klein-${Date.now()}.png`;
link.click();
}
async function deleteHistoryItem(ts, ev) {
ev.stopPropagation();
if (!confirm("Delete this archive?")) return;
try {
const res = await fetch('/api/history/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timestamp: ts })
});
if ((await res.json()).success) {
document.getElementById(`history-${ts}`).remove();
}
} catch (e) { alert("Delete failed"); }
}
// Infinite Scroll Observer
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && currentIndex < allHistory.length) {
loadHistory(1);
}
}, { threshold: 0.1 });
window.onload = () => {
loadHistory(0).then(() => {
const trigger = document.getElementById('loadMoreTrigger');
if (trigger) {
observer.observe(trigger);
trigger.onclick = () => loadHistory(1);
}
});
};
</script>
</body>
</html>