2376 lines
82 KiB
HTML
2376 lines
82 KiB
HTML
<!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">⚙</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">×</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 right‐click 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">×</button>
|
||
<button id="lightboxPrev">❮</button>
|
||
<img id="lightboxImg" src="" alt="Full Image" />
|
||
<button id="lightboxNext">❯</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lightbox Overlay for 3D viewer -->
|
||
<div id="lightbox3DOverlay">
|
||
<div id="lightbox3DContent">
|
||
<button id="lightbox3DClose">×</button>
|
||
<button id="lightbox3DPrev">❮</button>
|
||
<div id="lightbox3DViewport"></div>
|
||
<button id="lightbox3DNext">❯</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">< Previous</button>
|
||
Page ${currentPageImages} / ${totalPages}
|
||
<button ${currentPageImages === totalPages ? "disabled" : ""} id="imgNextPg">Next ></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">< Previous</button>
|
||
Page ${currentPage3D} / ${totalPages}
|
||
<button ${currentPage3D === totalPages ? "disabled" : ""} id="modNextPg">Next ></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.6–2.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>
|