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:
695
src/main.js
Normal file
695
src/main.js
Normal 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
493
src/style.css
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user