Files
3d-model-generator/web/index.html

2376 lines
82 KiB
HTML
Raw Normal View History

2026-05-14 10:23:47 +02:00
<!DOCTYPE html>
<html>
<head>
<title>3D-Modell Generator</title>
<meta charset="UTF-8" />
<style>
:root {
--bg-main: #fff;
--bg-sidebar: #fff1f2;
--accent: #9f1239;
--border: #ffe4e6;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
background: var(--bg-main);
color: #23262f;
}
.container {
max-width: 850px;
margin: 3em auto;
margin-top: 0 !important;
margin-bottom: 0 !important;
background: var(--bg-main);
padding: 2em 2em 2em 2em;
/*border-radius: 18px;*/
box-shadow: 0 6px 36px rgba(159,18,57,0.09), 0 2px 8px rgba(159,18,57,0.04);
border: 1.5px solid var(--border);
}
h2 {
margin: 0 0 1.2em 0;
font-size: 1.7em;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 1.2em;
}
.app-header h2 {
margin: 0;
}
.icon-button {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 8px;
font-size: 1.35em;
line-height: 1;
}
.settings-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 6000;
background: rgba(104, 17, 40, 0.34);
align-items: flex-start;
justify-content: flex-end;
padding: 28px;
box-sizing: border-box;
}
.settings-overlay.active {
display: flex;
}
.settings-panel {
width: min(360px, calc(100vw - 56px));
background: #fff;
border: 2px solid var(--border);
border-radius: 8px;
box-shadow: 0 10px 34px rgba(104,17,40,0.18);
padding: 1.2em;
box-sizing: border-box;
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 1em;
}
.settings-header h3 {
margin: 0;
color: var(--accent);
font-size: 1.2em;
}
.settings-close {
width: 34px;
height: 34px;
padding: 0;
border-radius: 8px;
background: #fff1f2;
color: var(--accent);
}
.settings-panel input[type="password"],
.settings-panel input[type="text"],
.settings-panel select {
width: 100%;
box-sizing: border-box;
font-size: 1.02em;
padding: 0.7em 1.2em;
border: 2px solid var(--border);
border-radius: 7px;
background: #fff1f2;
color: #681128;
}
.settings-panel label {
display: block;
color: #7b1930;
font-weight: 650;
margin-top: 0.9em;
margin-bottom: 0.35em;
}
.settings-actions {
display: flex;
justify-content: flex-end;
gap: 0.7em;
margin-top: 1em;
}
.settings-actions .secondary {
background: #fff1f2;
color: var(--accent);
}
.settings-status {
min-height: 1.3em;
color: #7b1930;
font-size: 0.95em;
margin-top: 0.75em;
}
.setting-check {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 0.8em;
color: #7b1930;
font-size: 0.95em;
}
.settings-note {
margin-top: 1em;
padding: 0.85em;
border: 1px solid var(--border);
border-radius: 7px;
background: #fff7f8;
color: #681128;
font-size: 0.9em;
line-height: 1.35;
}
.settings-note strong {
display: block;
margin-bottom: 0.35em;
}
.settings-note ul {
margin: 0.35em 0 0 1.15em;
padding: 0;
}
.section {
margin-bottom: 2.2em;
}
/* Eingabe- und Button-Zeile */
.input-row {
display: flex;
align-items: center;
gap: 1em;
}
input[type="text"] {
flex: 1 1 210px;
width: auto;
min-width: 90px;
font-size: 1.07em;
padding: 0.7em 1.2em;
border: 2px solid var(--border);
border-radius: 7px;
background: #fff1f2;
transition: border 0.2s;
color: #681128;
}
input[type="text"]:focus {
outline: none;
border-color: var(--accent);
}
button {
font-size: 1.05em;
font-weight: 500;
padding: 0.6em 1.6em;
margin-left: 0;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
/*box-shadow: 0 1.5px 6px rgba(159,18,57,0.09);*/
transition: background 0.18s, box-shadow 0.15s;
}
button:disabled {
background: #ffd5de;
color: #fff;
cursor: not-allowed;
}
.spinner {
display: none;
margin-left: 1em;
font-size: 1em;
color: var(--accent);
}
.spinner.active {
display: inline-block;
}
label {
font-weight: 600;
color: #7b1930;
margin-bottom: 0.5em;
display: block;
font-size: 1.02em;
}
/*
#moreBtn {
display: none;
margin-top: 0.6em;
margin-bottom: 0;
align-self: flex-end;
}*/
/* JSON-Output + Button rechts unten */
.json-row {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.json-row-bottom {
display: flex;
width: 100%;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 4px;
}
#tokenCounter {
font-size: 0.97em;
color: #7b1930;
margin-bottom: 6px;
margin-top: 0;
text-align: left;
align-self: flex-start;
}
#moreBtn {
margin-left: 1.2em;
margin-right: 0;
}
#json_out {
background: #fff1f2;
padding: 1em 1.1em;
border: 2px solid var(--border);
border-radius: 7px;
width: 100%;
height: 284px;
resize: none;
font-family: 'JetBrains Mono', 'Fira Mono', 'Menlo', monospace;
font-size: 1em;
margin: 0 auto 0.7em;
box-sizing: border-box;
color: #681128;
transition: border 0.2s;
}
#json_out:focus {
outline: none;
border-color: var(--accent);
}
.section:has(#llm_out:empty) {
display: none !important;
}
#llm_out {
background: #fff1f2;
padding: 1em 1.1em;
border: 2px solid var(--border);
border-radius: 7px;
max-height: 220px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'JetBrains Mono', 'Fira Mono', 'Menlo', monospace;
font-size: 1em;
margin-bottom: 1.1em;
margin-top: 0.2em;
color: #7b1930;
}
/* Gallery: immer 4 pro Zeile, keine Media-Queries! */
.images,
.models {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 0;
padding: 0; /* Fix für 3D Thumbnails rechtsbündig */
background: var(--bg-sidebar); /* Das gleiche Rosa wie Textbox */
border-radius: 0 0 7px 7px;
border: 2px solid var(--border);
border-top: 0;
padding: 16px; /* Etwas Luft oben/unten */
}
.images img,
.model-thumb img {
width: 100%;
aspect-ratio: 1/1;
object-fit: cover;
border: 0px;
/*border: 2px solid var(--border);*/
border-radius: 7px;
cursor: pointer;
background: var(--bg-sidebar);
transition: box-shadow 0.18s, border-color 0.14s;
box-sizing: border-box;
}
.images img:hover,
.model-thumb img:hover {
box-shadow: 0 0 0 3px #fda4af65;
border-color: var(--accent);
}
.model-thumb img {
display: flex;
align-items: center;
justify-content: center;
margin: 0; /* Kein Offset mehr */
}
/* Kontextmenü */
.ctxMenu, .ctx-menu, #ctxMenu {
position: absolute;
z-index: 5000;
background: #fff;
border: 2px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(159,18,57,0.10);
display: none;
min-width: 164px;
font-size: 1em;
overflow: hidden;
}
.ctxMenu div, .ctx-menu div, #ctxMenu div {
padding: 0.77em 1.21em;
cursor: pointer;
transition: background 0.13s;
}
.ctxMenu div:hover, .ctx-menu div:hover, #ctxMenu div:hover {
background: #fff1f2;
color: var(--accent);
}
/* Tabs */
.tabs {
display: flex;
gap: 1.4em;
margin-bottom: 0;
}
.tab {
background: var(--border);
border: none;
padding: 0.72em 2.2em;
cursor: pointer;
border-radius: 8px 8px 0 0;
font-weight: 600;
font-size: 1.11em;
color: #b91c3a;
transition: background 0.13s, color 0.11s;
border: 2px solid var(--border);
border-bottom: 0px;
z-index: 0;
transform: translateY(2px); /* 1px nach unten */
}
.tab.active {
background: #fff1f2;
color: var(--accent);
z-index: 2;
}
/* Lightbox & Overlays identisch für Bilder & 3D! */
#lightboxOverlay,
#lightbox3DOverlay {
display: none;
position: fixed;
z-index: 3000;
top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(159,18,57,0.84) !important;
justify-content: center;
align-items: center;
transition: background 0.18s;
}
#lightboxContent,
#lightbox3DContent {
position: relative;
max-width: 96vw;
max-height: 96vh;
margin: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#lightboxImg,
#lightbox3DViewport {
max-width: 92vw;
max-height: 87vh;
border-radius: 12px;
background: #fff1f2;
border: 2px solid var(--border);
box-shadow: 0 5px 34px rgba(159,18,57,0.14);
display: block;
margin-bottom: 0;
/* Damit Canvas & Img gleich aussehen */
box-sizing: border-box;
/* zentrieren im flex-Container */
align-self: center;
}
/* X-Button immer oben rechts */
#lightboxClose,
#lightbox3DClose {
position: fixed;
top: 24px;
right: 32px;
z-index: 3100;
background: none;
border: none;
color: var(--accent);
font-size: 2.3em;
font-weight: 500;
padding: 0;
cursor: pointer;
box-shadow: none;
line-height: 1;
transition: color 0.18s;
}
#lightboxClose:hover,
#lightbox3DClose:hover {
color: #fd4c77;
}
/* Pfeile immer 7vw vom Rand, identisch in beiden Lightboxes */
#lightboxPrev,
#lightboxNext,
#lightbox3DPrev,
#lightbox3DNext {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 3100;
background: none;
border: none;
color: var(--accent);
font-size: 3em;
font-weight: 500;
padding: 0;
cursor: pointer;
box-shadow: none;
line-height: 1;
transition: color 0.17s;
pointer-events: auto;
user-select: none;
}
#lightboxPrev,
#lightbox3DPrev { left: 7vw; }
#lightboxNext,
#lightbox3DNext { right: 7vw; }
#lightboxPrev:hover,
#lightboxNext:hover,
#lightbox3DPrev:hover,
#lightbox3DNext:hover { color: #fd4c77; }
/* Klickbare Lightbox-Sides zum Navigieren */
.lightbox-nav-left,
.lightbox-nav-right {
position: fixed;
top: 0; bottom: 0;
width: 20vw;
z-index: 3099;
cursor: pointer;
}
.lightbox-nav-left { left: 0; }
.lightbox-nav-right { right: 0; }
#lightboxClose,
#lightbox3DClose,
#lightboxPrev,
#lightboxNext,
#lightbox3DPrev,
#lightbox3DNext {
color: #fd4c77 !important;
transition: color 0.18s;
}
#lightboxClose:hover,
#lightbox3DClose:hover,
#lightboxPrev:hover,
#lightboxNext:hover,
#lightbox3DPrev:hover,
#lightbox3DNext:hover {
color: var(--accent) !important;
}
button:focus { outline: none; }
textarea { resize: none !important; }
/* ---- 3D Lightbox identisch wie Image Lightbox ---- */
#lightbox3DOverlay {
display: none;
position: fixed;
z-index: 3000;
top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(255, 17, 62, 0.84);
justify-content: center;
align-items: center;
transition: background 0.18s;
}
#lightbox3DContent {
position: relative;
max-width: 96vw;
max-height: 96vh;
margin: auto;
display: flex;
flex-direction: column;
align-items: center;
}
#lightbox3DViewport {
max-width: 92vw;
max-height: 87vh;
border-radius: 12px;
background: #fff1f2;
border: 2px solid var(--border);
box-shadow: 0 5px 34px rgba(159,18,57,0.14);
display: block;
margin-bottom: 0;
overflow: hidden;
}
/* Das Canvas darin füllt den Viewport */
#lightbox3DViewport canvas {
width: 100% !important;
height: 100% !important;
display: block;
max-width: 100%;
max-height: 100%;
margin: 0 auto;
background: transparent;
}
/* X-Button  immer oben rechts, wie bei Bild-Lightbox */
#lightbox3DClose {
position: fixed;
top: 24px;
right: 32px;
z-index: 3100;
background: none;
border: none;
color: var(--accent);
font-size: 2.3em;
font-weight: 500;
padding: 0;
cursor: pointer;
box-shadow: none;
line-height: 1;
transition: color 0.18s;
}
#lightbox3DClose:hover {
color: #fd4c77;
}
/* Pfeile groß und wie bei Image-Lightbox */
#lightbox3DPrev,
#lightbox3DNext {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 3100;
background: none;
border: none;
color: var(--accent);
font-size: 3em;
font-weight: 500;
padding: 0.06em 0.28em;
cursor: pointer;
box-shadow: none;
line-height: 1;
border-radius: 50%;
transition: color 0.18s, background 0.13s, box-shadow 0.14s;
pointer-events: auto;
user-select: none;
}
#lightbox3DPrev { left: 3vw; }
#lightbox3DNext { right: 3vw; }
#lightbox3DPrev:hover,
#lightbox3DNext:hover {
color: #fff;
}
/* ----------- Placeholder / Kachel für Bild-Generierung ----------- */
.placeholder-tile {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
width: 100%;
background: linear-gradient(135deg, #fff1f2 60%, #ffdbe7 100%);
border: 2px dashed #fd4c77;
border-radius: 10px;
position: relative;
animation: placeholder-shimmer 1.1s linear infinite;
min-height: 120px;
min-width: 0;
box-sizing: border-box;
}
.placeholder-tile::after {
content: " ";
font-size: 2.1em;
color: #fda4af;
opacity: 0.8;
animation: placeholder-pulse 1.4s cubic-bezier(0.4,0,0.2,1) infinite;
}
@keyframes placeholder-shimmer {
0% { filter: brightness(1); }
50% { filter: brightness(1.07); }
100% { filter: brightness(1); }
}
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.images-section, .models-section {
display: flex;
flex-direction: column;
align-items: stretch;
}
#images-pagination, #models-pagination {
display: flex;
justify-content: center;
background: var(--bg-sidebar);
border-radius: 0 7px 0 0;
border: 2px solid var(--border);
border-bottom: none;
padding: 1em 0 0.2em 0;
}
#images-pagination .pagination,
#models-pagination .pagination {
display: flex;
gap: 1.1em;
align-items: center;
}
.images-section, .models-section {
transition: opacity 0.3s;
}
.images-section[style*="none"], .models-section[style*="none"] {
opacity: 0;
pointer-events: none;
}
.tab.active {
background: linear-gradient(90deg, #fff1f2 80%, #fda4af28 100%);
color: var(--accent);
box-shadow: 0 3px 18px #fda4af18;
}
.images:empty,
.models:empty {
display: none !important;
}
#gallery-wrapper,
#images-pagination,
#models-pagination {
display: none;
}
</style>
<script src="https://unpkg.com/three@0.125.2/build/three.min.js"></script>
<script src="https://unpkg.com/three@0.125.2/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://unpkg.com/three@0.125.2/examples/js/loaders/DRACOLoader.js"></script>
<script src="https://unpkg.com/three@0.125.2/examples/js/controls/OrbitControls.js"></script>
<!--
<script type="module">
import * as THREE from 'https://unpkg.com/three@0.165.0/build/three.module.js';
import { OrbitControls } from 'https://unpkg.com/three@0.165.0/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'https://unpkg.com/three@0.165.0/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'https://unpkg.com/three@0.165.0/examples/jsm/loaders/DRACOLoader.js';
// ...ab hier ist dein 3D-Code!
// Ersetze alle Vorkommen von window.THREE, window.GLTFLoader, window.DRACOLoader usw.
// durch die direkt importierten Objekte.
</script>-->
<script src="https://cdn.jsdelivr.net/npm/stackblur-canvas@2.2.0/dist/stackblur.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsonrepair@3.12.0/lib/umd/jsonrepair.min.js"></script>
</head>
<body>
<div class="container">
<div class="app-header">
<h2>3D-Model Generator</h2>
<button id="settingsBtn" class="icon-button" title="Settings" aria-label="Settings">&#9881;</button>
</div>
<!-- Input & “Generate Prompt” -->
<div class="section">
<div class="input-row">
<input id="obj" type="text" placeholder="Enter object name (e.g. low poly farmer)" />
<button id="promptBtn">Generate Prompt</button>
<span class="spinner" id="spin1">⏳ Generating...</span>
</div>
</div>
<!-- LLM Output -->
<div class="section">
<label>LLM Output</label>
<pre id="llm_out"></pre>
</div>
<!-- JSON Output (editable) -->
<div class="section">
<label>Diffusion Settings</label>
<div class="json-row">
<textarea id="json_out"></textarea>
<div class="json-row-bottom">
<div id="tokenCounter"></div>
<button id="moreBtn">Generate Images</button>
</div>
</div>
</div>
<!-- Gallery Tabs -->
<div id="gallery-wrapper">
<div class="tabs">
<button id="tabImages" class="tab active">Images</button>
<button id="tab3D" class="tab">3D Gallery</button>
</div>
<div class="section images-section" id="images-section">
<div id="images-pagination"></div>
<div class="images" id="images"></div>
</div>
<div class="section models-section" id="models-section" style="display:none;">
<div id="models-pagination"></div>
<div class="models" id="models"></div>
</div>
</div>
</div>
<div id="settingsOverlay" class="settings-overlay" aria-hidden="true">
<div class="settings-panel" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<div class="settings-header">
<h3 id="settingsTitle">Settings</h3>
<button id="settingsClose" class="settings-close" aria-label="Close settings">&times;</button>
</div>
<label for="imageGenerationBackend">Image Generator</label>
<select id="imageGenerationBackend">
<option value="local">Local (Diffusers)</option>
<option value="replicate">Replicate API</option>
</select>
<label for="replicateImageModel">Replicate Image Model</label>
<select id="replicateImageModel">
<option value="black-forest-labs/flux-schnell">FLUX.1 schnell - cheap drafts</option>
<option value="google/imagen-4-fast">Google Imagen 4 Fast - balanced</option>
<option value="bytedance/seedream-4">ByteDance Seedream 4 - higher quality</option>
</select>
<div class="settings-note">
<strong>Replicate pricing</strong>
Local Diffusers has no Replicate image charge. Replicate image options are billed per output: FLUX.1 schnell is $3 per 1000 images, Imagen 4 Fast is $0.02 per image, and Seedream 4 is $0.03 per image. Hunyuan 3D 3.1 model conversion is $0.16 per unit. Prices are listed by Replicate and may change.
</div>
<label for="replicateToken">Replicate API Token</label>
<input id="replicateToken" type="password" placeholder="r8_..." autocomplete="off" />
<label class="setting-check" for="showReplicateToken">
<input id="showReplicateToken" type="checkbox" />
Show key
</label>
<div id="settingsStatus" class="settings-status"></div>
<div class="settings-actions">
<button id="clearSettingsBtn" class="secondary">Clear</button>
<button id="saveSettingsBtn">Save</button>
</div>
</div>
</div>
<!-- Context Menu for rightclick on images -->
<div id="ctxMenu">
<div id="ctxEditExternal">Edit Externally</div>
<div id="ctxUseData">Use Generation Data</div>
<div id="ctxGenerate3D">Generate 3D Model</div>
<div id="ctxGenerate3DEqui">Generate 3D Model + HDRI</div>
</div>
<!-- Lightbox Overlay for 2D images -->
<div id="lightboxOverlay">
<div id="lightboxContent">
<button id="lightboxClose">&times;</button>
<button id="lightboxPrev">&#10094;</button>
<img id="lightboxImg" src="" alt="Full Image" />
<button id="lightboxNext">&#10095;</button>
</div>
</div>
<!-- Lightbox Overlay for 3D viewer -->
<div id="lightbox3DOverlay">
<div id="lightbox3DContent">
<button id="lightbox3DClose">&times;</button>
<button id="lightbox3DPrev">&#10094;</button>
<div id="lightbox3DViewport"></div>
<button id="lightbox3DNext">&#10095;</button>
</div>
</div>
<script>
// Basic references
const llm_out = document.getElementById("llm_out");
const json_out = document.getElementById("json_out");
const images = document.getElementById("images");
const promptBtn = document.getElementById("promptBtn");
const spinner = document.getElementById("spin1");
const moreBtn = document.getElementById("moreBtn");
let generateHandler, stopHandler;
let isGenerating = false;
const ctxMenu = document.getElementById("ctxMenu");
const ctxEditExt = document.getElementById("ctxEditExternal");
const ctxUseData = document.getElementById("ctxUseData");
const ctxGen3D = document.getElementById("ctxGenerate3D");
const ctxGen3DEqui = document.getElementById("ctxGenerate3DEqui");
const tabImages = document.getElementById("tabImages");
const tab3D = document.getElementById("tab3D");
const imagesDiv = document.getElementById("images");
const modelsDiv = document.getElementById("models");
const tokenCounter = document.getElementById('tokenCounter');
const imagesSection = document.querySelector('.images-section');
const modelsSection = document.getElementById('models-section');
const settingsBtn = document.getElementById("settingsBtn");
const settingsOverlay = document.getElementById("settingsOverlay");
const settingsClose = document.getElementById("settingsClose");
const imageGenerationBackend = document.getElementById("imageGenerationBackend");
const replicateImageModel = document.getElementById("replicateImageModel");
const replicateToken = document.getElementById("replicateToken");
const showReplicateToken = document.getElementById("showReplicateToken");
const settingsStatus = document.getElementById("settingsStatus");
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
const clearSettingsBtn = document.getElementById("clearSettingsBtn");
let generateForeverActive = false;
let generateForeverAbort = false;
let generateForeverMenu = null;
let activePlaceholders = null;
let hasSavedReplicateToken = false;
let lastValidDiffusionSettings = null;
function syncReplicateModelState() {
replicateImageModel.disabled = imageGenerationBackend.value !== "replicate";
}
async function refreshSettingsState() {
try {
if (!window.pywebview || !window.pywebview.api || !window.pywebview.api.get_settings) {
settingsStatus.textContent = "Settings are not ready yet.";
return;
}
const settings = await window.pywebview.api.get_settings();
hasSavedReplicateToken = Boolean(settings.has_replicate_api_token);
imageGenerationBackend.value = settings.image_generation_backend || "local";
replicateImageModel.value = settings.replicate_image_model || "google/imagen-4-fast";
syncReplicateModelState();
replicateToken.value = "";
replicateToken.placeholder = hasSavedReplicateToken ? "Token is set" : "r8_...";
settingsStatus.textContent = settings.replicate_api_token_source === "environment"
? "Replicate API token available from environment."
: hasSavedReplicateToken ? "Replicate API token saved." : "No Replicate API token saved.";
} catch (e) {
settingsStatus.textContent = "Could not load settings.";
}
}
async function openSettings() {
settingsOverlay.classList.add("active");
settingsOverlay.setAttribute("aria-hidden", "false");
await refreshSettingsState();
replicateToken.focus();
}
function closeSettings() {
settingsOverlay.classList.remove("active");
settingsOverlay.setAttribute("aria-hidden", "true");
replicateToken.value = "";
showReplicateToken.checked = false;
replicateToken.type = "password";
}
settingsBtn.addEventListener("click", openSettings);
settingsClose.addEventListener("click", closeSettings);
settingsOverlay.addEventListener("mousedown", (e) => {
if (e.target === settingsOverlay) closeSettings();
});
imageGenerationBackend.addEventListener("change", syncReplicateModelState);
showReplicateToken.addEventListener("change", () => {
replicateToken.type = showReplicateToken.checked ? "text" : "password";
});
saveSettingsBtn.addEventListener("click", async () => {
const token = replicateToken.value.trim();
saveSettingsBtn.disabled = true;
try {
const result = await window.pywebview.api.save_settings({
replicate_api_token: token,
image_generation_backend: imageGenerationBackend.value,
replicate_image_model: replicateImageModel.value
});
hasSavedReplicateToken = Boolean(result.has_replicate_api_token);
replicateToken.value = "";
replicateToken.placeholder = hasSavedReplicateToken ? "Token is set" : "r8_...";
syncReplicateModelState();
settingsStatus.textContent = hasSavedReplicateToken
? "Settings saved. Replicate API token is available."
: "Settings saved. Add a Replicate API token before using API generation.";
} catch (e) {
settingsStatus.textContent = "Could not save settings.";
} finally {
saveSettingsBtn.disabled = false;
}
});
clearSettingsBtn.addEventListener("click", async () => {
clearSettingsBtn.disabled = true;
try {
const result = await window.pywebview.api.clear_replicate_api_token();
hasSavedReplicateToken = Boolean(result.has_replicate_api_token);
replicateToken.value = "";
replicateToken.placeholder = hasSavedReplicateToken ? "Token is set" : "r8_...";
if (result.image_generation_backend) imageGenerationBackend.value = result.image_generation_backend;
if (result.replicate_image_model) replicateImageModel.value = result.replicate_image_model;
syncReplicateModelState();
settingsStatus.textContent = result.replicate_api_token_source === "environment"
? "Saved token cleared. Environment token is still active."
: "Replicate API token cleared.";
} catch (e) {
settingsStatus.textContent = "Could not clear settings.";
} finally {
clearSettingsBtn.disabled = false;
}
});
// Lightbox elements
const lightboxOverlay = document.getElementById("lightboxOverlay");
const lightboxImg = document.getElementById("lightboxImg");
const lightboxClose = document.getElementById("lightboxClose");
const lightboxPrev = document.getElementById("lightboxPrev");
const lightboxNext = document.getElementById("lightboxNext");
const lightbox3DOverlay = document.getElementById("lightbox3DOverlay");
const lightbox3DClose = document.getElementById("lightbox3DClose");
const lightbox3DPrev = document.getElementById("lightbox3DPrev");
const lightbox3DNext = document.getElementById("lightbox3DNext");
const lightbox3DViewport = document.getElementById("lightbox3DViewport");
let lastObject = "";
let ctxTargetFile = ""; // filepath under right-click
let currentIndex = -1; // index inside galleryFiles
let isBatchActive = false;
let is3DContext = false;
// For 3D gallery
let currentModelIndex = -1;
// Pagination
let currentPageImages = 1;
let imagesPerPage = 16;
let currentPage3D = 1;
let modelsPerPage = 16;
// --- Gallerie-Daten ---
let galleryFiles = [];
let modelGallery = [];
const DEFAULT_SETTINGS = {
"checkpoint": "RealismPlus/dreamshaper_8.safetensors",
"vae": "Automatic",
"sampler": "DPM++ 2M Karras",
"steps": 30,
"cfg_scale": 6.5,
"batch_count": 4
};
function mergeDiffusionSettings(userSettings, llmSettings) {
// User-Settings als Baseline!
let merged = { ...userSettings };
// Felder aus dem LLM überschreiben
["positive prompt", "negative prompt", "dimensions"].forEach(key => {
if (llmSettings[key] !== undefined) merged[key] = llmSettings[key];
});
// Felder ergänzen, die fehlen (nur falls auch im User-JSON nicht vorhanden!)
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (
merged[key] === undefined ||
merged[key] === null ||
(typeof defVal === "number" && isNaN(merged[key]))
) {
merged[key] = defVal;
}
}
// Dimensions prüfen
if (
!Array.isArray(merged.dimensions) ||
merged.dimensions.length !== 2 ||
merged.dimensions.some(x => typeof x !== "number" || !Number.isFinite(x))
) {
merged.dimensions = [768, 768];
}
return merged;
}
function updateTokenCounter() {
let prompt = "";
let approxTokens = 0;
try {
// Regulärer Weg (wenn das JSON schon valid ist)
let json = JSON.parse(sanitizeDiffusionJSON(json_out.value));
prompt = (json["positive prompt"] || "");
} catch {
// "Hacky" Extraction: Fallback für invalides JSON beim Streamen
// Suche nach: "positive prompt": "irgendwas
let raw = sanitizeDiffusionJSON(json_out.value);
let match = raw.match(/"positive prompt"\s*:\s*"([^"]*)/s); // /s = dotall (Zeilenumbruch inkludiert)
if (match) {
prompt = match[1];
}
}
approxTokens = Math.ceil(prompt.length / 4); // grobe Schätzung
tokenCounter.textContent = `Approx. ${approxTokens} / 77 Tokens used`;
tokenCounter.style.color = approxTokens > 77 ? "#b91c3a" : "#7b1930";
if (approxTokens > 77) tokenCounter.textContent += " ⚠️";
}
// Initialisieren bei Seite-Laden
window.addEventListener("DOMContentLoaded", updateTokenCounter);
// Bei jeder Änderung!
json_out.addEventListener("input", updateTokenCounter);
json_out.addEventListener("input", function () {
try {
// Teste, ob das Feld gerade valides JSON ist
JSON.parse(sanitizeDiffusionJSON(json_out.value));
lastValidDiffusionSettings = sanitizeDiffusionJSON(json_out.value);
// Optional: console.log("Valid JSON saved!");
} catch {
// Kein gültiges JSON tue nichts
}
});
function sanitizeDiffusionJSON(raw) {
// 1. Typografische Anführungszeichen ersetzen
let fixed = raw.replace(/[“”„«»]/g, '"').replace(/[]/g, "'");
// 2. JSON parsen
let data;
try {
data = JSON.parse(fixed);
} catch (e) {
// Wenn trotzdem kein valides JSON: original zurückgeben
return fixed;
}
// 3. Prompts als String erzwingen und Punkte als Kommas ersetzen
["positive prompt", "negative prompt"].forEach(key => {
if (Array.isArray(data[key])) {
data[key] = data[key].join(" ");
}
if (typeof data[key] === "string") {
// Ersetze ". " und punktuelle Satzenden durch Kommas, aber keine Punkte in Zahlen!
data[key] = data[key]
.replace(/\. +/g, ", ") // Punkt + Leerzeichen → Komma
.replace(/([a-zA-Z0-9\)])\.\s*$/gm, "$1") // Entfernt Punkt am Zeilenende
.replace(/([^\d])\.(?=[^,\d])/g, "$1,") // Punkte die KEINE Dezimalstellen sind → Komma
.replace(/,+/g, ",") // Mehrere Kommas hintereinander zu einem Komma
.replace(/^,|,$/g, ""); // Komma am Anfang/Ende entfernen
}
});
// 4. Wieder als hübsches JSON ausgeben
return JSON.stringify(data, null, 2);
}
// Hilfsfunktion zum Schließen aller Menüs
function closeAllContextMenus() {
ctxMenu.style.display = "none";
if (generateForeverMenu) generateForeverMenu.style.display = "none";
}
// Schließe alle Kontextmenüs bei jedem Klick außerhalb
document.addEventListener("mousedown", (e) => {
if (e.button !== 0) return; // Nur Linksklick
if (
!ctxMenu.contains(e.target) &&
(!generateForeverMenu || !generateForeverMenu.contains(e.target))
) {
closeAllContextMenus();
}
});
// Auch bei jedem Rechtsklick schließen
document.addEventListener("contextmenu", (e) => {
if (
!ctxMenu.contains(e.target) &&
(!generateForeverMenu || !generateForeverMenu.contains(e.target))
) {
closeAllContextMenus();
}
});
// Auch bei ESC-Taste schließen
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeAllContextMenus();
if (settingsOverlay.classList.contains("active")) closeSettings();
}
});
lightbox3DViewport.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
// Setzt das Kontextfile auf das aktuell angezeigte Model-Image (Preview)
if (currentModelIndex >= 0 && modelGallery[currentModelIndex]) {
ctxTargetFile = modelGallery[currentModelIndex].img; // Das PNG-Preview!
// Zeigt das reduzierte Kontextmenü an
is3DContext = true;
ctxEditExt.style.display = "block";
ctxUseData.style.display = "block";
ctxGen3D.style.display = "none";
ctxGen3DEqui.style.display = "none";
ctxMenu.style.top = e.pageY + "px";
ctxMenu.style.left = e.pageX + "px";
ctxMenu.style.display = "block";
}
});
// --- Pagination Helper ---
function getPaginated(arr, page, perPage) {
const start = (page - 1) * perPage;
return arr.slice(start, start + perPage);
}
function getTotalPages(arr, perPage) {
return Math.max(1, Math.ceil(arr.length / perPage));
}
stopHandler = () => {
moreBtn.textContent = "Stopping…";
moreBtn.disabled = true;
window.pywebview.api.stop_generation();
// Wait for backend to confirm before resetting button (see below)
};
generateHandler = () => {
// 1. Versuche das aktuelle JSON zu parsen
let currentJSON = sanitizeDiffusionJSON(json_out.value).trim();
let validJSON = null;
try {
JSON.parse(currentJSON);
validJSON = currentJSON;
// Optional: lastValidDiffusionSettings = currentJSON; // falls du hier nochmal sicherst
} catch {
// Wenn kaputt: Das letzte valide nehmen!
if (lastValidDiffusionSettings) {
validJSON = lastValidDiffusionSettings;
json_out.value = lastValidDiffusionSettings; // Zeige es dem User!
updateTokenCounter();
console.log("Ungültiges JSON erkannt! Die letzten gültigen Einstellungen wurden verwendet.");
} else {
validJSON = defaultJSON;
json_out.value = defaultJSON;
updateTokenCounter();
console.log("JSON war ungültig, Defaults werden benutzt.");
}
}
if (!validJSON) {
alert("Kann kein valides JSON finden.");
return;
}
// Button-UI usw. wie gehabt:
isGenerating = true;
moreBtn.textContent = "Generating... (Stop)";
moreBtn.removeEventListener("click", generateHandler);
moreBtn.addEventListener("click", stopHandler);
moreBtn.disabled = false;
promptBtn.disabled = true;
window.pywebview.api.generate_images(sanitizeDiffusionJSON(validJSON));
};
// Funktion zum Zurücksetzen der UI nach Ende / Error:
function endGenerateUI() {
isGenerating = false;
if(!generateForeverActive){
moreBtn.textContent = "Generate Images";
moreBtn.removeEventListener("click", stopHandler);
moreBtn.addEventListener("click", generateHandler); // re-attach generate
moreBtn.disabled = false; // make it clickable again
promptBtn.disabled = false;
}
}
// Bei Laden binde den Handler:
moreBtn.addEventListener("click", generateHandler);
// --- Images Gallery Rendering mit Pagination ---
function renderImagesGallery() {
images.innerHTML = "";
// Wenn auf Seite 1 und Placeholders aktiv, zeige sie zuerst!
let placeholderCount = 0;
if (currentPageImages === 1 && activePlaceholders && activePlaceholders.count > 0) {
placeholderCount = activePlaceholders.count;
for (let i = 0; i < placeholderCount; i++) {
const placeholder = document.createElement("div");
placeholder.className = "placeholder-tile";
placeholder.dataset.placeholderIndex = i;
placeholder.dataset.batchId = activePlaceholders.batchId;
images.appendChild(placeholder);
}
}
// Dann normale Bilder, aber auf Seite 1 Platz für die Tiles abziehen!
const start = (currentPageImages - 1) * imagesPerPage;
let filesToShow = galleryFiles.slice(start, start + imagesPerPage - placeholderCount);
filesToShow.forEach(item => {
addImageToGallery(item.filepath);
});
renderImagesPagination(getTotalPages(galleryFiles, imagesPerPage));
updateTabsVisibility();
}
// Helper zum Tab-Button ein/ausblenden
function updateTabsVisibility() {
const galleryWrapper = document.getElementById('gallery-wrapper');
const hasImages = galleryFiles && galleryFiles.length > 0;
const hasPlaceholders = activePlaceholders && activePlaceholders.count > 0;
const hasModels = modelGallery && modelGallery.length > 0;
if (!hasImages && !hasModels && !hasPlaceholders) {
galleryWrapper.style.display = "none";
return;
} else {
galleryWrapper.style.display = "block";
}
// 3D Gallery Tab Handling
if (hasModels) {
tab3D.style.display = "";
} else {
tab3D.style.display = "none";
// Falls aktuell 3D aktiv, wechsel auf Images
if (tab3D.classList.contains("active")) {
tabImages.classList.add("active");
tab3D.classList.remove("active");
imagesSection.style.display = (hasImages || hasPlaceholders) ? "flex" : "none";
modelsSection.style.display = "none";
}
}
// Images Tab Handling
if (hasImages || hasPlaceholders) {
tabImages.style.display = "";
imagesSection.style.display = "flex";
} else {
tabImages.style.display = "none";
imagesSection.style.display = "none";
// Falls aktuell Images aktiv, wechsel auf 3D (falls da!)
if (tabImages.classList.contains("active")) {
if (hasModels) {
tab3D.classList.add("active");
tabImages.classList.remove("active");
modelsSection.style.display = "flex";
}
}
}
if (tabImages.classList.contains("active")) {
imagesSection.style.display = "flex";
modelsSection.style.display = "none";
} else if (tab3D.classList.contains("active")) {
imagesSection.style.display = "none";
modelsSection.style.display = "flex";
}
}
function updateImagesSectionVisibility() {
const section = document.querySelector('.images-section');
if (galleryFiles && galleryFiles.length > 0) {
section.style.display = "flex";
tabImages.style.display = "";
} else {
section.style.display = "none";
tabImages.style.display = "none";
// Wenn gerade der Tab aktiv ist, auf 3D schalten (falls da was da ist):
if (tabImages.classList.contains("active")) {
if (modelGallery && modelGallery.length > 0) {
tab3D.classList.add("active");
tabImages.classList.remove("active");
document.getElementById('models-section').style.display = "flex";
renderModelsGallery();
updateTabsVisibility();
}
}
}
}
function renderImagesPagination(totalPages) {
const pagDiv = document.getElementById('images-pagination');
pagDiv.innerHTML = "";
if (totalPages <= 1) {
pagDiv.style.display = "none";
return;
}
let pag = document.createElement("div");
pag.className = "pagination";
pag.innerHTML = `
<button ${currentPageImages === 1 ? "disabled" : ""} id="imgPrevPg">&lt; Previous</button>
Page ${currentPageImages} / ${totalPages}
<button ${currentPageImages === totalPages ? "disabled" : ""} id="imgNextPg">Next &gt;</button>
`;
pagDiv.appendChild(pag);
if (currentPageImages > 1) {
pag.querySelector("#imgPrevPg").onclick = () => { currentPageImages--; renderImagesGallery(); }
}
if (currentPageImages < totalPages) {
pag.querySelector("#imgNextPg").onclick = () => { currentPageImages++; renderImagesGallery(); }
}
pagDiv.style.display = "flex";
}
// --- 3D Gallery Rendering mit Pagination ---
function renderModelsGallery() {
modelsDiv.innerHTML = "";
const totalPages = getTotalPages(modelGallery, modelsPerPage);
if (currentPage3D > totalPages) currentPage3D = totalPages;
const visible = getPaginated(modelGallery, currentPage3D, modelsPerPage);
visible.forEach((entry, i) => {
const thumb = document.createElement("div");
thumb.className = "model-thumb";
const img = document.createElement("img");
img.src = entry.img;
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "cover";
thumb.appendChild(img);
thumb.addEventListener("click", () => open3DLightbox((currentPage3D-1)*modelsPerPage + i));
thumb.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
ctxTargetFile = entry.img;
showContextMenu(e.pageX, e.pageY);
});
modelsDiv.appendChild(thumb);
});
renderModelsPagination(totalPages);
}
function renderModelsPagination(totalPages) {
const pagDiv = document.getElementById('models-pagination');
const modelsGrid = document.getElementById('models');
// Klassen für Borderradius/Styling zuerst resetten
modelsGrid.classList.remove("rounded", "with-pagination");
pagDiv.innerHTML = "";
if (totalPages <= 1) {
pagDiv.style.display = "none";
modelsGrid.classList.add("rounded");
return;
}
pagDiv.style.display = "flex";
modelsGrid.classList.add("with-pagination");
let pag = document.createElement("div");
pag.className = "pagination";
pag.innerHTML = `
<button ${currentPage3D === 1 ? "disabled" : ""} id="modPrevPg">&lt; Previous</button>
Page ${currentPage3D} / ${totalPages}
<button ${currentPage3D === totalPages ? "disabled" : ""} id="modNextPg">Next &gt;</button>
`;
pagDiv.appendChild(pag);
if (currentPage3D > 1) {
pag.querySelector("#modPrevPg").onclick = () => { currentPage3D--; renderModelsGallery(); }
}
if (currentPage3D < totalPages) {
pag.querySelector("#modNextPg").onclick = () => { currentPage3D++; renderModelsGallery(); }
}
}
// ------------- Default JSON Template -------------
const defaultJSON = `
{
"positive prompt": "",
"negative prompt": "",
"dimensions": [768, 768],
"checkpoint": "RealismPlus/dreamshaper_8.safetensors",
"vae": "Automatic",
"sampler": "DPM++ 2M Karras",
"steps": 30,
"cfg_scale": 6.5,
"batch_count": 4
}`;
// ------------- Reset Text Outputs -------------
function resetTextOutputs() {
llm_out.textContent = "";
//json_out.value = defaultJSON;
updateTokenCounter();
llm_out.style.display = "block";
moreBtn.disabled = true;
}
// ------------- “Generate Prompt” handler -------------
promptBtn.addEventListener("click", () => {
const objectName = document.getElementById("obj").value.trim();
if (!objectName) return;
lastObject = objectName;
resetTextOutputs();
spinner.classList.add("active");
promptBtn.disabled = true;
moreBtn.disabled = true;
window.pywebview.api.generate_prompt(objectName);
});
document.getElementById("obj").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
promptBtn.click();
}
});
window.addEventListener('pywebviewready', function() {
json_out.value = defaultJSON;
updateTokenCounter();
refreshSettingsState();
window.pywebview.api.get_initial_images().then((items) => {
galleryFiles = items;
currentPageImages = 1;
// Setze den Images-Tab aktiv, falls noch keiner aktiv ist
tabImages.classList.add("active");
tab3D.classList.remove("active");
renderImagesGallery(); // <- Füllt die Gallery und setzt .images-section sichtbar wenn nötig!
imagesSection.style.display = "flex"; // <-- Explizit sichtbar machen!
modelsSection.style.display = "none";
updateTabsVisibility();
if (galleryFiles.length > 0) moreBtn.style.display = "inline-block";
});
window.pywebview.api.get_3d_models().then(list => {
modelGallery = list;
// Nur sichtbar schalten, falls der Tab aktiv ist oder gar kein Images-Content da ist
if (tab3D.classList.contains("active")) {
modelsSection.style.display = "flex";
imagesSection.style.display = "none";
}
updateTabsVisibility();
});
// (… der Rest: foreverMenu etc. ...)
});
// ------------- Load existing images on startup -------------
window.onload = () => {
json_out.value = defaultJSON;
updateTokenCounter();
// ------------- Generate Forever --------------
generateForeverMenu = document.createElement("div");
generateForeverMenu.style.position = "absolute";
generateForeverMenu.style.display = "none";
generateForeverMenu.style.background = "#fff";
generateForeverMenu.style.border = "1px solid #ccc";
generateForeverMenu.style.borderRadius = "7px";
generateForeverMenu.style.zIndex = 9999;
generateForeverMenu.innerHTML = '<div id="toggleForeverBtn"></div>';
document.body.appendChild(generateForeverMenu);
let onImageGenDoneResolve = null; // Zum "Warten" auf Fertigstellung
moreBtn.addEventListener("contextmenu", function (e) {
e.preventDefault();
e.stopPropagation();
// Setze speziellen Modus für „Generate Forever“
is3DContext = false;
ctxTargetFile = null; // Nicht relevant
// Nur diesen Eintrag anzeigen
ctxEditExt.style.display = "none";
ctxUseData.style.display = "none";
ctxGen3D.style.display = "none";
ctxGen3DEqui.style.display = "none";
// Den ersten Eintrag als „Generate Forever“ benutzen!
let foreverBtn = ctxMenu.querySelector("#ctxGenerateForever");
if (!foreverBtn) {
foreverBtn = document.createElement("div");
foreverBtn.id = "ctxGenerateForever";
ctxMenu.prepend(foreverBtn);
}
foreverBtn.textContent = generateForeverActive ? "Stop Generate Forever" : "Generate Forever";
foreverBtn.style.display = "block";
// Hide all others!
for (const div of ctxMenu.querySelectorAll("div")) {
if (div !== foreverBtn &&
div !== ctxEditExt &&
div !== ctxUseData &&
div !== ctxGen3D &&
div !== ctxGen3DEqui) {
div.style.display = "none";
}
}
// Show Menü:
ctxMenu.style.top = e.pageY + "px";
ctxMenu.style.left = e.pageX + "px";
ctxMenu.style.display = "block";
});
document.addEventListener("click", function () {
generateForeverMenu.style.display = "none";
});
// Handler für Generate Forever
ctxMenu.addEventListener("click", function(e) {
if (e.target.id === "ctxGenerateForever") {
ctxMenu.style.display = "none";
if (generateForeverActive) {
generateForeverAbort = true;
generateForeverActive = false;
stopHandler();
} else {
generateForeverActive = true;
generateForeverAbort = false;
generateForeverLoop();
}
moreBtn.textContent = generateForeverActive ? "🌀 Generating Forever... (Stop)" : "Generate Images";
}
});
// Überschreibe window.on_image_gen_done, damit es auch Promises auflösen kann:
const origOnImageGenDone = window.on_image_gen_done || function(){};
window.on_image_gen_done = function () {
endGenerateUI();
if (typeof onImageGenDoneResolve === "function") {
onImageGenDoneResolve();
onImageGenDoneResolve = null;
}
origOnImageGenDone();
};
// ... window.on_error wie gehabt
async function generateForeverLoop() {
while (generateForeverActive && !generateForeverAbort) {
const editedJSON = sanitizeDiffusionJSON(json_out.value).trim();
if (!editedJSON) {
alert("JSON is empty or invalid.");
break;
}
//moreBtn.disabled = true;
promptBtn.disabled = true;
let errorDetected = false;
const origOnError = window.on_error;
window.on_error = function(msg) {
alert("Error: " + msg);
errorDetected = true;
if (origOnError) origOnError(msg);
};
await window.pywebview.api.generate_images(sanitizeDiffusionJSON(editedJSON));
// Jetzt WARTEN bis das Backend das "fertig"-Signal gibt:
await new Promise(resolve => { onImageGenDoneResolve = resolve; });
// Fertig!
// Wenn Error kam, abbrechen:
if (errorDetected){
// promptBtn.disabled = false;
// moreBtn.disabled = false;
break;
}
// Optional Pause:
await new Promise(resolve => setTimeout(resolve, 1000));
}
generateForeverActive = false;
}
};
// --- Tab Handling ---
tabImages.addEventListener("click", () => {
tabImages.classList.add("active");
tab3D.classList.remove("active");
imagesSection.style.display = "flex";
modelsSection.style.display = "none";
renderImagesGallery();
});
tab3D.addEventListener("click", () => {
tab3D.classList.add("active");
tabImages.classList.remove("active");
imagesSection.style.display = "none";
modelsSection.style.display = "flex";
window.pywebview.api.get_3d_models().then(list => {
modelGallery = list;
currentPage3D = 1;
renderModelsGallery();
updateTabsVisibility();
});
});
function load3DGallery() {
modelsDiv.innerHTML = "";
window.pywebview.api.get_3d_models()
.then(list => {
modelGallery = list;
list.forEach((entry, i) => {
const thumb = document.createElement("div");
thumb.className = "model-thumb";
// Show a still image as preview (using the PNG)
const img = document.createElement("img");
img.src = entry.img;
img.style.width = "100%";
img.style.height = "100%";
img.style.objectFit = "cover";
thumb.appendChild(img);
// Left-click opens 3D lightbox
thumb.addEventListener("click", () => open3DLightbox(i));
// Right-click shows same context menu
thumb.addEventListener("contextmenu", (e) => {
e.preventDefault();
ctxTargetFile = entry.img;
showContextMenu(e.pageX, e.pageY);
});
modelsDiv.appendChild(thumb);
});
});
}
// ------------- Callbacks from Python -------------
function extractField(streamText, fieldName) {
// Extrahiere den Wert eines Felds wie "positive prompt": "..."
const regex = new RegExp(`"${fieldName.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')}"\\s*:\\s*"([^"]*)`, "s");
const match = streamText.match(regex);
return match ? match[1] : undefined;
}
function extractArrayField(streamText, fieldName) {
// Holt das Array wie [512, 768] oder [ 512,768 ] oder [512 , 768] (egal wie viele Leerzeichen)
const regex = new RegExp(
`"${fieldName.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')}"\\s*:\\s*\$begin:math:display$\\\\s*(\\\\d+)\\\\s*,\\\\s*(\\\\d+)\\\\s*\\$end:math:display$`
);
const match = streamText.match(regex);
if (match) {
console.log("Dimensions erkannt:", match[1], match[2]);
return [parseInt(match[1], 10), parseInt(match[2], 10)];
} else {
// Fallback: Finde Array irgendwo im Text (z.B. falls das Feld anders formatiert ist)
const anyArr = streamText.match(/\[\s*(\d+)\s*,\s*(\d+)\s*\]/);
if (anyArr) {
console.log("Fallback-Dimensions:", anyArr[1], anyArr[2]);
return [parseInt(anyArr[1], 10), parseInt(anyArr[2], 10)];
}
}
return undefined;
}
window.on_llm_output = (txt) => {
llm_out.textContent = txt;
};
window.on_llm_done = () => { /* no-op */ };
window.on_json_output = (jsonText) => {
// Nutze das letzte gültige Diffusions-JSON als Basis!
let base = {};
try {
base = JSON.parse(lastValidDiffusionSettings ?? defaultJSON);
} catch {
base = JSON.parse(defaultJSON);
}
// Prompt-Felder (aus Streaming-JSON)
const positive = extractField(jsonText, "positive prompt");
const negative = extractField(jsonText, "negative prompt");
const dimensions = extractArrayField(jsonText, "dimensions");
if (positive !== undefined) base["positive prompt"] = positive;
if (negative !== undefined) base["negative prompt"] = negative;
if (dimensions !== undefined) base["dimensions"] = dimensions;
json_out.value = sanitizeDiffusionJSON(JSON.stringify(base, null, 2));
updateTokenCounter();
};
window.on_json_done = () => {
let current;
try {
current = JSON.parse(sanitizeDiffusionJSON(json_out.value));
} catch {
// Wiederherstellen:
if (lastValidDiffusionSettings) {
// >>> Userwerte aus dem letzten validen Zustand nehmen <<<
json_out.value = lastValidDiffusionSettings;
updateTokenCounter();
alert("Ungültiges JSON erkannt! Die letzten gültigen Einstellungen wurden wiederhergestellt.");
} else {
json_out.value = defaultJSON;
updateTokenCounter();
alert("Fehler und keine alten gültigen Einstellungen vorhanden. Defaults wurden geladen.");
}
return;
}
// Jetzt: Felder nur ergänzen, falls sie wirklich fehlen:
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (
current[key] === undefined ||
current[key] === null ||
(typeof defVal === "number" && isNaN(current[key]))
) {
current[key] = defVal;
}
}
if (
!Array.isArray(current.dimensions) ||
current.dimensions.length !== 2 ||
current.dimensions.some(x => typeof x !== "number" || !Number.isFinite(x))
) {
current.dimensions = [768, 768];
}
json_out.value = sanitizeDiffusionJSON(JSON.stringify(current, null, 2));
updateTokenCounter();
lastValidDiffusionSettings = json_out.value;
spinner.classList.remove("active");
promptBtn.disabled = false;
moreBtn.style.display = "inline-block";
moreBtn.disabled = false;
};
window.on_generation_aborted = () => {
// stop the forever loop for good:
generateForeverAbort = true;
generateForeverActive = false;
endGenerateUI();
// also remove any stale placeholders:
// window.pywebview.api.remove_placeholders(/* pass last batch_id if you store it */);
};
// --- Backend-Callbacks (relevanter Teil) ---
window.on_image_updated = function(filepath, dataUri) {
window.pywebview.api.get_initial_images().then((items) => {
galleryFiles = items;
renderImagesGallery();
updateTabsVisibility();
if (galleryFiles.length > 0) moreBtn.style.display = "inline-block";
});
};
window.on_new_image = (filepath/*, dataUri*/) => {
galleryFiles.unshift({ filepath });
currentPageImages = 1;
renderImagesGallery();
updateTabsVisibility(); // ← HIER hinzufügen!
moreBtn.style.display = "inline-block";
};
window.on_image_gen_done = () => {
if(!generateForeverActive){
spinner.classList.remove("active");
promptBtn.disabled = false;
moreBtn.style.display = "inline-block";
moreBtn.disabled = false;
}
};
window.on_error = (msg) => {
alert("Error: " + msg);
endGenerateUI();
spinner.classList.remove("active");
promptBtn.disabled = false;
moreBtn.disabled = false;
};
// --- 3D-Update nach Generierung ---
window.on_3d_generated = (glbPath) => {
// 3D-Liste neu laden & auf Seite 1 zurückspringen
window.pywebview.api.get_3d_models().then(list => {
modelGallery = list;
currentPage3D = 1;
renderModelsGallery();
updateTabsVisibility();
});
alert("3D model generated:\n" + glbPath);
};
// --- KACHELN: Backend-gesteuert ---
window.show_placeholders = function(count, batch_id) {
activePlaceholders = { batchId: batch_id, count: count };
isBatchActive = true;
// Placeholder-Tiles werden NUR noch beim Rendern erzeugt, nicht sofort ins DOM!
if (currentPageImages === 1) renderImagesGallery();
updateTabsVisibility();
setTimeout(() => {
if (window.pywebview && window.pywebview.api && window.pywebview.api.placeholders_ready) {
window.pywebview.api.placeholders_ready(batch_id);
}
}, 0);
};
window.replace_placeholder = function(idx, filepath, batch_id) {
// Nur State ändern, NICHT mehr direkt im DOM
if (activePlaceholders && activePlaceholders.batchId === batch_id) {
activePlaceholders.count--;
if (activePlaceholders.count <= 0) activePlaceholders = null;
}
galleryFiles.unshift({ filepath });
// Nur Seite 1 neu rendern:
if (currentPageImages === 1) renderImagesGallery();
updateTabsVisibility();
};
window.remove_placeholders = function(batch_id) {
isBatchActive = false;
if (activePlaceholders && activePlaceholders.batchId === batch_id) {
activePlaceholders = null;
}
renderImagesGallery();
updateTabsVisibility();
};
// ------------- Gallery Helpers & Context Menu -------------
function addImageToGallery(filepath) {
const img = document.createElement("img");
img.src = filepath; // direkt die URL
img.loading = "lazy"; // lazy-load
img.decoding= "async"; // asynchron dekodieren
img.dataset.filepath = filepath;
img.addEventListener("click", () => {
const idx = galleryFiles.findIndex(o => o.filepath === filepath);
if (idx >= 0) openLightbox(idx);
});
img.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
ctxTargetFile = filepath;
showContextMenu(e.pageX, e.pageY);
});
images.appendChild(img);
}
// Patch alle Context-Menu-Show-Events:
function showContextMenu(x, y) {
// Kontext: 2D/3D bestimmen
is3DContext = modelsDiv.style.display === "grid" || lightbox3DOverlay.style.display === "flex";
// Toggle Menü-Einträge:
ctxEditExt.style.display = "block";
ctxUseData.style.display = "block";
ctxGen3D.style.display = is3DContext ? "none" : "block";
ctxGen3DEqui.style.display = is3DContext ? "none" : "block";
// "Generate Forever"-Button ausblenden:
let foreverBtn = ctxMenu.querySelector("#ctxGenerateForever");
if (foreverBtn) foreverBtn.style.display = "none";
// Show:
ctxMenu.style.top = y + "px";
ctxMenu.style.left = x + "px";
ctxMenu.style.display = "block";
}
window.addEventListener("click", () => {
ctxMenu.style.display = "none";
});
// “Edit Externally”
ctxEditExt.addEventListener("click", () => {
if (!ctxTargetFile) return;
window.pywebview.api.edit_external(ctxTargetFile);
});
// “Use Generation Data”
ctxUseData.addEventListener("click", async () => {
if (!ctxTargetFile) return;
try {
const response = await window.pywebview.api.get_image_json(ctxTargetFile);
let parsed;
try {
parsed = JSON.parse(sanitizeDiffusionJSON(response)); // <- SANITIZE!);
} catch {
json_out.value = sanitizeDiffusionJSON(response); // <- SANITIZE!
updateTokenCounter();
return;
}
if (parsed.error) {
alert("Error reading metadata: " + parsed.error);
} else {
// List of allowed keys for diffusion
const allowed = [
"positive prompt",
"negative prompt",
"dimensions",
"checkpoint",
"vae",
"sampler",
"steps",
"cfg_scale",
"batch_count"
];
// Only keep allowed keys
const filtered = {};
for (const key of allowed) {
if (parsed.hasOwnProperty(key)) filtered[key] = parsed[key];
}
json_out.value = sanitizeDiffusionJSON(JSON.stringify(filtered, null, 2));
updateTokenCounter();
}
} catch (e) {
alert("Failed to retrieve generation data: " + e);
}
});
// “Generate 3D Model”
ctxGen3D.addEventListener("click", () => {
if (!ctxTargetFile) return;
window.pywebview.api.generate_3d_model(ctxTargetFile);
});
// “Generate 3D Model”
ctxGen3DEqui.addEventListener("click", () => {
if (!ctxTargetFile) return;
window.pywebview.api.generate_3d_and_hdri(ctxTargetFile);
});
// ------------- 2D Lightbox Implementation -------------
function openLightbox(idx) {
currentIndex = idx;
const item = galleryFiles[idx];
if (!item) return;
lightboxImg.src = item.filepath;
lightboxOverlay.style.display = "flex";
}
function closeLightbox() {
lightboxOverlay.style.display = "none";
currentIndex = -1;
}
function showPrev() {
if (galleryFiles.length === 0) return;
currentIndex = (currentIndex - 1 + galleryFiles.length) % galleryFiles.length;
lightboxImg.src = galleryFiles[currentIndex].filepath;
}
function showNext() {
if (galleryFiles.length === 0) return;
currentIndex = (currentIndex + 1) % galleryFiles.length;
lightboxImg.src = galleryFiles[currentIndex].filepath;
}
lightboxClose.addEventListener("mousedown", closeLightbox);
lightboxPrev.addEventListener("mousedown", (e) => { e.stopPropagation(); showPrev(); });
lightboxNext.addEventListener("mousedown", (e) => { e.stopPropagation(); showNext(); });
lightboxOverlay.addEventListener("mousedown", (e) => {
const w = window.innerWidth;
const x = e.clientX;
// Pfeilbereiche (links/rechts): Navigation, Mitte: nix!
if (x < 0.15 * w) {
showPrev();
} else if (x > 0.85 * w) {
showNext();
}
// Kein else: Nicht schließen!
});
document.addEventListener("keydown", (e) => {
if (lightboxOverlay.style.display === "flex") {
if (e.key === "Escape") closeLightbox();
else if (e.key === "ArrowLeft") showPrev();
else if (e.key === "ArrowRight") showNext();
}
});
lightboxImg.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
if (currentIndex >= 0) {
ctxTargetFile = galleryFiles[currentIndex].filepath;
showContextMenu(e.pageX, e.pageY);
}
});
// ------------- 3D Lightbox Implementation -------------
function open3DLightbox(idx) {
currentModelIndex = idx;
lightbox3DOverlay.style.display = "flex";
show3DModel(idx);
}
function close3DLightbox() {
lightbox3DOverlay.style.display = "none";
// Clear previous renderer
lightbox3DViewport.innerHTML = "";
}
function showPrev3D() {
if (!modelGallery.length) return;
currentModelIndex = (currentModelIndex - 1 + modelGallery.length) % modelGallery.length;
show3DModel(currentModelIndex);
}
function showNext3D() {
if (!modelGallery.length) return;
currentModelIndex = (currentModelIndex + 1) % modelGallery.length;
show3DModel(currentModelIndex);
}
lightbox3DClose.addEventListener("mousedown", close3DLightbox);
lightbox3DPrev.addEventListener("mousedown", (e) => { e.stopPropagation(); showPrev3D(); });
lightbox3DNext.addEventListener("mousedown", (e) => { e.stopPropagation(); showNext3D(); });
lightbox3DOverlay.addEventListener("mousedown", (e) => {
const w = window.innerWidth;
const x = e.clientX;
if (x < 0.15 * w) {
showPrev3D();
} else if (x > 0.85 * w) {
showNext3D();
}
// Kein else: Nicht schließen!
});
document.addEventListener("keydown", (e) => {
if (lightbox3DOverlay.style.display === "flex") {
if (e.key === "Escape") close3DLightbox();
else if (e.key === "ArrowLeft") showPrev3D();
else if (e.key === "ArrowRight") showNext3D();
}
});
async function show3DModel(idx) {
lightbox3DViewport.innerHTML = "";
const entry = modelGallery[idx];
if (!entry) return;
const url = entry.glb;
const imgPath = entry.img;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(600, 600);
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.physicallyCorrectLights = true;
lightbox3DViewport.appendChild(renderer.domElement);
// --- 1. Load HDRI path from JSON ---
let envMap = null;
let hdriUrl = null;
try {
const hdriJson = await window.pywebview.api.get_image_json(imgPath);
const meta = JSON.parse(sanitizeDiffusionJSON(hdriJson));
if (meta && meta.hdri_seamless) {
const baseDir = imgPath.substring(0, imgPath.lastIndexOf("/") + 1);
hdriUrl = baseDir + meta.hdri_seamless;
}
} catch (e) {
console.warn("Failed to fetch or parse HDRI JSON:", e);
}
// --- 2. Environment map for lighting (PMREM, not visible directly) ---
if (hdriUrl) {
const loader = new THREE.TextureLoader();
await new Promise((resolve) => {
loader.load(
hdriUrl,
(texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.colorSpace = THREE.SRGBColorSpace;
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
envMap = pmrem.fromEquirectangular(texture).texture;
scene.environment = envMap;
pmrem.dispose();
resolve();
},
undefined,
(err) => { console.warn("Failed to load HDRI:", err); resolve(); }
);
});
}
// --- 3. Background sphere: vivid, blurred, high-contrast version of HDRI ---
if (hdriUrl) {
await new Promise((resolve) => {
const img = new window.Image();
img.crossOrigin = ""; // '', or 'anonymous'
img.onload = function () {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// ---- Pixel loop: boost contrast & saturation ----
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imgData.data;
const contrast = 2.2; // Try 1.62.2 for natural/vivid
const brightness = -8; // Negative to darken slightly
const saturation = 1.5; // >1 is more vibrant
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
for (let i = 0; i < data.length; i += 4) {
// Contrast
let r = factor * (data[i] - 128) + 128;
let g = factor * (data[i+1] - 128) + 128;
let b = factor * (data[i+2] - 128) + 128;
// Brightness
r += brightness;
g += brightness;
b += brightness;
// Saturation (HSL method)
let _r = r / 255, _g = g / 255, _b = b / 255;
let max = Math.max(_r, _g, _b), min = Math.min(_r, _g, _b);
let l = (max + min) / 2, s = 0, h = 0;
if (max !== min) {
let d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max) {
case _r: h = (_g - _b) / d + (_g < _b ? 6 : 0); break;
case _g: h = (_b - _r) / d + 2; break;
case _b: h = (_r - _g) / d + 4; break;
}
h /= 6;
}
s = Math.min(1, s * saturation);
// HSL back to RGB
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
}
let r2, g2, b2;
if (s === 0) {
r2 = g2 = b2 = l;
} else {
let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
let p = 2 * l - q;
r2 = hue2rgb(p, q, h + 1/3);
g2 = hue2rgb(p, q, h);
b2 = hue2rgb(p, q, h - 1/3);
}
data[i] = Math.min(255, Math.max(0, r2 * 255));
data[i+1] = Math.min(255, Math.max(0, g2 * 255));
data[i+2] = Math.min(255, Math.max(0, b2 * 255));
}
ctx.putImageData(imgData, 0, 0);
// Convert to Three.js texture
const vividTexture = new THREE.CanvasTexture(canvas);
vividTexture.mapping = THREE.EquirectangularReflectionMapping;
vividTexture.colorSpace = THREE.SRGBColorSpace;
vividTexture.minFilter = THREE.LinearFilter;
vividTexture.magFilter = THREE.LinearFilter;
vividTexture.generateMipmaps = false;
vividTexture.needsUpdate = true;
// Use MeshBasicMaterial, ignore lighting/tone mapping
const geometry = new THREE.SphereGeometry(50, 64, 32);
const material = new THREE.MeshBasicMaterial({
map: vividTexture,
side: THREE.BackSide,
toneMapped: false,
});
const sphere = new THREE.Mesh(geometry, material);
sphere.renderOrder = -1;
scene.add(sphere);
resolve();
};
img.onerror = function (e) { resolve(); };
img.src = hdriUrl;
});
}
// --- 4. Load the GLTF model ---
const dracoLoader = new THREE.DRACOLoader();
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
const gltfLoader = new THREE.GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
gltfLoader.load(
url,
(gltf) => {
const obj = gltf.scene;
scene.add(obj);
obj.updateWorldMatrix(true, true);
const box = new THREE.Box3().setFromObject(obj);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
obj.position.sub(center);
// Camera/orbit/zoom logic
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
const minZoom = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1;
const maxZoom = minZoom * 3.5;
let zoomRadius = minZoom * 1.5;
let theta = -Math.PI / 2;
// Set initial camera position
camera.position.set(-zoomRadius, 0, 0);
camera.lookAt(0, 0, 0);
// Fallback lighting if no envMap
if (!envMap) {
const keyLight = new THREE.DirectionalLight(0xffffff, 4.5);
keyLight.position.set(3, 5, 6);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xffeebb, 2.7);
fillLight.position.set(-4, 2, 3);
scene.add(fillLight);
const backLight = new THREE.DirectionalLight(0x88bbff, 3.2);
backLight.position.set(0, 6, -6);
scene.add(backLight);
scene.add(new THREE.AmbientLight(0xffffff, 3.0));
}
// OrbitControls + Auto-Rotate logic
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
controls.enablePan = false;
controls.minDistance = minZoom;
controls.maxDistance = maxZoom;
controls.autoRotate = false;
let autoRotate = true;
let lastInteraction = Date.now();
const RESUME_DELAY = 3000;
// Clamp function for zoom
function clamp(x, min, max) { return Math.max(min, Math.min(max, x)); }
controls.addEventListener('start', () => {
autoRotate = false;
lastInteraction = Date.now();
});
controls.addEventListener('change', () => {
// Update zoomRadius and theta based on current camera position
const pos = camera.position.clone().sub(controls.target);
zoomRadius = clamp(pos.length(), minZoom, maxZoom);
theta = Math.atan2(pos.x, pos.z);
});
function maybeResume() {
if (!autoRotate && Date.now() - lastInteraction > RESUME_DELAY) {
autoRotate = true;
}
}
let phi = Math.PI / 2; // vertical angle, user-set or default
// Clamp for zoom
function clamp(x, min, max) { return Math.max(min, Math.min(max, x)); }
controls.addEventListener('change', () => {
const pos = camera.position.clone().sub(controls.target);
zoomRadius = clamp(pos.length(), minZoom, maxZoom);
theta = Math.atan2(pos.x, pos.z);
phi = controls.getPolarAngle();
});
function animate() {
requestAnimationFrame(animate);
maybeResume();
if (autoRotate) {
theta += 0.008;
// Use phi for vertical angle
camera.position.x = zoomRadius * Math.sin(phi) * Math.sin(theta);
camera.position.y = zoomRadius * Math.cos(phi);
camera.position.z = zoomRadius * Math.sin(phi) * Math.cos(theta);
camera.lookAt(0, 0, 0);
// No controls.update() here!
} else {
controls.update();
}
renderer.render(scene, camera);
}
animate();
},
undefined,
(err) => {
lightbox3DViewport.innerHTML =
`<div style="color:white; text-align:center; padding:20px;">
Error loading model:<br>${err.message || err}
</div>`;
}
);
}
</script>
</body>
</html>