auto-git:

[add] README.md
 [add] default.png
 [add] equirect_hdr_icon_512-Wiederhergestellt.png
 [add] generate_equirect.py
 [add] icon.png
 [add] index.html
 [add] package-lock.json
 [add] package.json
 [add] public/
 [add] requirements.txt
 [add] run.sh
 [add] src-tauri/
 [add] src/
 [add] vite.config.js
This commit is contained in:
2026-05-07 10:44:51 +02:00
parent 19e5cbfa1f
commit 2a2e72eda7
21 changed files with 7508 additions and 0 deletions

695
src/main.js Normal file
View File

@@ -0,0 +1,695 @@
import './style.css';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { invoke } from '@tauri-apps/api/tauri';
import { readBinaryFile } from '@tauri-apps/api/fs';
import { listen } from '@tauri-apps/api/event';
const app = document.getElementById('app');
app.innerHTML = `
<div id="canvas-container"></div>
<div id="thumb-dock">
<div id="thumb-header">Maps</div>
<div id="thumb-list"></div>
</div>
<div id="settings-panel" class="hidden">
<div class="settings-header">
<div>
<div class="settings-title">Generation Settings</div>
<div class="settings-sub">Defaults tuned for SDXL 360</div>
</div>
<button id="settings-close" aria-label="Close settings">×</button>
</div>
<div class="settings-grid">
<label>Steps
<input type="number" id="steps-input" min="1" max="80" />
</label>
<label>Guidance
<input type="number" id="guidance-input" step="0.1" min="0" max="20" />
</label>
<label>Width
<input type="number" id="width-input" min="256" max="4096" step="64" />
</label>
<label>Height
<input type="number" id="height-input" min="256" max="4096" step="64" />
</label>
<label>Scheduler
<select id="scheduler-input">
<option value="dpmsolver-sde">DPM++ 2M SDE</option>
<option value="dpmsolver">DPM++ 2M</option>
<option value="euler">Euler</option>
<option value="euler_a">Euler Ancestral</option>
<option value="heun">Heun</option>
<option value="ddim">DDIM</option>
</select>
</label>
<label>Upscale
<select id="upscale-input">
<option value="none">None</option>
<option value="realesrgan">Real-ESRGAN</option>
<option value="topaz">Topaz</option>
</select>
</label>
<label class="settings-check">Seam Inpainting
<input type="checkbox" id="seam-inpaint-input" />
</label>
<label>Model Path
<input type="text" id="model-path-input" />
</label>
<label>Base Model
<input type="text" id="base-model-input" />
</label>
<label>VAE
<input type="text" id="vae-model-input" />
</label>
</div>
<div class="settings-footer">
<button id="settings-reset">Reset defaults</button>
</div>
</div>
<div id="progress-overlay">
<div id="progress-ring">
<div id="progress-fill"></div>
<div id="progress-text">...</div>
</div>
</div>
<div id="prompt-bar">
<input id="prompt-input" type="text" placeholder="Describe the environment..." />
<button id="settings-btn" title="Generation settings">Settings</button>
<button id="generate-btn">Generate</button>
</div>
<div id="status"></div>
<div id="delete-confirm" class="hidden" role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title">
<div id="delete-confirm-card">
<div id="delete-confirm-title">Delete map?</div>
<div id="delete-confirm-body">This will remove <span id="delete-confirm-filename"></span> from disk.</div>
<div id="delete-confirm-actions">
<button id="delete-confirm-cancel" type="button">Cancel</button>
<button id="delete-confirm-delete" type="button">Delete</button>
</div>
</div>
</div>
`;
const canvasContainer = document.getElementById('canvas-container');
const promptInput = document.getElementById('prompt-input');
const generateBtn = document.getElementById('generate-btn');
const settingsBtn = document.getElementById('settings-btn');
const statusEl = document.getElementById('status');
const thumbList = document.getElementById('thumb-list');
const thumbDock = document.getElementById('thumb-dock');
const deleteConfirm = document.getElementById('delete-confirm');
const deleteConfirmFilename = document.getElementById('delete-confirm-filename');
const deleteConfirmCancel = document.getElementById('delete-confirm-cancel');
const deleteConfirmDelete = document.getElementById('delete-confirm-delete');
const progressOverlay = document.getElementById('progress-overlay');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const settingsPanel = document.getElementById('settings-panel');
const settingsClose = document.getElementById('settings-close');
const settingsReset = document.getElementById('settings-reset');
const stepsInput = document.getElementById('steps-input');
const guidanceInput = document.getElementById('guidance-input');
const widthInput = document.getElementById('width-input');
const heightInput = document.getElementById('height-input');
const schedulerInput = document.getElementById('scheduler-input');
const upscaleInput = document.getElementById('upscale-input');
const seamInpaintInput = document.getElementById('seam-inpaint-input');
const modelPathInput = document.getElementById('model-path-input');
const baseModelInput = document.getElementById('base-model-input');
const vaeModelInput = document.getElementById('vae-model-input');
// Three.js setup
let renderer, scene, camera, controls, skyMesh;
const defaultTextureUrl = '/default.png';
const skyFadeDurationMs = 1000;
let skyTransition = null;
let skyTextureRequestId = 0;
let autoSpin = true;
let lastInteraction = Date.now();
const minZoomDistance = 0.2;
const maxZoomDistance = 20;
let desiredDistance = 1;
const zoomLerpFactor = 0.1;
const zoomVec = new THREE.Vector3();
let progressState = {
upscale: null,
seamInpaint: null,
phases: {
gen: null,
inpaint: null,
upscale: null,
},
};
let progressUnlisten = null;
let currentMapPath = null;
let deleteConfirmResolve = null;
let generationRunning = false;
let cancelRequested = false;
const defaultSettings = {
steps: 25,
width: 1536,
height: 768,
guidance: 6.5,
scheduler: 'dpmsolver-sde',
upscale: 'none',
seamInpaint: false,
modelPath: 'proximasan/sdxl-360-diffusion',
baseModel: 'stabilityai/stable-diffusion-xl-base-1.0',
vaeModel: 'madebyollin/sdxl-vae-fp16-fix',
};
let currentSettings = { ...defaultSettings };
function setStatus(msg) {
statusEl.textContent = msg || '';
statusEl.style.opacity = msg ? '0.98' : '0';
if (msg) {
clearTimeout(setStatus._timer);
setStatus._timer = setTimeout(() => {
statusEl.style.opacity = '0';
}, 5000);
}
}
async function loadTexture(url) {
const loader = new THREE.TextureLoader();
return new Promise((resolve, reject) => {
loader.load(
url,
tex => {
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.colorSpace = THREE.SRGBColorSpace;
resolve(tex);
},
undefined,
err => reject(err)
);
});
}
async function setSkyFromUrl(url) {
const requestId = ++skyTextureRequestId;
try {
const texture = await loadTexture(url);
if (requestId !== skyTextureRequestId) {
texture.dispose();
return;
}
transitionSkyToTexture(texture);
} catch (e) {
console.error('Failed to load texture', e);
if (requestId === skyTextureRequestId) {
setStatus('Failed to load map texture');
}
}
}
function replaceSkyTexture(texture) {
const previousTexture = skyMesh.material.map;
skyMesh.material.map = texture;
skyMesh.material.needsUpdate = true;
if (previousTexture && previousTexture !== texture) {
previousTexture.dispose();
}
}
function removeSkyTransition(transition) {
scene.remove(transition.mesh);
transition.material.dispose();
}
function finishSkyTransition(transition) {
if (!transition || skyTransition !== transition) return;
removeSkyTransition(transition);
skyTransition = null;
replaceSkyTexture(transition.texture);
}
function transitionSkyToTexture(texture) {
if (!skyMesh.material.map) {
replaceSkyTexture(texture);
return;
}
if (skyTransition) {
finishSkyTransition(skyTransition);
}
const material = new THREE.MeshBasicMaterial({
side: THREE.BackSide,
color: 0xffffff,
map: texture,
transparent: true,
opacity: 0,
depthWrite: false,
});
const mesh = new THREE.Mesh(skyMesh.geometry, material);
mesh.rotation.copy(skyMesh.rotation);
mesh.renderOrder = skyMesh.renderOrder + 1;
scene.add(mesh);
skyTransition = {
mesh,
material,
texture,
start: performance.now(),
duration: skyFadeDurationMs,
};
}
function updateSkyTransition(now) {
if (!skyTransition) return;
const progress = Math.min(1, (now - skyTransition.start) / skyTransition.duration);
skyTransition.material.opacity = THREE.MathUtils.smoothstep(progress, 0, 1);
skyTransition.mesh.rotation.copy(skyMesh.rotation);
if (progress >= 1) {
finishSkyTransition(skyTransition);
}
}
function initScene() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0.1, 0, 0.1);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
canvasContainer.appendChild(renderer.domElement);
desiredDistance = camera.position.length();
const geometry = new THREE.SphereGeometry(50, 64, 32);
const material = new THREE.MeshBasicMaterial({ side: THREE.BackSide, color: 0xffffff });
skyMesh = new THREE.Mesh(geometry, material);
scene.add(skyMesh);
controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false;
controls.enableZoom = false; // we implement custom smooth zooming
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.rotateSpeed = 0.3;
controls.autoRotate = false;
controls.autoRotateSpeed = 0.15;
controls.minDistance = 0.05;
controls.maxDistance = 20;
controls.addEventListener('start', () => {
autoSpin = false;
lastInteraction = Date.now();
});
controls.addEventListener('end', () => {
lastInteraction = Date.now();
});
window.addEventListener('resize', onResize);
renderer.domElement.addEventListener(
'wheel',
(e) => {
e.preventDefault();
const delta = e.deltaY;
// Scale desired distance exponentially for smooth feel
const factor = Math.exp(delta * 0.0015);
desiredDistance = THREE.MathUtils.clamp(desiredDistance * factor, minZoomDistance, maxZoomDistance);
lastInteraction = Date.now();
autoSpin = false;
},
{ passive: false }
);
animate();
}
function onResize() {
if (!renderer || !camera) return;
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const idle = Date.now() - lastInteraction > 2000;
if (idle) autoSpin = true;
if (autoSpin) {
skyMesh.rotation.y += 0.0008;
}
updateSkyTransition(now);
// Smooth zoom toward desired distance
const target = controls.target;
const currentDistance = camera.position.distanceTo(target);
const nextDistance = THREE.MathUtils.lerp(currentDistance, desiredDistance, zoomLerpFactor);
zoomVec.copy(camera.position).sub(target).normalize().multiplyScalar(nextDistance).add(target);
camera.position.copy(zoomVec);
controls.update();
renderer.render(scene, camera);
}
async function pathToObjectUrl(path) {
const data = await readBinaryFile(path);
const blob = new Blob([new Uint8Array(data)], { type: 'image/png' });
return URL.createObjectURL(blob);
}
function resetProgressState() {
progressState = {
upscale: null,
seamInpaint: null,
phases: {
gen: null,
inpaint: null,
upscale: null,
},
};
}
function computeProgress() {
const upscaleOn = progressState.upscale === true;
const seamOn = progressState.seamInpaint === true;
const weights = {
gen: seamOn || upscaleOn ? 0.5 : 1,
inpaint: seamOn ? 0.5 : 0,
upscale: upscaleOn ? 0.5 : 0,
};
if (seamOn && upscaleOn) {
weights.gen = 0.25;
weights.inpaint = 0.25;
}
const frac = phase => {
if (!phase || !phase.total) return 0;
const pct = phase.current / phase.total;
return Math.max(0, Math.min(1, pct));
};
return (
frac(progressState.phases.gen) * weights.gen +
frac(progressState.phases.inpaint) * weights.inpaint +
frac(progressState.phases.upscale) * weights.upscale
);
}
function updateProgressDisplay(pct) {
if (!progressOverlay) return;
const clamped = Math.max(0, Math.min(1, pct || 0));
const deg = clamped * 100;
progressFill.style.background = `conic-gradient(#ffffff ${deg}%, rgba(255,255,255,0.12) ${deg}%)`;
progressText.textContent = `${Math.round(clamped * 100)}%`;
}
function startProgress() {
resetProgressState();
if (progressOverlay) {
progressOverlay.style.display = 'flex';
}
updateProgressDisplay(0);
}
function stopProgress() {
updateProgressDisplay(1);
if (progressOverlay) {
progressOverlay.style.display = 'none';
}
}
async function refreshThumbnails(selectedPath) {
let maps;
try {
maps = await invoke('list_maps');
} catch (e) {
console.error(e);
setStatus('Failed to read map list');
thumbDock.style.display = 'none';
return;
}
thumbList.innerHTML = '';
if (!maps || maps.length === 0) {
thumbDock.style.display = 'none';
return;
}
thumbDock.style.display = 'block';
for (let idx = 0; idx < maps.length; idx++) {
const item = maps[idx];
const el = document.createElement('div');
el.className = 'thumb-item';
const img = document.createElement('img');
const deleteBtn = document.createElement('button');
deleteBtn.className = 'thumb-delete';
deleteBtn.type = 'button';
deleteBtn.textContent = '×';
deleteBtn.title = 'Delete map';
deleteBtn.setAttribute('aria-label', `Delete ${item.filename}`);
deleteBtn.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
await deleteMap(item, el);
});
let fileUrl;
try {
fileUrl = await pathToObjectUrl(item.path);
img.src = fileUrl;
} catch (err) {
console.error('Failed to load thumbnail', err);
img.alt = 'Failed to load';
}
el.appendChild(img);
el.appendChild(deleteBtn);
el.title = item.filename;
el.addEventListener('click', () => {
if (fileUrl) {
currentMapPath = item.path;
setSkyFromUrl(fileUrl);
setStatus(`Loaded ${item.filename}`);
}
});
thumbList.appendChild(el);
if (fileUrl) {
if (idx === 0 && !selectedPath) {
currentMapPath = item.path;
setSkyFromUrl(fileUrl);
setStatus(`Showing ${item.filename}`);
}
if (selectedPath && selectedPath === item.path) {
currentMapPath = item.path;
setSkyFromUrl(fileUrl);
setStatus(`Showing ${item.filename}`);
}
}
}
}
async function deleteMap(item, tileEl) {
if (!item?.path) return;
const confirmed = await requestDeleteConfirmation(item.filename);
if (!confirmed) return;
try {
await invoke('delete_map', { path: item.path });
const wasCurrent = currentMapPath === item.path;
tileEl?.remove();
setStatus(`Deleted ${item.filename}`);
if (thumbList.children.length === 0) {
thumbDock.style.display = 'none';
currentMapPath = null;
await setSkyFromUrl(defaultTextureUrl);
return;
}
if (wasCurrent) {
thumbList.querySelector('.thumb-item')?.click();
}
} catch (err) {
console.error(err);
setStatus('Failed to delete map');
}
}
function requestDeleteConfirmation(filename) {
if (deleteConfirmResolve) {
closeDeleteConfirmation(false);
}
deleteConfirmFilename.textContent = `"${filename}"`;
deleteConfirm.classList.remove('hidden');
deleteConfirmDelete.focus();
return new Promise(resolve => {
deleteConfirmResolve = resolve;
});
}
function closeDeleteConfirmation(result) {
deleteConfirm.classList.add('hidden');
if (deleteConfirmResolve) {
const resolve = deleteConfirmResolve;
deleteConfirmResolve = null;
resolve(result);
}
}
async function generateMap() {
if (generationRunning) {
await cancelGeneration();
return;
}
const prompt = promptInput.value.trim();
if (!prompt) {
setStatus('Please enter a prompt');
return;
}
const settings = {
steps: Number(stepsInput.value) || defaultSettings.steps,
guidance: Number(guidanceInput.value) || defaultSettings.guidance,
width: Number(widthInput.value) || defaultSettings.width,
height: Number(heightInput.value) || defaultSettings.height,
scheduler: schedulerInput.value || defaultSettings.scheduler,
upscale: upscaleInput.value || defaultSettings.upscale,
seamInpaint: seamInpaintInput.checked,
modelPath: modelPathInput.value.trim() || defaultSettings.modelPath,
baseModel: baseModelInput.value.trim() || defaultSettings.baseModel,
vaeModel: vaeModelInput.value.trim() || defaultSettings.vaeModel,
};
currentSettings = settings;
generationRunning = true;
cancelRequested = false;
generateBtn.disabled = false;
generateBtn.textContent = 'Cancel';
setStatus('Generating...');
startProgress();
try {
const result = await invoke('generate_map', { prompt, settings });
const outputPath = result.outputPath || result.output_path || result;
if (outputPath) {
const fileUrl = await pathToObjectUrl(outputPath);
await refreshThumbnails(outputPath);
currentMapPath = outputPath;
await setSkyFromUrl(fileUrl);
setStatus('New environment loaded');
} else {
setStatus('Generation finished, but no output path reported');
}
} catch (e) {
console.error(e);
setStatus(e === 'Generation cancelled' ? 'Generation cancelled' : (typeof e === 'string' ? e : 'Generation failed'));
} finally {
stopProgress();
generationRunning = false;
cancelRequested = false;
generateBtn.textContent = 'Generate';
generateBtn.disabled = false;
}
}
async function cancelGeneration() {
if (!generationRunning || cancelRequested) return;
cancelRequested = true;
generateBtn.disabled = true;
generateBtn.textContent = 'Cancelling...';
setStatus('Cancelling generation...');
try {
await invoke('cancel_generation');
} catch (e) {
console.error(e);
cancelRequested = false;
generateBtn.disabled = false;
generateBtn.textContent = 'Cancel';
setStatus(typeof e === 'string' ? e : 'Failed to cancel generation');
}
}
function setupUI() {
generateBtn.addEventListener('click', () => {
if (generationRunning) {
cancelGeneration();
} else {
generateMap();
}
});
promptInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !generationRunning) generateMap();
});
settingsBtn.addEventListener('click', () => {
settingsPanel.classList.remove('hidden');
});
settingsClose.addEventListener('click', () => {
settingsPanel.classList.add('hidden');
});
settingsReset.addEventListener('click', () => {
applySettings(defaultSettings);
});
deleteConfirmCancel.addEventListener('click', () => {
closeDeleteConfirmation(false);
});
deleteConfirmDelete.addEventListener('click', () => {
closeDeleteConfirmation(true);
});
deleteConfirm.addEventListener('click', (event) => {
if (event.target === deleteConfirm) {
closeDeleteConfirmation(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !deleteConfirm.classList.contains('hidden')) {
closeDeleteConfirmation(false);
}
});
}
async function setupProgressEvents() {
try {
progressUnlisten = await listen('gen-progress', (event) => {
const data = event?.payload || {};
if (typeof data.upscale === 'boolean') {
progressState.upscale = data.upscale;
}
if (typeof data.seamInpaint === 'boolean') {
progressState.seamInpaint = data.seamInpaint;
}
if (data.phase && typeof data.current === 'number' && typeof data.total === 'number') {
progressState.phases[data.phase] = {
current: data.current,
total: data.total,
};
}
updateProgressDisplay(computeProgress());
});
} catch (err) {
console.error('Failed to bind progress listener', err);
}
}
async function bootstrap() {
initScene();
setupUI();
applySettings(defaultSettings);
await setupProgressEvents();
await setSkyFromUrl(defaultTextureUrl);
await refreshThumbnails();
setStatus('Ready');
}
function applySettings(cfg) {
currentSettings = { ...cfg };
stepsInput.value = cfg.steps;
guidanceInput.value = cfg.guidance;
widthInput.value = cfg.width;
heightInput.value = cfg.height;
schedulerInput.value = cfg.scheduler;
upscaleInput.value = cfg.upscale;
seamInpaintInput.checked = Boolean(cfg.seamInpaint);
modelPathInput.value = cfg.modelPath;
baseModelInput.value = cfg.baseModel;
vaeModelInput.value = cfg.vaeModel;
}
bootstrap();

493
src/style.css Normal file
View File

@@ -0,0 +1,493 @@
:root {
--overlay-bg: rgba(255, 255, 255, 0.14);
--text-color: #f7f7f7;
--dock-bg: rgba(18, 18, 24, 0.78);
--dock-hover: rgba(34, 34, 46, 0.92);
--input-bg: rgba(245, 245, 245, 0.82);
--input-text: #1f2933;
--border: rgba(255, 255, 255, 0.22);
--glass-bg: rgba(255,255,255,0.12);
--glass-bg-strong: rgba(255,255,255,0.18);
--glass-border: rgba(255,255,255,0.3);
--glass-shadow: 0 20px 50px rgba(0,0,0,0.35);
}
* { box-sizing: border-box; }
body, html, #app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #0b0c10;
color: var(--text-color);
font-family: "Inter", "SF Pro Display", "Segoe UI", system-ui, -apple-system, sans-serif;
}
button,
input,
select,
#status,
#thumb-dock,
#settings-panel,
#delete-confirm-card,
#progress-text {
text-shadow: 0 1px 3px rgba(0,0,0,0.72), 0 0 12px rgba(0,0,0,0.34);
}
input::placeholder {
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
#canvas-container {
width: 100%;
height: 100%;
position: fixed;
inset: 0;
overflow: hidden;
}
#prompt-bar {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
align-items: center;
padding: 12px 14px;
background: rgba(255,255,255,0.12);
border: 1px solid var(--border);
border-radius: 12px;
backdrop-filter: blur(10px);
box-shadow: 0 20px 50px rgba(0,0,0,0.35);
}
#prompt-input {
width: min(48vw, 720px);
min-width: 220px;
border: 1px solid rgba(255,255,255,0.3);
padding: 12px 14px;
border-radius: 12px;
background: rgba(255,255,255,0.16);
color: #fdfefe;
font-size: 16px;
outline: none;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.35), 0 4px 14px rgba(0,0,0,0.18);
backdrop-filter: blur(6px);
}
#prompt-input:focus {
border-color: rgba(255,255,255,0.5);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.45), 0 6px 18px rgba(0,0,0,0.18);
}
#generate-btn {
border: none;
padding: 12px 18px;
border-radius: 10px;
background: rgba(255,255,255,0.18);
color: #f5f6fb;
font-weight: 700;
font-size: 15px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.2s ease;
box-shadow: 0 8px 18px rgba(0,0,0,0.14), inset 0 1px 0 rgba(255,255,255,0.35);
backdrop-filter: blur(6px);
}
#generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
#generate-btn:not(:disabled):hover { transform: translateY(-1px); }
#generate-btn:not(:disabled):active { transform: translateY(0); }
#settings-btn {
border: 1px solid rgba(255,255,255,0.32);
padding: 12px 14px;
border-radius: 10px;
background: rgba(255,255,255,0.08);
color: #f5f6fb;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.2s ease;
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.2);
backdrop-filter: blur(6px);
}
#settings-btn:hover { transform: translateY(-1px); }
#settings-btn:active { transform: translateY(0); }
#settings-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 420px;
max-width: 680px;
background: var(--glass-bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--glass-shadow);
padding: 18px 18px 14px;
backdrop-filter: blur(18px);
z-index: 6;
}
#settings-panel.hidden { display: none; }
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.settings-title {
font-weight: 700;
font-size: 16px;
letter-spacing: 0.1px;
}
.settings-sub {
font-size: 12px;
color: rgba(255,255,255,0.7);
}
#settings-close {
border: 1px solid var(--glass-border);
background: rgba(255,255,255,0.1);
color: #fff;
width: 32px;
height: 32px;
border-radius: 10px;
cursor: pointer;
font-size: 18px;
line-height: 1;
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.22);
backdrop-filter: blur(6px);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px 12px;
}
.settings-grid label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: rgba(255,255,255,0.9);
}
.settings-grid input,
.settings-grid select {
width: 100%;
border-radius: 10px;
border: 1px solid var(--glass-border);
background: rgba(255,255,255,0.14);
color: #f9fafc;
padding: 10px 12px;
font-size: 14px;
outline: none;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.22);
backdrop-filter: blur(6px);
}
.settings-grid .settings-check {
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 43px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--glass-border);
background: rgba(255,255,255,0.1);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.18);
backdrop-filter: blur(6px);
}
.settings-grid .settings-check input {
width: 18px;
height: 18px;
padding: 0;
margin: 0;
accent-color: rgba(255,255,255,0.9);
cursor: pointer;
}
.settings-grid input:focus,
.settings-grid select:focus {
border-color: rgba(255,255,255,0.52);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.32), 0 0 0 3px rgba(255,255,255,0.12);
}
.settings-footer {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
#settings-reset {
border: 1px solid var(--glass-border);
padding: 10px 14px;
border-radius: 10px;
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.2);
backdrop-filter: blur(6px);
}
#status {
position: fixed;
top: 18px;
right: 18px;
padding: 10px 14px;
background: rgba(0,0,0,0.5);
border-radius: 10px;
border: 1px solid var(--border);
font-size: 13px;
min-width: 200px;
text-align: center;
opacity: 0;
transition: opacity 0.25s ease;
}
#delete-confirm {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.04);
backdrop-filter: blur(10px);
pointer-events: auto;
}
#delete-confirm.hidden { display: none; }
#delete-confirm-card {
width: min(360px, calc(100vw - 36px));
border-radius: 12px;
border: 1px solid var(--border);
background: var(--glass-bg);
box-shadow: var(--glass-shadow);
padding: 18px;
color: #fff;
backdrop-filter: blur(18px);
}
#delete-confirm-title {
font-size: 17px;
font-weight: 700;
margin-bottom: 8px;
}
#delete-confirm-body {
color: rgba(255,255,255,0.76);
font-size: 13px;
line-height: 1.4;
overflow-wrap: anywhere;
}
#delete-confirm-filename {
color: #fff;
}
#delete-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
#delete-confirm-actions button {
border-radius: 10px;
border: 1px solid var(--glass-border);
padding: 9px 13px;
color: #fff;
cursor: pointer;
font-weight: 600;
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.2);
backdrop-filter: blur(6px);
}
#delete-confirm-cancel {
background: rgba(255,255,255,0.08);
}
#delete-confirm-delete {
background: var(--glass-bg-strong);
}
#delete-confirm-actions button:focus-visible {
outline: 2px solid rgba(255,255,255,0.86);
outline-offset: 2px;
}
/* Progress overlay */
#progress-overlay {
position: fixed;
inset: 0;
display: none;
justify-content: center;
align-items: center;
pointer-events: none;
z-index: 5;
}
#progress-ring {
width: 90vmin;
height: 90vmin;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
#progress-fill {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(#ffffff 0%, rgba(255,255,255,0.08) 0%);
/* Ring thickness is outer - inner. Make it very thin (about 1% of radius). */
mask: radial-gradient(farthest-side, transparent 99%, black 50%);
}
#progress-text {
position: relative;
color: #f5f6fb;
font-size: 24px;
letter-spacing: 0.5px;
text-shadow: 0 1px 8px rgba(0,0,0,0.5);
}
#thumb-dock {
display: block;
position: fixed;
top: 50%;
transform: translate(0, -50%);
left: 0;
width: 120px;
max-height: 80vh;
background: rgba(255,255,255,0.1);
border-right: 1px solid rgba(255,255,255,0.22);
border-radius: 0 12px 12px 0;
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
overflow: hidden;
pointer-events: auto;
backdrop-filter: blur(10px);
}
#thumb-dock:hover { background: rgba(255,255,255,0.14); }
#thumb-header {
padding: 10px 12px;
font-size: 12px;
letter-spacing: 0.2px;
text-transform: uppercase;
color: #d5d6e0;
border-bottom: 1px solid var(--border);
}
#thumb-list {
overflow-y: auto;
max-height: calc(80vh - 44px);
padding: 8px 10px;
display: grid;
gap: 10px;
}
/* Custom scrollbar for dock */
#thumb-list::-webkit-scrollbar {
width: 10px;
}
#thumb-list::-webkit-scrollbar-track {
background: rgba(255,255,255,0.08);
border-radius: 999px;
}
#thumb-list::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.22);
border-radius: 999px;
border: 2px solid rgba(255,255,255,0.08);
}
#thumb-list::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.3);
}
.thumb-item {
position: relative;
width: 100%;
max-width: 150px;
height: 150px;
border-radius: 10px;
overflow: visible;
border: 1px solid rgba(255,255,255,0.18);
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.12s ease;
background: #15171f;
}
.thumb-delete {
position: absolute;
top: -6px;
right: -6px;
width: 24px;
height: 24px;
border: 1px solid var(--glass-border);
border-radius: 50%;
background: rgba(255,255,255,0.16);
color: #fff;
display: grid;
place-items: center;
font-size: 18px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.25);
backdrop-filter: blur(6px);
}
.thumb-delete:focus-visible {
outline: 1px solid rgba(255,255,255,0.9);
outline-offset: -2px;
}
.thumb-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 10px;
}
.thumb-item:hover {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 10px 26px rgba(0,0,0,0.26);
border-color: rgba(255,255,255,0.34);
}
@media (max-width: 800px) {
#thumb-dock { display: none; }
#prompt-bar {
flex-direction: column;
bottom: 18px;
align-items: stretch;
}
#prompt-input { width: 78vw; }
#generate-btn { width: 100%; }
#settings-btn { width: 100%; }
#status { bottom: 120px; }
}