Files
3d-model-generator/web/index.html
2026-05-14 10:23:47 +02:00

2376 lines
82 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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