Files
2026-08-15 18:06:43 +08:00

4125 lines
222 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>ComfyUI AppBuilder</title>
<style>
:root {
--bg: #000000;
--bg-color: #1c1c1c;
--bg-badge: rgba(255,255,255,0.3);
--panel-bg: #393939;
--primary: #4CAF50;
--second: #418a44;
--text: #FFFFFF;
--title: #AAAAAA;
--border: #555555;
--border-neg: #D8D8D8;
--shadow: rgb(0,0,0,0.7);
--red: #dc4125;
}
/* 🌓 浅色主题 */
:root.light-theme {
--bg: #FFFFFF;
--bg-color: #F1F1F1;
--bg-badge: rgba(128,128,128,0.4);
--panel-bg: #FFFFFF;
--primary: #4CAF50;
--second: #5acc5e;
--text: #000000;
--title: #555555;
--border: #D8D8D8;
--border-neg: #333333;
--shadow: rgb(0,0,0,0.);
--red: #dc4125;
}
.splash-screen {
position: fixed; top: 0; left: 0; width: 100vw; height: 100dvh; background-color: var(--bg); z-index: 999998;
display: flex; flex-direction: column; justify-content: center; align-items: center;
opacity: 1; visibility: visible; transition: opacity 0s cubic-bezier(0.25, 1, 0.5, 1), visibility 0s linear 0s;
}
.splash-screen.fade-out {
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 0.4s cubic-bezier(0.25, 1, 0.5, 1), visibility 0s linear 0.4s;
}
.splash-content { text-align: center; }
/* 🎛️ Splash 屏 Emoji 定时旋转停顿动画 */
.splash-icon {
font-size: 64px;
display: inline-block;
margin-bottom: 16px;
}
.splash-icon.loading {
animation: spinAndPause 3s infinite cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: center center;
will-change: transform, filter;
}
@keyframes spinAndPause {
0% { transform: rotate(0deg) scale(1); }
30% { transform: rotate(180deg) scale(1); }
50% { transform: rotate(180deg) scale(1); }
80% { transform: rotate(360deg) scale(1); }
100% { transform: rotate(360deg) scale(1); }
}
.splash-title {
font-size: 24px; font-weight: bold; color: var(--text); letter-spacing: 1.5px;
font-family: ui-rounded, "SF Pro Rounded", "Arial Rounded MT Bold", system-ui,sans-serif;
}
.splash-subtitle {
font-size: 13px; font-weight: bold; color: var(--title); display: none;
font-family: ui-rounded, "SF Pro Rounded", "Arial Rounded MT Bold", system-ui,sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html, body {
background-color: var(--bg);
overscroll-behavior-y: none;
overscroll-behavior: none;
touch-action: manipulation !important;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column; height: 100dvh; overflow: hidden;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
input, select, textarea, #lightbox-img {
-webkit-user-select: auto !important;
user-select: auto !important;
-webkit-touch-callout: default !important;
}
body, button, input, select, textarea, .segmented-toggle, .gallery-item {
touch-action: manipulation;
}
select {
text-overflow: ellipsis !important; /* 超长自动省略号 */
white-space: nowrap !important; overflow: hidden !important;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10'><polygon points='0,1 12,1 6,9' fill='%23AAAAAA'/></svg>") !important;
background-repeat: no-repeat !important;
background-position: left 7px center !important;
padding-left: 24px !important;
}
:root.light-theme select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10'><polygon points='0,1 12,1 6,9' fill='%23555555'/></svg>") !important;
}
.progress-bar-container {
position: fixed; top: 0; left: 0; width: 100%; height: 4px;
background-color: transparent; z-index: 999999; pointer-events: none;
}
.progress-bar-fill {
width: 0%; height: 100%; background-color: var(--primary); transition: width 0.1s ease-out;
}
.viewer-container {
display: flex; flex-direction: column;
flex: 0 0 30dvh;
background-color: #000; overflow: hidden;
}
.preview-section {
flex: 1; background-color: var(--bg); display: flex; justify-content: center;
position: relative; overflow: hidden; padding: 46px 8px 8px 8px; align-items: center;
}
#main-preview {
max-width: 100%; max-height: 100%; object-fit: contain;
pointer-events: none; user-select: none; -webkit-user-drag: none;
}
.gallery-container {
display: flex; align-items: center; padding: 0 16px; gap: 10px; height: 72px;
background-color: var(--bg); border-top: 1px solid var(--border);
border-top-left-radius: 24px; border-top-right-radius: 24px; padding-top: 8px;
overflow: hidden;
}
.gallery-scroll-wrap {
flex: 1; display: flex; overflow-x: auto; overflow-y: hidden; align-items: center;
height: 100%; gap: 8px; touch-action: pan-x; padding: 5px 0;
}
.gallery-scroll-wrap::-webkit-scrollbar { display: none; }
.gallery-item {
height: 60px; border-radius: 6px; object-fit: cover; flex-shrink: 0;
border: 2px solid var(--border); cursor: pointer; user-select: none; -webkit-user-drag: none;
}
/* 🎬 画廊视频封面包裹器与假播放按钮 */
.gallery-item-wrap {
position: relative; display: inline-block; height: 60px; flex-shrink: 0; cursor: pointer;
}
.gallery-play-badge {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 26px; height: 26px; background: rgba(0, 0, 0, 0.65);
border: 1.5px solid #ffffff; border-radius: 50%; color: #ffffff; font-size: 11px;
display: flex; align-items: center; justify-content: center;
pointer-events: none; padding-left: 2px; box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
.gallery-clear-btn {
flex-shrink: 0; width: 3rem; height: 60px; border-radius: 8px; background-color: #444; border: none;
color: #FFF; font-size: 18px; font-weight: bold; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.gallery-clear-btn:hover { background-color: var(--red); }
.gallery-item-placeholder {
width: 60px; height: 60px; border-radius: 6px; flex-shrink: 0;
background-color: var(--panel-bg); border: 2px solid var(--border);
animation: galleryPulse 1.5s infinite ease-in-out;
}
@keyframes galleryPulse {
0% { opacity: 0.6; }
50% { opacity: 0.3; }
100% { opacity: 0.6; }
}
.status-badge {
position: absolute; top: 10px; right: 50px; background: var(--border); height: 24px;
padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: bold; color: var(--title); z-index: 10;
border: 1px solid var(--title); text-shadow: 0px 1px 0px var(--panel-bg);
}
@supports (-webkit-hyphens: none) {
.status-badge { right: 55px; }
}
.back-badge { display: none; width: 32px !important; left: 10px; right: auto; color: #FFF; background-color: #d13d22; border: 1px solid #e6583e; text-shadow: none;}
.resizer {
height: 18px; width: 100%; cursor: row-resize; flex-shrink: 0;
position: relative; z-index: 100; user-select: none; background-color: var(--bg-color);
}
.resizer::after {
content: ''; position: absolute; left: 50%; top: 8px; transform: translate(-50%, -50%);
width: 100px; height: 4px; background: var(--title); border-radius: 2px;
}
.right-panel {
flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; border-top: 2px solid var(--border);
box-shadow: 0px 0px 30px var(--shadow); border-top-left-radius: 24px; border-top-right-radius: 24px;
}
.controls-section {
flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
padding: 8px 16px; padding-bottom: 140px; background-color: var(--bg-color);
}
.controls-section::-webkit-scrollbar { display: none; }
.control-group { margin-bottom: 8px;}
.control-group label.control-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 3px;
font-size: 13px;
color: var(--title);
}
.control-group label.control-title span:first-child {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 8px;
}
.slider-value-badge {
flex-shrink: 0;
display: inline-block;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
font-family: monospace;
color: var(--title);
background-color: var(--panel-bg);
user-select: none;
}
input[type="text"], select {
width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); font-family: monospace;
background: var(--panel-bg); color: var(--text); font-size: 14px; appearance: none; height: 36px;
}
input[type="number"] {
width: 100%; padding: 8px 10px; background: var(--panel-bg); color: var(--text); font-size: 14px; appearance: none;
border: none; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); border-radius: 0px;
height: 36px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; /* 👈 改为居中对齐 */
}
textarea.tight-textarea {
height: 36px; width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel-bg); color: var(--text); font-size: 14px; font-family: monospace;
resize: none; white-space: pre-wrap; line-height: 18px; overflow-y: hidden;
}
textarea.tight-textarea:focus { outline: none; border-color: var(--primary); }
input[type="range"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
height: 36px;
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 18px;
outline: none;
overflow: hidden;
touch-action: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 34px;
height: 34px;
background: var(--primary);
border-radius: 50%;
cursor: pointer;
box-shadow: -1016px 0 0 1000px var(--second);
}
input[type="range"]::-moz-range-thumb {
width: 34px;
height: 34px;
background: var(--primary);
border: none;
border-radius: 50%;
cursor: pointer;
box-shadow: -1016px 0 0 1000px var(--second);
}
input[type="range"]::-moz-range-track {
width: 100%;
height: 36px;
background: transparent;
border: none;
}
input[type="range"]::-moz-range-progress {
background: transparent;
border: none;
}
.numeric-input-wrapper { display: flex; align-items: center; width: 100%; gap: 0px; }
.numeric-input-wrapper input { flex: 1; text-align: center; }
.btn-numeric-adjust {
background: var(--panel-bg); border: 1px solid var(--border); color: var(--title);
width: 2.5rem; height: 36px; cursor: pointer; font-size: 18px; font-weight: bold;
display: flex; align-items: center; justify-content: center; user-select: none;
}
.btn-numeric-adjust-right { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
.btn-numeric-adjust-left { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
.btn-numeric-adjust:hover { background: var(--border); }
.segmented-toggle {
width: 100%; height: 36px; background: var(--panel-bg); border: 1px solid var(--border);
border-radius: 8px; position: relative; cursor: pointer; overflow: hidden;
}
.toggle-track { display: flex; height: 100%; position: relative; z-index: 1; }
.toggle-label-half {
flex: 1; display: flex; align-items: center; justify-content: center; user-select: none;
font-size: 13px; font-weight: bold; color: var(--text); transition: color 0.2s; z-index: 2;
}
.toggle-thumb {
position: absolute; top: 2px; left: 2px; height: calc(100% - 4px);
width: calc(50% - 4px); background: var(--border); border-radius: 6px;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s; z-index: 1;
}
input[type="checkbox"]:checked + .toggle-track .toggle-thumb { transform: translateX(calc(100% + 4px)); background: var(--primary); }
input[type="checkbox"]:checked + .toggle-track .toggle-label-half[data-val="true"] { color: #fff; }
input[type="checkbox"]:not(:checked) + .toggle-track .toggle-label-half[data-val="false"] { color: #fff; }
/* 🔥 Lora Stack 样式注入区域 */
/* 🎚️ 独立单行开关(保持你原有的 CSS) */
.toggle-switch {
position: relative; width: 2.5rem; height: 36px; border-radius: 8px;
background-color: var(--border);
cursor: pointer; transition: background-color 0.3s;
flex-shrink: 0;
}
.toggle-switch::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 32px;
background-color: #fff; border-radius: 6px;
transition: transform 0.3s, background-color 0.3s;
}
.toggle-switch.active { background-color: var(--primary);}
.toggle-switch.active::after { transform: translateX(18px); }
/* 📦 容器与 4-in-1 无缝胶囊行 */
.lora-stack-container { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.lora-row { display: flex; gap: 0px; align-items: center; width: 100%; }
/* 1. 🗑️ 无缝吸附在最左侧的删除按钮 */
.btn-del-lora {
width: 2.5rem; height: 36px; background: var(--panel-bg);
border: 1px solid var(--border); border-right: none;
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
color: var(--red); font-size: 13px; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.btn-del-lora:hover { background: var(--border); color: var(--red); }
/* 2. 中间的 LoRA 下拉框 (左右均无缝无圆角) */
.lora-row select {
flex: 3; min-width: 0; height: 36px; font-size: 0.8rem;
padding-right: 6px !important;
border-radius: 0px !important; text-overflow: ellipsis !important; /* 超长自动省略号 */
white-space: nowrap !important; overflow: hidden !important;
}
/* 1. 强度输入框:恢复右侧圆角与右边框 */
.lora-row input[type="number"] {
width: 2.5rem; height: 36px; text-align: center;
padding-left: 2px !important; padding-right: 2px !important;
border-radius: 0px !important;
border-radius: 0px 8px 8px 0px !important;
border-right: 1px solid var(--border) !important; /* 👈 恢复右边框 */
border-left: none !important;
}
/* 2. 独立开关:恢复四周独立圆角,并向左拉开 6px 的隔离带 */
.lora-row .toggle-switch {
margin-left: 6px; /* 👈 就是这个 margin-left 拉开了左侧间隔! */
border-radius: 8px !important; /* 👈 恢复四周完整的独立圆角 */
}
/* 🎛️ 顶部工具栏 */
.lora-stack-header {
display: flex; justify-content: space-between; align-items: center;
}
/* + Add LoRA 按钮 */
.btn-add-lora {
background: var(--panel-bg); border: 1px solid var(--border);
color: var(--title); height: 36px; padding: 0 12px; border-radius: 8px;
font-size: 12px; font-weight: bold; cursor: pointer;
}
.btn-add-lora:hover { background-color: var(--border); }
/* 🎚️ 三段式总开关 (遵循相同的 36px 高度与滑块语言) */
.tri-state-toggle {
position: relative; width: 2.5rem; height: 36px; border-radius: 8px;
background-color: var(--border);
cursor: pointer; transition: background-color 0.3s, border 0.3s; flex-shrink: 0;
}
.tri-state-thumb {
position: absolute; top: 2px; left: 2px; width: 18px; height: 32px;
background-color: #fff; border-radius: 6px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s;
}
/* 三段位置控制 */
.tri-state-toggle.all-off .tri-state-thumb { transform: translateX(0px); }
.tri-state-toggle.partial { background-color: var(--border); }
.tri-state-toggle.partial .tri-state-thumb { transform: translateX(9px); }
.tri-state-toggle.all-on { background-color: var(--primary); }
.tri-state-toggle.all-on .tri-state-thumb { transform: translateX(18px); }
.input-with-button { display: flex; gap: 6px; width: 100%; }
.btn-edit-multiline {
background: var(--panel-bg); border: 1px solid var(--border); border-radius: 8px; width: 2.5rem; height: 57px;
flex-shrink: 0; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
}
.btn-edit-multiline:hover { background: var(--border); }
.input-with-button input, .input-with-button textarea { flex: 1; width: auto; }
.uploader-control-row { display: flex; gap: 6px; width: 100%; }
.uploader-dropzone {
flex: 1; padding: 10px; border: 2px dashed var(--border); border-radius: 8px;
background: var(--bg); text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; user-select: none;
outline: none;
}
.uploader-dropzone.dragover { border-color: var(--primary); background: rgba(76, 175, 80, 0.05); }
.uploader-dropzone:hover { background: rgba(76, 175, 80, 0.05); }
.uploader-text { font-size: 13px; color: #888; }
.uploader-filename { font-size: 12px; color: var(--primary); word-break: break-all; margin-top: 2px; font-weight: bold; }
/* 📋 上下排列的按钮组样式 */
.uploader-btn-group { display: flex; flex-direction: column; gap: 6px; width: 2.5rem; flex-shrink: 0; }
.uploader-btn {
background: var(--panel-bg); border: 1px solid var(--border); color: var(--text); border-radius: 8px;
width: 2.5rem; height: 100%; min-height: 36px; cursor: pointer; font-size: 16px; display: flex;
align-items: center; justify-content: center; user-select: none; flex: 1;
}
.uploader-btn:hover { background-color: var(--border); }
.uploader-preview-toggle { display: none; }
/*.uploader-preview-toggle {
background: var(--panel-bg); border: 1px solid var(--border); color: #fff; border-radius: 8px;
width: 2.5rem; cursor: pointer; font-size: 16px; display: none; align-items: center;
justify-content: center; user-select: none;
}*/
.uploader-preview-toggle:hover { background-color: var(--border); }
.uploader-preview-box {
margin-top: 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg);
overflow: hidden; display: flex; justify-content: center; align-items: center; max-height: 200px;
transition: max-height 0.2s ease-out;
}
.uploader-preview-box.collapsed { max-height: 0; margin-top: 0; border: none; }
.uploader-preview-box img, .uploader-preview-box video { max-width: 100%; max-height: 200px; object-fit: contain; }
.uploader-preview-box audio { width: 100%; padding: 8px; }
.bottom-action-bar {
position: absolute; bottom: 0; left: 0; width: 100%; background-color: var(--bg); z-index: 100; display: flex;
flex-direction: column; padding-bottom: env(safe-area-inset-bottom); border-top-left-radius: 24px; border-top-right-radius: 24px;
box-shadow: 0px 0px 30px var(--shadow); border-top: 1px solid var(--border);
}
.button-group { display: flex; gap: 10px; padding: 8px 16px 10px 16px; }
.btn {
flex: 1; padding: 10px; border: none; border-radius: 8px; font-size: 14px; height: 40px;
font-weight: bold; color: white; cursor: pointer; display: flex; justify-content: center;
align-items: center; transition: background-color 0.2s, box-shadow 0.2s, transform 0.1s;
}
.btn:active { opacity: 0.8; transform: scale(0.98); }
.btn-run-wrapper { position: relative; flex: 3; display: flex; }
.btn-run { width: 100%; background-color: #3E8AE2; }
.btn-run:hover { filter: brightness(1.2); }
.queue-badge {
display: none; position: absolute; top: -6px; left: -6px; font-size: 13px; font-weight: bold;
background-color: #F44336 !important; color: #FFFFFF !important; height: 26px; min-width: 26px;
padding: 0 5px; border-radius: 13px; border: 2px solid var(--bg); align-items: center;
justify-content: center; z-index: 10; pointer-events: none;
}
.btn-stop { background-color: var(--red); flex: 1; }
.btn-stop:hover { filter: brightness(1.2); }
.modal {
display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%;
background-color: rgba(0,0,0,0.8); justify-content: center; align-items: center; padding: 16px;
backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
}
.modal-content {
background-color: var(--panel-bg); border: 1px solid var(--border); border-radius: 12px;
width: 100%; max-width: 500px; display: flex; flex-direction: column; gap: 12px; padding: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.modal-header { display: flex; color: var(--text); justify-content: space-between; align-items: center; font-size: 15px; font-weight: bold; }
.modal-close { color: #aaa; font-size: 24px; font-weight: bold; cursor: pointer; }
.modal-close:hover { color: var(--text); }
#multiline-textarea {
width: 100%; height: 50vh; background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 10px; font-family: monospace; font-size: 14px; resize: vertical;
}
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; }
.modal-btn { padding: 8px 14px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 13px; }
.modal-btn-cancel { background-color: var(--border); color: var(--text); }
.modal-btn-save { background-color: var(--primary); color: #fff; }
.lightbox-modal {
display: none; position: fixed; z-index: 11000; left: 0; top: 0; width: 100%; height: 100%;
background-color: rgba(0,0,0,0.8); justify-content: center; align-items: center;
backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
}
.lightbox-content {
max-width: calc(100dvw - 12px) !important;
max-height: calc(100dvh - 120px) !important;
object-fit: contain;
user-select: none;
-webkit-user-drag: auto !important; /* 👈 改为 auto,允许电脑端把图片拖出浏览器 */
touch-action: none;
transform-origin: center center;
will-change: transform;
}
/* 双击/复原时的平滑过渡动画 */
.lightbox-content.smooth-transform {
transition: transform 0.25s cubic-bezier(0.25, 1, 0.5, 1);
}
@keyframes zoom { from {transform: scale(0.95); opacity: 0;} to {transform: scale(1); opacity: 1;} }
.lightbox-arrow {
position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.55);
border: none; color: #fff; font-size: 30px; width: 48px; height: 48px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
z-index: 11200; transition: background 0.2s, transform 0.1s;
}
.lightbox-arrow:hover { background: var(--primary); }
.lightbox-arrow:active { transform: translateY(-50%) scale(0.9); }
.lightbox-arrow-left { left: 10px; padding-right: 2px; }
.lightbox-arrow-right { right: 10px; padding-left: 2px; }
.lightbox-close { top: 30px; right: 10px; user-select: none; z-index: 11300 !important; }
.lightbox-close:hover { background: var(--red) !important; }
.lightbox-download { top: calc(100% - 30px); right: 10px; user-select: none; z-index: 11300 !important; padding-bottom: 2px; }
.lightbox-download:hover { background: var(--primary) !important; }
.number-input::-webkit-outer-spin-button, .number-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.number-input[type=number] { -moz-appearance: textfield; }
.btn-batch-input {
background-color: var(--bg) !important; border: 2px solid var(--border) !important; border-radius: 8px !important;
flex: 3rem 0 0; color: #fff; text-align: center; outline: none; touch-action: manipulation;
padding-left: 4px !important; padding-right: 4px !important; height: 40px !important;
}
.theme-toggle-btn {
position: absolute; top: 10px; right: 10px; padding: 3px 10px; cursor: pointer;
text-shadow: 0px 0px 1px var(--text) !important; transition: transform 0.2s ease;
}
.theme-toggle-btn:hover { transform: scale(1.1); }
.log-drawer-body {
flex: 1; height: auto; width: 100%; background: #171717; color: #ccc; padding: 12px;
font-family: Consolas, Monaco, "Courier New", monospace; font-size: 12px; overflow-y: auto;
-webkit-overflow-scrolling: touch; user-select: text !important; -webkit-user-select: text !important;
}
.log-line { margin-bottom: 3px; word-break: break-all; }
.log-time { color: #555; margin-right: 6px; }
.log-tag { font-weight: bold; margin-right: 6px; }
.log-line.white { color: #FFF; }
.log-line.white .log-tag { color: #FFF; }
.log-line.warning { color: #ffeb3b; }
.log-line.warning .log-tag { color: #ffeb3b; }
.log-line.green { color: var(--primary); }
.log-line.green .log-tag { color: var(--primary); }
.log-line.error { color: var(--red); }
.log-line.error .log-tag { color: var(--red); }
.btn-console { background-color: #444; color: #fff; flex: 0 0 3rem; padding-top: 0; padding-left: 0;}
.btn-console:hover { filter: brightness(1.2); }
.btn-console.active { background-color: var(--primary); }
.main-layout { display: flex; flex-direction: column; flex: 1; width: 100%; overflow: hidden; background-color: var(--bg);}
.log-drawer-container {
display: flex; flex-direction: column; width: 100%; flex-shrink: 0; height: 0px;
transition: height 0.25s cubic-bezier(0.2, 0.8, 0.2, 1); background-color: var(--panel-bg);
z-index: 1000; overflow: hidden;
}
.log-drawer-container.dragging { transition: none; }
.log-resizer {
height: 18px; width: 100%; cursor: row-resize; background-color: #171717;
flex-shrink: 0; display: flex; justify-content: center; align-items: center; border-top: 2px solid var(--border);
}
.log-resizer::after { content: ''; width: 100px; height: 4px; background: #AAAAAA; border-radius: 2px; }
.header-bar {
position: absolute; top: 8px; left: 10px; right: 10px; display: flex;
justify-content: space-between; align-items: center; z-index: 1000; gap: 10px; pointer-events: none;
}
.header-btn-group {
display: flex; flex-shrink: 0; height: 30px; gap: 1.5px;
background-color: var(--border); border: 1.5px solid var(--border); border-radius: 15px;
overflow: hidden; pointer-events: auto; box-shadow: 0px 0px 30px var(--shadow);
}
.header-btn {
background-color: var(--panel-bg); border: none; color: var(--title); height: 100%; width: 2rem;
font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center;
touch-action: manipulation; cursor: pointer; white-space: nowrap;
}
.header-btn:hover { background-color: var(--border); }
.header-status-badge {
background-color: var(--panel-bg); color: var(--title); font-size: 12px; font-weight: bold;
height: 100%; padding: 0 10px; line-height: 27px; text-align: center;
flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* 1. 当鼠标/焦点不在输入框内时:开启左侧省略号黑科技 */
.seed-input:not(:focus) {
direction: rtl !important; /* 利用 RTL 机制强行把 "..." 挤到最左侧 */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
/* 2. 当用户点击进入打字状态时:切回正常光标方向,依然靠右 */
.seed-input:focus {
direction: ltr !important;
}
.seed-utility-group {
display: flex; align-items: center; background-color: var(--border); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; flex-shrink: 0; height: 36px; gap: 1px;
}
.btn-seed-util-item {
width: 2.5rem; height: 100%; background-color: var(--panel-bg); border: none; color: var(--text);
cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; user-select: none;
}
.btn-seed-util-item:hover { background-color: var(--border); }
/* 🎛️ 右上角连体状态与监控按钮 */
.header-status-group {
background-color: var(--border); border: 1.5px solid var(--border);
height: 30px; border-radius: 15px; display: flex; align-items: center;
pointer-events: auto; overflow: hidden; box-shadow: 0px 0px 30px var(--shadow);
gap: 1.5px; flex-shrink: 1; min-width: 0;
}
.header-status-group .monitor-btn { flex-shrink: 0 !important; width: 2rem !important; }
.header-status-group .monitor-btn:hover { background-color: var(--border); color: var(--text); }
.header-status-group .monitor-btn.active { background-color: var(--primary); color: #fff; }
/* 📊 系统硬件监控顶部挤压面板 */
.system-monitor-panel {
background-color: var(--bg); border-bottom: 1.5px solid var(--border); display: flex; flex-direction: column;
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
padding: 8px 12px 8px 12px; gap: 4px; overflow: hidden; z-index: 99; flex-shrink: 0; width: 100%;
}
.system-monitor-panel.collapsed { max-height: 0; padding-top: 0; padding-bottom: 0; border-bottom: none; pointer-events: none; }
.monitor-row { display: flex; gap: 10px; width: 100%; align-items: center; }
.monitor-item { flex: 1; display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.monitor-label { display: flex; justify-content: space-between; font-size: 10px; font-weight: bold; color: var(--title); font-family: monospace; }
.monitor-label span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.monitor-bar-track { height: 7px; background-color: var(--border); border-radius: 4px; overflow: hidden; border: 1px solid var(--border); }
.monitor-bar-fill { height: 100%; background-color: var(--primary); border-radius: 3px; transition: width 0.3s ease; }
.monitor-bar-fill.warn { background-color: #ff9800; }
.monitor-bar-fill.danger { background-color: var(--red); }
/* 🖌️ Uploader 预览图右上角的画笔按钮 */
.uploader-preview-box { position: relative; }
.uploader-mask-btn {
position: absolute; top: 8px; right: 8px; z-index: 10; width: 36px; height: 36px; border-radius: 50%;
background: var(--bg-badge); border: 1px solid var(--border); color: #fff; font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px);
}
.uploader-mask-btn:hover { background: var(--primary); }
/* 🖌️ 遮罩编辑器 Modal 样式 */
.mask-modal-content {
max-width: 900px !important; height: 85vh; max-height: 850px;
display: flex; flex-direction: column; padding: 12px !important; gap: 10px !important;
}
.mask-workspace {
flex: 1; min-height: 0; background: #0a0a0a; border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; display: flex;
align-items: center; justify-content: center; position: relative;
touch-action: none; user-select: none;
}
.mask-canvas-wrapper {
position: relative; display: inline-block; max-width: 100%; max-height: 100%;
}
.mask-bg-img {
display: block; max-width: 100%; max-height: 70dvh; width: auto; height: auto;
vertical-align: middle; pointer-events: none; -webkit-user-drag: none;
}
.mask-draw-canvas {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
cursor: crosshair; touch-action: none; opacity: 0.4 !important;
}
.mask-brush-cursor {
position: absolute; pointer-events: none; border: 2px solid rgba(255,255,255,0.8);
border-radius: 50%; transform: translate(-50%, -50%); display: none;
box-shadow: 0 0 4px rgba(0,0,0,0.5); z-index: 20;
}
.mask-toolbar {
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 8px; padding: 8px; background: var(--bg);
border-radius: 8px; border: 1px solid var(--border);
}
.mask-tools-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.mask-tool-btn {
background: var(--panel-bg); border: 1px solid var(--border); color: var(--text);
padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;
display: flex; align-items: center; gap: 4px; height: 36px;
}
.mask-tool-btn:hover:not(:disabled) { background: var(--primary); color: #fff; border-color: var(--primary); }
.mask-tool-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
.mask-tool-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.mask-tool-btn.btn-danger:hover { background: var(--red); border-color: var(--red); color: #fff; }
.mask-divider { width: 1px; height: 20px; background: var(--border); }
.mask-slider-label {
display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--title); font-weight: bold;
}
.mask-slider-label input[type="range"] { width: 200px; height: 36px; }
.mask-actions-group { display: flex; gap: 10px; width: 100%; justify-content: flex-end; border-top: 1px solid var(--border); padding-top: 6px; }
@media (min-width: 768px) {
.main-layout { flex-direction: row; }
.viewer-container { flex: 1 1 auto; min-width: 0; height: 100%; }
.right-panel {
flex: 0 0 400px; width: 400px; min-width: 350px; max-width: calc(100vw - 350px) !important;
display: flex; flex-direction: column; height: 100%;
position: relative; box-shadow: none; border-radius: 0px !important; border-top: 0px !important;
}
.gallery-container { flex: 0 0 100px !important; height: 100px !important; border-radius: 0px; padding-top: 0px; }
.gallery-item { height: 80px !important; }
.gallery-clear-btn { height: 40px !important; }
.gallery-item-placeholder { width: 80px !important; height: 80px !important; }
.gallery-item-wrap { height: 80px; }
.gallery-play-badge { width: 32px; height: 32px; font-size: 13px; }
.resizer {
position: absolute; left: 0; top: 0;
width: 14px; height: 100%; cursor: col-resize; flex-shrink: 0; border-top: none;
border-bottom: none; border-left: 1px solid var(--border);
}
.resizer::after { width: 4px; height: 80px; left: 50%; top: calc(50% - 46px);}
.resizer:hover, .resizer.dragging { background: var(--border); }
.log-resizer {border-top: 1px solid var(--border);}
.controls-section { flex: 1; padding-bottom: 100px; padding-left: 18px; }
.bottom-action-bar { position: absolute; width: 100%; height: 100px; }
.button-group { display: flex; gap: 10px; padding-left: 0px; padding-top: 30px; }
.bottom-action-bar, .gallery-container, .resizer { border-radius: 0px; box-shadow: none; }
.mask-actions-group {
width: auto !important; justify-content: flex-start !important; border: none !important; padding: 0px !important;
}
#last-divider { display: flex !important; }
}
</style>
<script>
function isMobile() {
const ua = navigator.userAgent;
if (/Android|iPhone|iPod/i.test(ua)) return true;
if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) return true;
return false;
}
// 🛡️ 全局网页防双指张开缩放金钟罩 (仅对灯箱大图 #lightbox-img 白名单放行)
function initGlobalAntiPinchGuard() {
// A. 拦截 iOS Safari 专属的 Safari 网页缩放手势
document.addEventListener('gesturestart', (e) => {
if (e.target.id !== 'lightbox-img') e.preventDefault();
});
document.addEventListener('gesturechange', (e) => {
if (e.target.id !== 'lightbox-img') e.preventDefault();
});
document.addEventListener('gestureend', (e) => {
if (e.target.id !== 'lightbox-img') e.preventDefault();
});
// B. 拦截所有移动端浏览器的多指 (双指/三指) 网页拉伸放缩
document.addEventListener('touchstart', (e) => {
if (e.touches.length > 1 && e.target.id !== 'lightbox-img') {
if (e.cancelable) e.preventDefault();
}
}, { passive: false });
document.addEventListener('touchmove', (e) => {
if (e.touches.length > 1 && e.target.id !== 'lightbox-img') {
if (e.cancelable) e.preventDefault();
}
}, { passive: false });
}
// 启动金钟罩
initGlobalAntiPinchGuard();
const getSystemTheme = () => {
const topWin = window.top || window;
return topWin.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const themeButtonText = (theme) => {
const btn = document.getElementById("theme-toggle");
if (btn) {
if (theme === 'system') btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-dot-icon lucide-circle-dot"><path d="M 12 2 A 10 10 0 0 0 12 22 Z" fill="currentColor"/><circle cx="12" cy="12" r="10"/></svg>';
else if (theme === 'light') btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun-icon lucide-sun"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>';
else btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon-icon lucide-moon"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>';
}
}
const applyTheme = (theme) => {
let activeTheme = theme;
if (theme === 'system') activeTheme = getSystemTheme();
if (activeTheme === 'light') {
document.documentElement.classList.add('light-theme');
document.documentElement.classList.remove('dark-theme');
} else {
document.documentElement.classList.add('dark-theme');
document.documentElement.classList.remove('light-theme');
}
themeButtonText(theme);
};
const savedTheme = localStorage.getItem('appview_theme') || 'system';
applyTheme(savedTheme);
const topWin = window.top || window;
topWin.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = localStorage.getItem('appview_theme') || 'system';
if (currentTheme === 'system') { applyTheme('system'); }
});
if (window.self !== window.top && window.parent.FastLoadImageBridge) {
window.fetch = async function(input, init) {
let urlStr = typeof input === 'string' ? input : (input instanceof Request ? input.url : null);
if (urlStr && (urlStr.includes('/view') || urlStr.includes('/api/view'))) {
return await window.parent.FastLoadImageBridge.fetch(urlStr, init);
}
return window.parent.fetch.apply(window, arguments);
};
const originalSetSrc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src').set;
Object.defineProperty(HTMLImageElement.prototype, 'src', {
set(val) {
if (val && (val.includes('/view') || val.includes('/api/view'))) {
window.parent.FastLoadImageBridge.getObjectUrl(val).then(objectUrl => {
originalSetSrc.call(this, objectUrl);
}).catch(() => {
originalSetSrc.call(this, val);
});
return;
}
originalSetSrc.call(this, val);
}
});
}
</script>
</head>
<body>
<div id="splash-screen" class="splash-screen">
<div class="splash-content">
<span class="splash-icon">🎛️</span>
<div class="splash-title">ComfyUI AppBuilder</div>
<div class="splash-subtitle" data-i18n="still-loading">(The first access after startup may take longer)</div>
</div>
</div>
<div class="progress-bar-container"><div class="progress-bar-fill" id="progress-bar"></div></div>
<div class="main-layout">
<div class="viewer-container">
<div id="system-monitor-panel" class="system-monitor-panel collapsed">
<!-- 第一行:系统内存 RAM -->
<div class="monitor-row">
<div class="monitor-item">
<div class="monitor-label"><span>CPU</span><span id="mon-cpu-text">0%</span></div>
<div class="monitor-bar-track"><div class="monitor-bar-fill" id="mon-cpu-bar" style="width: 0%;"></div></div>
</div>
<div class="monitor-item">
<div class="monitor-label"><span>RAM</span><span id="mon-ram-text">0/0 GB 0%</span></div>
<div class="monitor-bar-track"><div class="monitor-bar-fill" id="mon-ram-bar" style="width: 0%;"></div></div>
</div>
</div>
<!-- 第二行及以后:GPU 动态渲染区 -->
<div id="gpu-monitor-container" style="display: flex; flex-direction: column; gap: 4px;"></div>
</div>
<div class="preview-section">
<div class="header-bar">
<div class="header-btn-group">
<button class="header-btn" id="iframe-close-btn" title-i18n="close_app_view" style="display: none;" title="Close App View">
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="header-btn" id="refresh-btn" title-i18n="refresh_nodes" style="display: none;" title="Refresh Node Definitions">
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-cw-icon lucide-rotate-cw"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
</button>
<button class="header-btn" id="save-btn" title-i18n="save_app" title="Save Changes">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save-icon lucide-save"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
</button>
<button class="header-btn" id="unload-btn" title-i18n="unload_models" title="Unload Models and Execution Cache">
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-brush-cleaning-icon lucide-brush-cleaning"><path d="m16 22-1-4"/><path d="M19 14a1 1 0 0 0 1-1v-1a2 2 0 0 0-2-2h-3a1 1 0 0 1-1-1V4a2 2 0 0 0-4 0v5a1 1 0 0 1-1 1H6a2 2 0 0 0-2 2v1a1 1 0 0 0 1 1"/><path d="M19 14H5l-1.973 6.767A1 1 0 0 0 4 22h16a1 1 0 0 0 .973-1.233z"/><path d="m8 22 1-4"/></svg>
</button>
<button class="header-btn" id="theme-toggle" title-i18n="theme_toggle" title="Appearance">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-dot-icon lucide-circle-dot"><path d="M 12 2 A 10 10 0 0 0 12 22 Z" fill="currentColor"/><circle cx="12" cy="12" r="10"/></svg>
</button>
</div>
<div class="header-status-group">
<span class="header-status-badge" id="status-text" data-i18n="label_ready">Ready</span>
<button class="monitor-btn header-btn" id="monitor-toggle-btn" title="Toggle System Monitor">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-activity-icon lucide-activity"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>
</button>
</div>
</div>
<img id="main-preview" src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'><rect width='100%' height='100%' fill='%23555'/><text x='50%' y='50%' fill='%23999' font-size='16' text-anchor='middle' alignment-baseline='middle'>No Media</text></svg>">
</div>
<div class="gallery-container" id="gallery">
<div class="gallery-scroll-wrap" id="gallery-scroll"></div>
<button class="gallery-clear-btn" id="btn-clear-gallery" title-i18n="clear_gallery" title="Clear gallery">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
<div class="right-panel">
<div id="resizer" class="resizer"></div>
<div class="controls-section" id="dynamic-form">
<p style="color:#aaa; text-align:center; margin-top:20px;" data-i18n="loading_ui">Loading UI...</p>
</div>
<div class="bottom-action-bar">
<div class="button-group">
<input type="number" id="input-batch-count" class="number-input btn-batch-input" value="1" min="1" max="999">
<div class="btn-run-wrapper">
<span class="queue-badge" id="queue-badge"></span>
<button class="btn btn-run" id="btn-run" data-i18n="button_run">▷ Run</button>
</div>
<button class="btn btn-stop" id="btn-stop" title-i18n="cancel_run" title="Cancel current run">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
<button class="btn btn-console" id="console-toggle" title-i18n="open_console" title="Open console">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal-icon lucide-terminal"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></svg>
</button>
</div>
</div>
</div>
</div>
<div id="log-drawer-container" class="log-drawer-container">
<div id="log-resizer" class="log-resizer"></div>
<div class="log-drawer-body" id="log-content"><div class="log-line system"></div></div>
</div>
<div id="multiline-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<span id="modal-title" data-i18n="label_editer">Edit Text</span>
<span class="modal-close" id="multiline-modal-close">&times;</span>
</div>
<textarea id="multiline-textarea"></textarea>
<div class="modal-footer">
<button class="modal-btn modal-btn-cancel" id="multiline-btn-cancel" data-i18n="label_cancel">Cancel</button>
<button class="modal-btn modal-btn-save" id="multiline-btn-save" data-i18n="label_save">Save</button>
</div>
</div>
</div>
<div id="lightbox-modal" class="lightbox-modal">
<span class="lightbox-arrow lightbox-close" id="lightbox-close">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</span>
<a class="lightbox-arrow lightbox-download" id="lightbox-download" title="Download" target="_blank" download>
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download-icon lucide-download"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>
</a>
<button class="lightbox-arrow lightbox-arrow-left" id="lightbox-prev">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>
</button>
<img class="lightbox-content" id="lightbox-img">
<button class="lightbox-arrow lightbox-arrow-right" id="lightbox-next">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
<!-- 🖌️ 遮罩编辑器模态框 (Mask Editor Modal) -->
<div id="mask-modal" class="modal">
<div class="modal-content mask-modal-content">
<div class="modal-header">
<span data-i18n="mask_editor">🖌️ Mask Editor</span>
<span class="modal-close" id="mask-modal-close">&times;</span>
</div>
<div class="mask-workspace" id="mask-workspace">
<div class="mask-canvas-wrapper" id="mask-canvas-wrapper">
<img id="mask-bg-img" class="mask-bg-img">
<canvas id="mask-draw-canvas" class="mask-draw-canvas"></canvas>
<div id="mask-brush-cursor" class="mask-brush-cursor"></div>
</div>
</div>
<div class="mask-toolbar">
<div class="mask-tools-group">
<button type="button" class="mask-tool-btn" id="mask-btn-undo" title="Undo (Ctrl+Z)" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-corner-up-left-icon lucide-corner-up-left"><path d="M20 20v-7a4 4 0 0 0-4-4H4"/><path d="M9 14 4 9l5-5"/></svg>
</button>
<button type="button" class="mask-tool-btn" id="mask-btn-redo" title="Redo (Ctrl+Y)" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-corner-up-right-icon lucide-corner-up-right"><path d="m15 14 5-5-5-5"/><path d="M4 20v-7a4 4 0 0 1 4-4h12"/></svg>
</button>
<span class="mask-divider"></span>
<button type="button" class="mask-tool-btn active" id="mask-btn-draw">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil-sparkles-icon lucide-pencil-sparkles"><path d="M10 3H8"/><path d="m15.007 5.008 3.987 3.986"/><path d="M20 15v4"/><path d="M21.174 6.813a2.82 2.82 0 0 0-3.986-3.987L3.842 16.175a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="M22 17h-4"/><path d="M4 5v4"/><path d="M6 7H2"/><path d="M9 2v2"/></svg>
</button>
<button type="button" class="mask-tool-btn" id="mask-btn-erase">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eraser-icon lucide-eraser"><path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21"/><path d="m5.082 11.09 8.828 8.828"/></svg>
</button>
<button type="button" class="mask-tool-btn" id="mask-btn-invert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-dot-icon lucide-circle-dot"><path d="M 12 2 A 10 10 0 0 0 12 22 Z" fill="currentColor"/><circle cx="12" cy="12" r="10"/></svg>
</button>
<span class="mask-divider"></span>
<button type="button" class="mask-tool-btn btn-danger" id="mask-btn-clear">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<span class="mask-divider" id="last-divider" style="display: none;"></span>
<label class="mask-slider-label">
<span data-i18n="pen-size">Size:</span>
<input type="range" id="mask-brush-size" min="5" max="150" value="30">
<span id="mask-brush-size-val" style="font-family: monospace;">30px</span>
</label>
</div>
<div class="mask-actions-group">
<button type="button" class="modal-btn modal-btn-cancel" id="mask-btn-cancel" data-i18n="cancel">Cancel</button>
<button type="button" class="modal-btn modal-btn-save" id="mask-btn-save" data-i18n="label_save">Save</button>
</div>
</div>
</div>
</div>
<script>
const standaloneParams = new URLSearchParams(window.location.search);
const appFileName = standaloneParams.get('app');
const isStandaloneMode = !!appFileName || standaloneParams.get('mode') === 'standalone';
if (isStandaloneMode) {
const splash = document.getElementById('splash-screen');
const iconEl = splash.querySelector('.splash-icon');
const titleEl = splash.querySelector('.splash-title');
const subtitleEl = splash.querySelector('.splash-subtitle');
iconEl.classList.add("loading");
iconEl.innerText = "⏳";
titleEl.innerText = "Loading Nodes & Models";
setTimeout(() => {
titleEl.innerText = "Still Loading...";
subtitleEl.style.display = "block";
}, 6000);
setTimeout(() => {
titleEl.innerText = "Loading Nodes & Models";
},15000);
}
</script>
<script type="text/javascript" src="./i18n/i18n.js"></script>
<script>
// ⚡ ====== 无头模式 (Standalone Mode) 专用引擎 ====== ⚡
let standaloneClientId = "appview_standalone_" + Math.random().toString(36).substring(2, 9);
let standaloneWorkflowData = null;
let standaloneBuilderNode = null;
let standaloneWs = null;
let globalObjectInfo = null;
async function fetchObjectInfo() {
if (!globalObjectInfo) {
try {
const res = await fetch(`/appbuilder/fast_object_info?app=${encodeURIComponent(appFileName)}`);
if (res.ok) globalObjectInfo = await res.json();
} catch(e) { console.error("Failed to fetch object_info:", e); }
}
return globalObjectInfo;
}
// 🔒 100% 抓取页面所有 DOM 输入框的最新值(支持 TextArea, Slider, LoraStack, Select 等)
function getAppViewFormValues() {
const formValues = {};
const elements = document.querySelectorAll('input, select, textarea');
elements.forEach(el => {
let key = null;
if (el.id && el.id.startsWith('input_')) {
key = el.id.replace('input_', '');
} else if (el.id && el.id.startsWith('lora-data_')) {
key = el.id.replace('lora-data_', '');
}
if (key) {
if (el.type === 'checkbox') {
formValues[key] = el.checked;
} else if (el.type === 'number' || el.type === 'range') {
formValues[key] = Number(el.value);
} else {
formValues[key] = el.value;
}
}
});
return formValues;
}
// ⚡ 基于“下游槽位直接注入”原理的终极 API 转换引擎
async function convertWorkflowToApiPrompt(workflowData, formValues) {
const objectInfo = await fetchObjectInfo();
const prompt = {};
const linksMap = {};
if (workflowData.links) {
workflowData.links.forEach(link => {
// link: [link_id, origin_id, origin_slot, target_id, target_slot, type]
linksMap[link[0]] = [String(link[1]), link[2]];
});
}
const nodesMap = {};
workflowData.nodes.forEach(node => { nodesMap[String(node.id)] = node; });
// 1. 读取面板配置,并按槽位顺序构建映射 (0号槽: prompt, 1号槽: seed...)
const builderNode = workflowData.nodes.find(n => n.type === "AppBuilderAdv" || n.type === "AppBuilder");
let config = {};
if (builderNode) {
const configStr = builderNode.widgets_values?.[0] || "{}";
try { config = JSON.parse(configStr); } catch(e){}
}
const slotValuesMap = {};
const uiEntries = Object.entries(config).filter(([k, p]) => p.type !== "BUTTON" && p.type !== "BYPASSER");
uiEntries.forEach(([key, param], slotIdx) => {
let val = formValues[key] !== undefined ? formValues[key] : param.value;
if (val === undefined || val === null) val = param.default;
// 未上传新图片时,保留工作流默认图片
if (param.type === "UPLOADER" && (!val || val === "None")) val = null;
if (val !== null && val !== undefined) {
if (param.type === "INT" || param.type === "SEED") val = Math.round(Number(val));
else if (param.type === "FLOAT") val = Number(val);
else if (param.type === "BOOLEAN") val = Boolean(val);
}
slotValuesMap[slotIdx] = val;
});
const uiNodeTypes = ["AppBuilder", "AppBuilderAdv", "ParametersUnpacker", "ParameterUnpacker", "AppBuilderBypasser"];
// 2. 遍历真实节点,把 UI 参数直接注入到 downstream 槽位中
workflowData.nodes.forEach(node => {
const classType = node.type;
if (!objectInfo[node.type] || uiNodeTypes.includes(classType)) return; // 过滤 UI 节点
const nodePrompt = {
class_type: classType,
inputs: {},
_meta: { title: node.title || classType }
};
const nodeDef = objectInfo ? objectInfo[classType] : null;
// A. 先用节点的默认 Widget 控件值填充
if (nodeDef && nodeDef.input) {
const reqInputs = nodeDef.input.required || {};
const optInputs = nodeDef.input.optional || {};
const allInputDefs = { ...reqInputs, ...optInputs };
let widgetIdx = 0;
const isWidgetSpec = (spec) => {
if (!spec || !Array.isArray(spec)) return false;
if (Array.isArray(spec[0])) return true;
const t = String(spec[0]).toUpperCase();
// 增加对 COMFY_DYNAMICCOMBO_V3 等新版动态/复合控件的兼容识别
return ["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", "NUMBER"].includes(t)
|| t.includes("COMBO")
|| t.startsWith("COMFY_");
};
const rawWidgetsValues = node.widgets_values;
const widgetValuesArray = Array.isArray(rawWidgetsValues) ? rawWidgetsValues : [];
const widgetValuesObj = (typeof rawWidgetsValues === "object" && !Array.isArray(rawWidgetsValues) && rawWidgetsValues !== null) ? rawWidgetsValues : {};
for (const [inputName, inputSpec] of Object.entries(allInputDefs)) {
if (isWidgetSpec(inputSpec)) {
// 1. 提取预设默认值 (Default Value)
let defaultValue = undefined;
if (Array.isArray(inputSpec) && inputSpec[1] && typeof inputSpec[1] === "object") {
defaultValue = inputSpec[1].default;
} else if (Array.isArray(inputSpec[0])) {
defaultValue = inputSpec[0][0];
}
// 2. 优先级:对象显式键值 -> 数组按序提取 -> object_info 默认值兜底
let val = undefined;
if (widgetValuesObj[inputName] !== undefined) {
val = widgetValuesObj[inputName];
} else if (widgetIdx < widgetValuesArray.length) {
val = widgetValuesArray[widgetIdx++];
if (typeof val === "string" && ["fixed", "increment", "decrement", "randomize"].includes(val)) {
if (widgetIdx < widgetValuesArray.length) val = widgetValuesArray[widgetIdx++];
}
} else {
val = defaultValue; // 🌟 兜底保障:无论如何填入默认值!
}
if (val !== undefined && val !== null) {
nodePrompt.inputs[inputName] = val;
}
// 3. 处理 DynamicCombo 复合控件 (如 SaveImageAdvanced)
const typeStr = String(inputSpec[0]).toUpperCase();
if (typeStr.includes("DYNAMICCOMBO") || typeStr.includes("DYNAMIC_COMBO")) {
const comboOpts = inputSpec[1]?.options;
if (Array.isArray(comboOpts)) {
const selKey = (typeof val === "object" && val !== null) ? (val.key || val.name || val.value) : val;
const selectedOpt = comboOpts.find(o => o.key === selKey || o.name === selKey);
if (selectedOpt && selectedOpt.inputs) {
const subReq = selectedOpt.inputs.required || {};
const subOpt = selectedOpt.inputs.optional || {};
const allSubDefs = { ...subReq, ...subOpt };
for (const [subName, subSpec] of Object.entries(allSubDefs)) {
let subDefault = subSpec[1]?.default ?? (Array.isArray(subSpec[0]) ? subSpec[0][0] : undefined);
let subVal = undefined;
if (widgetValuesObj[`${inputName}.${subName}`] !== undefined) {
subVal = widgetValuesObj[`${inputName}.${subName}`];
} else if (widgetIdx < widgetValuesArray.length) {
subVal = widgetValuesArray[widgetIdx++];
} else {
subVal = subDefault;
}
if (subVal !== undefined && subVal !== null) {
nodePrompt.inputs[`${inputName}.${subName}`] = subVal;
}
}
}
}
}
// 4. 处理 VHS_VideoCombine 专属动态格式参数
if (inputSpec[1] && inputSpec[1].formats) {
const formatsObj = inputSpec[1].formats;
const formatKey = (typeof val === "object" && val !== null) ? (val.key || val.name || val.value) : val;
if (formatKey && formatsObj[formatKey]) {
const subWidgetDefs = formatsObj[formatKey];
if (Array.isArray(subWidgetDefs)) {
for (const subItem of subWidgetDefs) {
if (Array.isArray(subItem) && subItem.length > 0) {
const subName = subItem[0];
let subDefault = subItem[2]?.default ?? (Array.isArray(subItem[1]) ? subItem[1][0] : undefined);
let subVal = undefined;
if (widgetValuesObj[subName] !== undefined) {
subVal = widgetValuesObj[subName];
} else if (widgetIdx < widgetValuesArray.length) {
subVal = widgetValuesArray[widgetIdx++];
} else {
subVal = subDefault;
}
if (subVal !== undefined && subVal !== null) {
nodePrompt.inputs[subName] = subVal;
}
}
}
}
}
}
}
} // 闭合 for (inputName)
} // 🌟 补上了这里缺失的右大括号,闭合 if (nodeDef && nodeDef.input) !
// B. 处理槽位连线:如果是 UI 节点的线,直接用 slotValuesMap 注入!如果是普通节点,保持 ["4", 0]
if (node.inputs) {
node.inputs.forEach(input => {
if (input.link) {
const originInfo = linksMap[input.link];
if (originInfo) {
const originNodeId = originInfo[0];
const originSlot = originInfo[1];
const originNode = nodesMap[originNodeId];
if (originNode && uiNodeTypes.includes(originNode.type)) {
// 🎯 来自 UI 节点的线:直接替换注入表单值!
const injectedVal = slotValuesMap[originSlot];
if (injectedVal !== undefined && injectedVal !== null) {
nodePrompt.inputs[input.name] = injectedVal;
}
} else {
// 真实物理节点连线:保持引用
nodePrompt.inputs[input.name] = [originNodeId, originSlot];
}
}
}
});
}
prompt[String(node.id)] = nodePrompt;
}); // 闭合 workflowData.nodes.forEach
// C. 兜底处理直连型 AppBuilder (如果有)
Object.entries(config).forEach(([key, param]) => {
const targetNodeId = param._node_id ? String(param._node_id) : null;
if (targetNodeId && prompt[targetNodeId]) {
let userVal = formValues[key] !== undefined ? formValues[key] : param.value;
if (userVal !== undefined && userVal !== null && userVal !== "None") {
if (param.type === "INT" || param.type === "SEED") userVal = Math.round(Number(userVal));
else if (param.type === "FLOAT") userVal = Number(userVal);
else if (param.type === "BOOLEAN") userVal = Boolean(userVal);
const nodePrefix = (nodesMap[targetNodeId]?.title || nodesMap[targetNodeId]?.type || "").replace(/[^a-zA-Z0-9_]/g, "_");
let extractedSlot = key;
if (key.startsWith(nodePrefix + "_")) extractedSlot = key.substring(nodePrefix.length + 1);
else { const p = key.split('_'); if (p.length > 1) extractedSlot = p.slice(1).join('_'); }
for (const inKey of Object.keys(prompt[targetNodeId].inputs)) {
if (inKey.toLowerCase() === extractedSlot.toLowerCase()) {
prompt[targetNodeId].inputs[inKey] = userVal;
break;
}
}
}
}
});
return prompt;
}
// ⚡ 归一化 WebSocket 引擎:完美适配原生数据格式,解决卡生成中与走字图
function initStandaloneWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
standaloneWs = new WebSocket(`${protocol}//${location.host}/ws?clientId=${standaloneClientId}`);
standaloneWs.binaryType = "arraybuffer";
standaloneWs.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const eventType = view.getUint32(0);
if (eventType === 1) {
const imageType = view.getUint32(4);
const mimeType = imageType === 1 ? "image/png" : "image/jpeg";
const imageBlob = new Blob([event.data.slice(8)], { type: mimeType });
window.postMessage({ type: 'b_preview', blob: imageBlob }, '*');
}
}
else {
try {
const msg = JSON.parse(event.data);
let detail = msg.data;
if (msg.type === "status" && detail && detail.status) detail = detail.status;
window.postMessage({ type: msg.type, detail: detail }, '*');
} catch(e){}
}
};
standaloneWs.onclose = () => { setTimeout(initStandaloneWebSocket, 2000); };
}
</script>
<script>
let sourceNodeId = null;
let activeInputEl = null;
let currentLightboxIndex = 0;
let currentImagesList = [];
let logHeight = localStorage.getItem('appview_log_height') || 250;
let isLogOpen = false;
let galleryData = [];
try {
const historyStr = localStorage.getItem('appview_gallery_history');
if (historyStr) { galleryData = JSON.parse(historyStr); if (!Array.isArray(galleryData)) galleryData = []; }
} catch(e) { galleryData = []; }
// 🔍 精准检测 URL 是否为视频文件
function isVideoUrl(urlStr) {
if (!urlStr) return false;
try {
const urlObj = new URL(urlStr, window.location.href);
const fn = urlObj.searchParams.get('filename') || urlObj.pathname;
const ext = fn.split('?')[0].split('.').pop().toLowerCase();
return ['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(ext);
} catch(e) {
const lower = urlStr.toLowerCase();
return lower.includes('.mp4') || lower.includes('.webm') || lower.includes('.mov');
}
}
function renderGalleryItem(src, isPrepend) {
const galleryScroll = document.getElementById('gallery-scroll');
if (!galleryScroll) return;
const placeholder = document.createElement('div');
placeholder.className = 'gallery-item-placeholder';
if (isPrepend) { galleryScroll.insertBefore(placeholder, galleryScroll.firstChild); }
else { galleryScroll.appendChild(placeholder); }
const isVideo = isVideoUrl(src);
let itemEl;
if (isVideo) {
const wrap = document.createElement('div');
wrap.className = 'gallery-item-wrap';
const vid = document.createElement('video');
// 🌟 Safari 兼容关键 1: 加上 #t=0.001 强制 Safari 解码第 0.001 秒作为静态封面!
const safariVideoSrc = src.includes('#') ? src : `${src}#t=0.001`;
vid.src = safariVideoSrc;
vid.preload = 'metadata';
vid.className = 'gallery-item';
// 🌟 Safari 兼容关键 2: iOS Safari 必须带有 muted 和 playsinline,否则会黑屏
vid.muted = true;
vid.defaultMuted = true;
vid.playsInline = true;
vid.setAttribute('playsinline', '');
vid.setAttribute('webkit-playsinline', '');
// 假播放按钮 ▶
const badge = document.createElement('div');
badge.className = 'gallery-play-badge';
badge.innerText = '▶';
wrap.appendChild(vid);
wrap.appendChild(badge);
wrap.onclick = function() { openLightbox(src); };
placeholder.replaceWith(wrap);
} else {
const testImg = new Image();
testImg.onload = () => {
itemEl = document.createElement('img');
itemEl.src = src; itemEl.className = 'gallery-item';
itemEl.onclick = function() { openLightbox(this.src); };
placeholder.replaceWith(itemEl);
};
testImg.onerror = () => {
placeholder.remove();
galleryData = galleryData.filter(url => url !== src);
localStorage.setItem('appview_gallery_history', JSON.stringify(galleryData));
};
testImg.src = src;
}
}
function updateQueueBadge(count) {
const badge = document.getElementById('queue-badge');
if (!badge) return;
const num = parseInt(count) || 0;
if (num > 0) { badge.innerText = num; badge.style.display = 'flex'; }
else { badge.style.display = 'none'; }
}
let lastLogId = 0;
async function restoreLogHistory() {
try {
const res = await fetch(`/appbuilder/logs?after=0`);
if (res.ok) {
const logs = await res.json();
logs.forEach(log => {
addRawTerminalLog(log.text);
lastLogId = Math.max(lastLogId, log.id);
});
}
} catch (e) { console.error("Failed to restore log history:", e); }
}
let lastTerminalLineEl = null;
function addRawTerminalLog(lineText) {
const content = document.getElementById('log-content');
if (!content) return;
const isProgress = lineText.includes('it/s') || lineText.includes('%|') || lineText.includes('s/it');
const cleanText = lineText.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim();
if (!cleanText) return;
if (cleanText.includes('[WinError 10054]')) return;
if (isProgress && lastTerminalLineEl && lastTerminalLineEl.parentNode === content) {
lastTerminalLineEl.innerHTML = cleanText;
} else {
const lineEl = document.createElement('div');
let typeClass = 'white';
if (cleanText.includes('[ERROR]') || cleanText.includes('Exception') || cleanText.includes('FAILED')) { typeClass = 'error'; }
else if (cleanText.includes('[WARNING]')) { typeClass = 'warning'; }
else if (cleanText.includes('Prompt executed') || cleanText.includes('executed in') || cleanText.includes('[INFO] Prompt executed')) { typeClass = 'green'; }
else if (cleanText.includes('got prompt') || cleanText.includes('[INFO] got prompt') || isProgress) { typeClass = 'white'; }
lineEl.className = `log-line ${typeClass}`;
lineEl.innerHTML = cleanText;
content.appendChild(lineEl);
if (isProgress) { lastTerminalLineEl = lineEl; } else { lastTerminalLineEl = null; }
}
content.scrollTop = content.scrollHeight;
while (content.childNodes.length > 420) { content.removeChild(content.firstChild); }
}
function createNumericInput(options = {}) {
const type = (options.type || "INT").toUpperCase();
const minVal = options.min !== undefined ? Number(options.min) : -Infinity;
const maxVal = options.max !== undefined ? Number(options.max) : Infinity;
const step = options.step !== undefined ? Number(options.step) : (type === "INT" ? 1 : 0.1);
const precision = options.precision !== undefined ? Number(options.precision) : (type === "FLOAT" ? 3 : 0);
const initialVal = options.value ?? 0;
const wrapper = document.createElement('div');
wrapper.className = 'numeric-input-wrapper';
const btnMinus = document.createElement('button');
btnMinus.type = 'button';
btnMinus.className = 'btn-numeric-adjust btn-numeric-adjust-left';
btnMinus.innerText = '◀';
const input = document.createElement('input');
input.type = 'number';
input.className = 'number-input';
if (options.id) input.id = options.id;
if (options.min !== undefined) input.min = options.min;
if (options.max !== undefined) input.max = options.max;
input.step = type === "FLOAT" ? "any" : step;
input.value = type === "FLOAT"
? Number(initialVal).toFixed(precision)
: Math.round(Number(initialVal));
const btnPlus = document.createElement('button');
btnPlus.type = 'button';
btnPlus.className = 'btn-numeric-adjust btn-numeric-adjust-right';
btnPlus.innerText = '▶';
// 🔒【核心修复】:防死循环锁
let isUpdating = false;
const applyValueChange = (rawVal, isFromNativeEvent = false) => {
if (isUpdating) return; // 阻止死循环递归!
isUpdating = true;
let val = Number(rawVal);
if (isNaN(val)) val = 0;
if (val < minVal) val = minVal;
if (val > maxVal) val = maxVal;
const formatted = type === "FLOAT" ? val.toFixed(precision) : Math.round(val);
const isTyping = (document.activeElement === input) && isFromNativeEvent;
if (!isTyping || Number(rawVal) !== val) {
if (input.value !== formatted) {
input.value = formatted;
}
}
// 只有由 ◀ ▶ 按钮触发时才手动派发事件;如果是打字触发的原生事件则不再重复派发
if (!isFromNativeEvent) {
input.dispatchEvent(new Event('change'));
}
if (typeof options.onChange === 'function') {
options.onChange(formatted);
}
isUpdating = false; // 解锁
};
// 事件绑定
btnMinus.onclick = () => applyValueChange(Number(input.value) - step, false);
btnPlus.onclick = () => applyValueChange(Number(input.value) + step, false);
// 监听打字失焦事件,传入 isFromNativeEvent = true
input.addEventListener('change', () => applyValueChange(input.value, true));
// 组装 DOM
wrapper.appendChild(btnMinus);
wrapper.appendChild(input);
wrapper.appendChild(btnPlus);
return { wrapper, input };
}
window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg) return;
const statusText = document.getElementById('status-text');
if (msg.type === 'b_preview') {
if (msg.blob) document.getElementById('main-preview').src = URL.createObjectURL(msg.blob);
}
else if (msg.type === 'executed') {
const data = msg.detail;
if (data && data.output) {
const mediaItems = [];
// 递归提取 data.output 下所有的文件项(兼容视频/图片/动画节点)
Object.values(data.output).forEach(val => {
if (Array.isArray(val)) {
val.forEach(item => { if (item && item.filename) mediaItems.push(item); });
}
});
mediaItems.forEach(item => {
const mediaUrl = `/api/view?filename=${encodeURIComponent(item.filename)}&subfolder=${encodeURIComponent(item.subfolder || '')}&type=${encodeURIComponent(item.type || 'output')}`;
// 加入历史记录与画廊
galleryData.unshift(mediaUrl);
if (galleryData.length > 1000) galleryData = galleryData.slice(0, 1000);
localStorage.setItem('appview_gallery_history', JSON.stringify(galleryData));
renderGalleryItem(mediaUrl, true);
const lightboxModal = document.getElementById('lightbox-modal');
if (lightboxModal && lightboxModal.style.display === 'flex') {
const currentViewedUrl = currentImagesList[currentLightboxIndex];
const items = Array.from(document.querySelectorAll('.gallery-item'));
currentImagesList = items.map(i => i.src || i.currentSrc);
currentLightboxIndex = currentImagesList.indexOf(currentViewedUrl);
if (currentLightboxIndex === -1) currentLightboxIndex = 0;
}
});
}
}
else if (msg.type === 'execution_start') {
statusText.innerText = t("label_generating", "Generating...");
statusText.style.color = "var(--primary)";
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('btn-run').style.backgroundColor = 'var(--primary)';
document.getElementById('btn-run').innerText = t("button_running", "▷ Running...");
syncParentValuesToAppView();
}
else if (msg.type === 'progress') {
const data = msg.detail;
if (data && data.max) {
const percentage = (data.value / data.max) * 100;
document.getElementById('progress-bar').style.width = `${percentage}%`;
}
}
else if (msg.type === 'status') {
if (msg.detail && msg.detail.exec_info) {
const remaining = msg.detail.exec_info.queue_remaining;
updateQueueBadge(remaining);
if (msg.detail.exec_info.queue_remaining === 0) {
setTimeout(() => {
statusText.innerText = t("label_ready", "Ready");
statusText.style.color = "var(--title)";
}, 300);
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('btn-run').style.backgroundColor = '#3E8AE2';
document.getElementById('btn-run').innerText = t("button_run", "▷ Run");
syncParentValuesToAppView();
}
}
}
else if (msg.type === 'execution_interrupted') {
statusText.innerText = t("label_interrupted", "Interrupted");
statusText.style.color = "#f44336";
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('btn-run').style.backgroundColor = '#3E8AE2';
document.getElementById('btn-run').innerText = t("button_run", "▷ Run");
updateQueueBadge(0);
}
else if (msg.type === 'execution_error') {
const data = msg.detail;
statusText.innerText = "Error";
statusText.style.color = "#f44336";
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('btn-run').style.backgroundColor = '#3E8AE2';
document.getElementById('btn-run').innerText = t("button_run", "▷ Run");
updateQueueBadge(0);
if (data) {
const errorMsg = data.exception_message || "Unknown error";
const nodeType = data.node_type || "Unknown Node";
const nodeIdVal = data.node_id || "?";
const errType = data.exception_type || "Exception";
alert(`⚠️ ComfyUI Execution Error\n\nFailed Node: ${nodeType} (ID: ${nodeIdVal})\nError Type: ${errType}\n\nMessage:\n${errorMsg}`);
}
}
else if (msg.type === 'pre_queue_error') {
statusText.innerText = t("label_error", "Error");
statusText.style.color = "#f44336";
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('btn-run').style.backgroundColor = '#3E8AE2';
document.getElementById('btn-run').innerText = t("button_run", "▷ Run");
updateQueueBadge(0);
alert(t("queue_error", "⚠️ Queue Error\n\n{0}", msg.message));
}
else if (msg.type === 'executing') {
let detail = msg.detail;
let nodeId = null;
if (detail) {
if (typeof detail === 'object') { nodeId = detail.node; } else { nodeId = detail; }
}
// 🎯 2. 情况一:生成结束 (nodeId 为 null/undefined)
if (nodeId === null || nodeId === undefined) {
statusText.innerText = typeof t === 'function' ? t("label_ready", "Ready") : "Ready";
statusText.style.color = "var(--title)";
document.getElementById('progress-bar').style.width = '0%';
const btnRun = document.getElementById('btn-run');
if (btnRun) {
btnRun.style.backgroundColor = '#3E8AE2';
btnRun.innerText = typeof t === 'function' ? t("button_run", "▷ Run") : "▷ Run";
}
}
// 🎯 3. 情况二:正在运行某个节点
else {
let nodeName = null;
// A. ⚡ 独立无头模式 (Standalone Mode)
if (isStandaloneMode && standaloneWorkflowData && standaloneWorkflowData.nodes) {
const node = standaloneWorkflowData.nodes.find(n => String(n.id) === String(nodeId));
if (node) {
const displayName = globalObjectInfo?.[node.type]?.display_name;
nodeName = node.title || displayName || node.type || `Node #${nodeId}`;
}
}
// B. 🎨 画布模式 (Canvas Mode)
else if (window.comfyApp) {
const node = window.comfyApp.graph.getNodeById(nodeId);
if (node) nodeName = node.title || node.type || `Node #${nodeId}`;
}
// 更新状态文本
statusText.innerText = nodeName || `Node #${nodeId}`;
statusText.style.color = "var(--primary)";
}
}
else if (msg.type === "appbuilder_log") {
const detail = msg.detail;
if (detail && detail.text) {
addRawTerminalLog(detail.text);
if (detail.id) lastLogId = Math.max(lastLogId, detail.id);
}
}
});
window.addEventListener('DOMContentLoaded', async () => {
await loadI18n();
const splash = document.getElementById('splash-screen');
if (splash && !isStandaloneMode) setTimeout(() => { splash.classList.add('fade-out'); }, 400);
const urlParams = new URLSearchParams(window.location.search);
const nodeId = urlParams.get('nodeId');
const isIframe = window.parent !== window;
const parentWindow = isIframe ? window.parent : (window.opener && !window.opener.closed ? window.opener : null);
const logContainer = document.getElementById('log-drawer-container');
const logBtn = document.getElementById('console-toggle');
if (localStorage.getItem('appview_log_height')) { logHeight = parseInt(localStorage.getItem('appview_log_height')); }
logBtn.onclick = (e) => {
e.stopPropagation();
isLogOpen = !isLogOpen;
logBtn.classList.toggle('active', isLogOpen);
const isDesktop = window.innerWidth >= 768;
if (isLogOpen) {
if (isDesktop) { logContainer.style.height = `${logHeight}px`; } else { applyMobileLayout(logHeight); }
setTimeout(() => { const content = document.getElementById('log-content'); if (content) content.scrollTop = content.scrollHeight; }, 250);
} else {
if (isDesktop) { logContainer.style.height = '0px'; } else { applyMobileLayout(0); }
}
};
restoreLogHistory();
themeButtonText(localStorage.getItem('appview_theme') || 'system');
document.getElementById("theme-toggle").onclick = () => {
const currentTheme = localStorage.getItem('appview_theme') || 'system';
let nextTheme = 'system';
if (currentTheme === 'system') nextTheme = 'light';
else if (currentTheme === 'light') nextTheme = 'dark';
else nextTheme = 'system';
localStorage.setItem('appview_theme', nextTheme);
applyTheme(nextTheme);
};
const closeBtn = document.getElementById('iframe-close-btn');
if (closeBtn) {
closeBtn.style.display = 'flex';
closeBtn.onclick = (e) => {
e.stopPropagation();
if (isStandaloneMode) {
window.location.href = `apps?mode=standalone`;
} else if (isIframe) {
window.parent.postMessage({ type: "close_appview" }, "*");
} else {
window.close();
}
};
}
// 💾 绑定无头模式原位保存按钮
document.getElementById('save-btn').onclick = async (e) => {
e.stopPropagation();
const statusText = document.getElementById('status-text');
statusText.style.color = "var(--primary)";
statusText.innerText = t("saving", "Saving...");
if (!isStandaloneMode) {
window.parent.postMessage({ type: "save_workflow" }, "*");
statusText.style.color = "var(--primary)";
statusText.innerText = t("app_saved", "App Saved");
setTimeout(() => {
statusText.style.color = "var(--title)";
statusText.innerText = t("label_ready", "Ready");
}, 1500);
} else {
if (!standaloneWorkflowData || !appFileName) return;
try {
const formValues = getAppViewFormValues();
if (standaloneBuilderNode) {
const configStr = standaloneBuilderNode.widgets_values?.[0] || "{}";
const config = JSON.parse(configStr);
Object.keys(config).forEach(key => {
if (formValues[key] !== undefined) {
config[key].value = formValues[key];
config[key].default = formValues[key];
}
});
// 🌟 核心修复:独立模式下将种子的随机模式(固定/随机/递增)保存进 JSON
const seedActionBtns = document.querySelectorAll('[id^="input_action_btn_"]');
seedActionBtns.forEach(btn => {
const seedKey = btn.id.replace('input_action_btn_', '');
if (config[seedKey]) {
config[seedKey].seed_mode = btn.dataset.mode;
}
});
standaloneBuilderNode.widgets_values[0] = JSON.stringify(config);
}
const response = await fetch('/appbuilder/workflows/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
filename: appFileName,
content: standaloneWorkflowData
})
});
if (response.ok) {
statusText.style.color = "var(--primary)";
statusText.innerText = t("app_saved", "App Saved");
} else {
const err = await response.json();
statusText.style.color = "var(--red)";
statusText.innerText = t("label_error", "Error");
alert(t("save_failed_alert", "⚠️ Save Failed\n\n{0}" ,(err.error || response.statusText)));
}
} catch (err) {
console.error("[AppBuilder] Save workflow failed:", err);
statusText.style.color = "var(--red)";
statusText.innerText = t("save_failed", "Save Failed");
} finally {
setTimeout(() => {
statusText.style.color = "var(--title)";
statusText.innerText = t("label_ready", "Ready");
}, 1500);
}
};
}
document.getElementById('unload-btn').onclick = async (e) => {
const statusText = document.getElementById('status-text');
try {
statusText.style.color = "var(--primary)";
statusText.innerText = t("unloading", "Unloading Models...");
const response = await fetch('/free', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({unload_models: true, free_memory: true})
});
if (response.ok) {
statusText.innerText = t("unloaded", "Models and Execution Cache have been cleared");
} else {
statusText.style.color = "var(--red)";
statusText.innerText = response.statusText;
}
} catch (err) {
console.error("[AppBuilder] Failed to fetch: ", err);
} finally {
setTimeout(() => {
statusText.style.color = "var(--title)";
statusText.innerText = t("label_ready", "Ready");
}, 1500);
}
};
galleryData.forEach(src => { renderGalleryItem(src, false); });
document.getElementById('btn-clear-gallery').onclick = (e) => {
e.stopPropagation();
const confirmText = t("confirm_clear_gallery", "Clear generation history?");
if (confirm(confirmText)) {
const galleryScroll = document.getElementById('gallery-scroll');
if (galleryScroll) galleryScroll.innerHTML = ''; // 👈 一键彻底清空图片、视频及 ▶ 播放按钮
galleryData = []; localStorage.removeItem('appview_gallery_history');
}
};
// 🌟【分流判断】:无头独立模式 VS 画布模式
if (isStandaloneMode) {
try {
// 从后端下载工作流配置文件
await fetchObjectInfo();
setTimeout(() => { if (splash) splash.classList.add('fade-out'); }, 400);
const res = await fetch(`/appbuilder/workflows/get?file=${encodeURIComponent(appFileName)}`);
if (res.ok) {
standaloneWorkflowData = await res.json();
standaloneBuilderNode = standaloneWorkflowData.nodes.find(n => n.type === "AppBuilderAdv" || n.type === "AppBuilder");
if (!standaloneBuilderNode) {
alert(t("not_app", "⚠️ Invalid App Workflow\n\nThis is not an App workflow!\nPlease configure it using AppBuilder first."));
window.location.href = `apps?mode=standalone`;
return;
}
let hasSubgraph = false;
let hasBypasser = false;
// 1. 嗅探子图:检查工作流中是否包含 UUID 类型的节点 (Group Node V2)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (standaloneWorkflowData.nodes) {
for (const node of standaloneWorkflowData.nodes) {
if (uuidRegex.test(node.type)) {
hasSubgraph = true; break;
}
}
}
// 2. 嗅探 Bypasser:检查 AppBuilder 配置中是否使用了 Bypasser 组件
if (standaloneBuilderNode) {
const jsonStr = standaloneBuilderNode.widgets_values?.[0] || "{}";
try {
const config = JSON.parse(jsonStr);
for (const key in config) {
if (config[key].type === "BYPASSER") {
hasBypasser = true; break;
}
}
} catch(e) {}
}
// 如果命中其中一项,直接弹窗警告,并禁用 Run 按钮
if (hasSubgraph || hasBypasser) {
alert(t("no_subgraph_alert", "⚠️ Warning\n\nYou cannot use subgraphs or bypassers in standalone mode; please use this workflow in canvas mode."));
window.location.href = `apps?mode=standalone`;
}
if (standaloneBuilderNode) {
const jsonStr = standaloneBuilderNode.widgets_values?.[0] || "{}";
renderDynamicForm(JSON.parse(jsonStr));
}
}
} catch(e) { console.error("Load standalone app failed:", e); }
initResizeLogic();
initLogResizeLogic();
handleResponsiveLayout(true);
window.addEventListener('resize', () => handleResponsiveLayout(false));
initLightboxEvents();
initStandaloneWebSocket();
restoreLogHistory(); // 恢复历史运行日志
return; // 无头模式初始化完成
}
// 下面保持你原有的在画布中打开的 (nodeId && parentWindow) 逻辑不变...
if (nodeId && parentWindow) {
const parentApp = parentWindow.comfyApp;
const parentApi = parentWindow.comfyApi;
window.comfyApp = parentApp;
window.comfyApi = parentApi;
const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.style.display = "flex";
refreshBtn.onclick = async (e) => {
if (splash) {
splash.classList.remove('fade-out');
const titleEl = splash.querySelector('.splash-title');
const iconEl = splash.querySelector('.splash-icon');
iconEl.classList.add("loading");
iconEl.innerText = "⏳";
titleEl.innerText = t("refreshing", "Loading Nodes & Models");
}
try {
if (window.comfyApp && typeof window.comfyApp.refreshComboInNodes === "function") {
await window.comfyApp.refreshComboInNodes();
}
} catch (err) {
console.warn("Refresh failed:", err);
}
await new Promise(resolve => setTimeout(resolve, 1000));
location.reload();
};
const parentNode = parentApp.graph.getNodeById(nodeId);
if (parentNode) {
sourceNodeId = nodeId;
if (parentNode.registerAppWindow) parentNode.registerAppWindow(window);
const jsonStr = parentNode.widgets.find(w => w.name === "config_json")?.value || "{}";
renderDynamicForm(JSON.parse(jsonStr));
initResizeLogic();
initLogResizeLogic();
handleResponsiveLayout(true);
window.addEventListener('resize', () => handleResponsiveLayout(false));
initLightboxEvents();
} else {
document.getElementById('dynamic-form').innerHTML =
`<div style="text-align:center; padding: 20px; color:#f44336; line-height: 1.6;">
<p style="font-size: 24px; margin-bottom:10px;">⚠️</p>
<p style="font-size: 14px; font-weight:bold;">${t('node_lost_alert', 'The original workflow was closed or not activated!')}</p>
</div>`;
}
} else {
document.getElementById('dynamic-form').innerHTML =
`<div style="text-align:center; padding: 20px; color:#f44336; line-height: 1.6;">
<p style="font-size: 24px; margin-bottom:10px;">⚠️</p>
<p style="font-size: 14px; font-weight:bold;">${t('connection_lost', 'Connection lost! Please reopen from the original node.')}</p>
</div>`;
}
});
let isDesktopState = window.innerWidth >= 768;
function handleResponsiveLayout(isInit = false) {
const isDesktop = window.innerWidth >= 768;
const rightPanel = document.querySelector('.right-panel');
const viewerContainer = document.querySelector('.viewer-container');
if (!rightPanel || !viewerContainer) return;
adjustGalleryLayout();
if (isInit) {
if (isDesktop) {
const savedWidth = localStorage.getItem('appview_panel_width') || "400";
rightPanel.style.width = `${savedWidth}px`;
rightPanel.style.flexBasis = `${savedWidth}px`;
} else {
const savedHeight = localStorage.getItem('appview_preview_height') || "30dvh";
viewerContainer.style.flex = `0 0 ${savedHeight}px`;
}
return;
}
if (isDesktop && !isDesktopState) {
viewerContainer.style.flex = "";
const savedWidth = localStorage.getItem('appview_panel_width') || "400";
rightPanel.style.width = `${savedWidth}px`;
rightPanel.style.flexBasis = `${savedWidth}px`;
isDesktopState = true;
} else if (!isDesktop && isDesktopState) {
rightPanel.style.width = "";
rightPanel.style.flexBasis = "";
const savedHeight = localStorage.getItem('appview_preview_height') || "30dvh";
viewerContainer.style.flex = `0 0 ${savedHeight}px`;
isDesktopState = false;
}
}
function adjustGalleryLayout() {
const isDesktop = window.innerWidth >= 768;
const gallery = document.getElementById('gallery');
if (!gallery) return;
if (isDesktop) {
const viewerContainer = document.querySelector('.viewer-container');
if (viewerContainer && gallery.parentNode !== viewerContainer) { viewerContainer.appendChild(gallery); }
} else {
const actionBar = document.querySelector('.bottom-action-bar');
const buttonGroup = document.querySelector('.button-group');
if (actionBar && buttonGroup && gallery.parentNode !== actionBar) { actionBar.insertBefore(gallery, buttonGroup); }
}
}
// 核心:同步主画布参数(增加了 🔀 随机模式防跳字拦截)
function syncParentValuesToAppView() {
if (!window.comfyApp || !sourceNodeId) return;
const node = window.comfyApp.graph.getNodeById(sourceNodeId);
if (!node) return;
for (const widget of node.widgets) {
const isSeedWidget = !!document.getElementById(`input_action_btn_${widget.name}`);
if (isSeedWidget) {
// 🌟 1. 检查当前的种子模式
const modeBtn = document.getElementById(`input_action_btn_${widget.name}`);
const isRandomMode = modeBtn && modeBtn.innerText === '🔀';
// 🌟 2.【核心防跳字修复】:如果是 🔀 随机模式,绝对禁止读取画布并覆盖 AppView 的数字!
if (isRandomMode) {
continue; // 👈 直接跳过,死死保持 AppView 提交的真实种子,绝不闪烁跳字!
}
const inputEl = document.getElementById(`input_${widget.name}`);
if (inputEl) {
inputEl.value = widget.value;
inputEl.dispatchEvent(new Event('change'));
inputEl.dispatchEvent(new Event('input'));
}
if (widget.name === 'control_after_generate') {
const idx = node.widgets.indexOf(widget);
if (idx > 0) {
const seedWidget = node.widgets[idx - 1];
const modeBtn = document.getElementById(`input_action_btn_${seedWidget.name}`);
if (modeBtn) {
const modeIcons = { "fixed": "⏺️", "increment": "⤴️", "decrement": "⤵️", "randomize": "🔀" };
modeBtn.innerText = modeIcons[widget.value] || "🔀";
modeBtn.title = `Seed Control: ${widget.value}`;
}
}
}
}
}
}
async function renderDynamicForm(config) {
const formContainer = document.getElementById('dynamic-form');
formContainer.innerHTML = '';
for (const [key, params] of Object.entries(config)) {
const type = (params.type || "STRING").toUpperCase();
if (type === "BUTTON") continue;
const group = document.createElement('div');
group.className = 'control-group';
const label = document.createElement('label');
label.className = 'control-title';
label.innerText = params.name || key;
group.appendChild(label);
const parentNode = window.comfyApp ? window.comfyApp.graph.getNodeById(sourceNodeId) : null;
const parentWidget = parentNode ? parentNode.widgets.find(w => w.name === key) : null;
// 💡【核心修复】:三级兜底读取 (parentWidget.value -> params.value -> params.default)
let currentValue = parentWidget ? parentWidget.value : undefined;
if (currentValue === undefined || currentValue === null || currentValue === "") {
currentValue = params.value !== undefined ? params.value : params.default;
}
if (type === "INT" || type === "FLOAT") {
const isSlider = params.slider === true || params.display === "slider";
const precision = params.precision !== undefined ? Number(params.precision) : (type === "FLOAT" ? 3 : 0);
if (isSlider) {
const minVal = params.min !== undefined ? Number(params.min) : -999999;
const maxVal = params.max !== undefined ? Number(params.max) : 999999;
const step = params.step !== undefined ? Number(params.step) : (type === "INT" ? 1 : 0.1);
let initVal = Number(currentValue ?? params.default ?? 0);
if (initVal < minVal) initVal = minVal;
if (initVal > maxVal) initVal = maxVal;
const formatVal = (v) => {
let n = Number(v);
if (Math.abs(n) < 1e-9) n = 0;
return type === "FLOAT" ? n.toFixed(precision) : String(Math.round(n));
};
label.innerHTML = `<span>${params.name || key}</span><span class="slider-value-badge" id="badge_${key}">${formatVal(initVal)}</span>`;
const input = document.createElement('input');
input.type = 'range';
input.min = minVal;
input.max = maxVal;
input.step = step;
input.value = initVal;
input.id = `input_${key}`;
// 🎨 动态计算渐变进度条 (带纽子 17px 半径物理偏移修正,彻底解决右侧溢出)
const updateTrack = () => {
const val = Number(input.value);
const rawPct = ((val - minVal) / (maxVal - minVal)) * 100;
// 📏 获取 input 物理宽度,计算纽子 (Thumb 34px) 的中心点偏移
const sliderWidth = input.offsetWidth || 250;
const thumbRadius = 17; // 34px / 2
const trackRange = Math.max(1, sliderWidth - thumbRadius * 2);
// 🎯 将 0~100% 的数值比例映射为滑块纽子中心点的实际物理百分比
const fillPx = thumbRadius + (rawPct / 100) * trackRange;
const fillPct = (fillPx / sliderWidth) * 100;
if (minVal < 0 && maxVal > 0) {
// 跨正负数双向 Slider
const rawZeroPct = ((-minVal) / (maxVal - minVal)) * 100;
const zeroPx = thumbRadius + (rawZeroPct / 100) * trackRange;
const zeroPct = (zeroPx / sliderWidth) * 100;
if (val >= 0) {
input.style.background = `linear-gradient(to right, var(--panel-bg) 0%, var(--panel-bg) ${zeroPct}%, var(--second) ${zeroPct}%, var(--second) ${fillPct}%, var(--panel-bg) ${fillPct}%, var(--panel-bg) 100%)`;
} else {
input.style.background = `linear-gradient(to right, var(--panel-bg) 0%, var(--panel-bg) ${fillPct}%, var(--second) ${fillPct}%, var(--second) ${zeroPct}%, var(--panel-bg) ${zeroPct}%, var(--panel-bg) 100%)`;
}
} else {
// 单向 Slider
input.style.background = `linear-gradient(to right, var(--second) 0%, var(--second) ${fillPct}%, var(--panel-bg) ${fillPct}%, var(--panel-bg) 100%)`;
}
};
input.addEventListener('input', () => {
const badge = document.getElementById(`badge_${key}`);
if (badge) badge.innerText = formatVal(input.value);
updateTrack();
});
setTimeout(updateTrack, 0);
group.appendChild(input);
} else {
const { wrapper, input } = createNumericInput({
type: type,
value: currentValue ?? 0,
min: params.min,
max: params.max,
step: params.step,
precision: precision,
id: `input_${key}`
});
group.appendChild(wrapper);
}
} else if (type === "LORA_STACK") {
const container = document.createElement('div');
container.className = 'lora-stack-container';
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = `lora-data_${key}`;
hiddenInput.value = currentValue || "[]";
container.appendChild(hiddenInput);
let loraValues = ["None"];
const targetFolder = params.folder || "loras";
if (targetFolder) {
try {
const response = await fetch(`/appbuilder/ls/${targetFolder}`);
if (response.ok) {
const data = await response.json();
loraValues = ["None", ...data];
}
} catch (e) { console.error(e); }
}
// 1. 顶部栏:Add 按钮 + 三段式总开关
const header = document.createElement('div');
header.className = 'lora-stack-header';
const btnAdd = document.createElement('button');
btnAdd.type = 'button';
btnAdd.className = 'btn-add-lora';
btnAdd.innerText = t('btn_add_lora', '+ Add LoRA');
const masterControlWrap = document.createElement('div');
masterControlWrap.style.cssText = 'display: flex; align-items: center; gap: 6px;';
const masterLabel = document.createElement('span');
masterLabel.style.cssText = 'font-size: 12px; color: var(--title); font-weight: bold;';
masterLabel.innerText = t('toggle_all', 'Toggle All');
const masterToggle = document.createElement('div');
masterToggle.className = 'tri-state-toggle all-off';
const masterThumb = document.createElement('div');
masterThumb.className = 'tri-state-thumb';
masterToggle.appendChild(masterThumb);
masterControlWrap.appendChild(masterLabel);
masterControlWrap.appendChild(masterToggle);
header.appendChild(btnAdd);
header.appendChild(masterControlWrap);
container.appendChild(header);
const rowsContainer = document.createElement('div');
rowsContainer.style.cssText = 'display: flex; flex-direction: column; gap: 6px;';
container.appendChild(rowsContainer);
const updateMasterSwitchState = () => {
const rows = rowsContainer.querySelectorAll('.lora-row');
if (rows.length === 0) {
masterToggle.className = 'tri-state-toggle all-off';
return;
}
let activeCount = 0;
rows.forEach(r => {
if (r.querySelector('.toggle-switch').classList.contains('active')) {
activeCount++;
}
});
if (activeCount === 0) {
masterToggle.className = 'tri-state-toggle all-off';
} else if (activeCount === rows.length) {
masterToggle.className = 'tri-state-toggle all-on';
} else {
masterToggle.className = 'tri-state-toggle partial';
}
};
const updateHiddenInput = () => {
const loras = [];
const rows = rowsContainer.querySelectorAll('.lora-row');
rows.forEach(row => {
loras.push({
lora_name: row.querySelector('select').value,
strength: parseFloat(row.querySelector('input[type="number"]').value),
enabled: row.querySelector('.toggle-switch').classList.contains('active')
});
});
hiddenInput.value = JSON.stringify(loras);
if (window.comfyApp && sourceNodeId) {
const node = window.comfyApp.graph.getNodeById(sourceNodeId);
if (node) {
const widget = node.widgets.find(w => w.name === key);
if (widget) {
widget.value = hiddenInput.value;
if (widget.callback) widget.callback(hiddenInput.value);
}
}
}
updateMasterSwitchState();
};
const formatStrength = (raw) => {
let val = parseFloat(raw);
if (isNaN(val)) val = 0.0;
if (val < 0.0) val = 0.0;
if (val > 100.0) val = 100.0;
return val.toFixed(2);
};
const addSingleRow = (rowData = { lora_name: "None", strength: 1.0, enabled: true }) => {
const row = document.createElement('div');
row.className = 'lora-row';
const btnDel = document.createElement('button');
btnDel.type = 'button';
btnDel.title = t('remove', 'Remove');
btnDel.className = 'btn-del-lora';
btnDel.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>';
btnDel.onclick = () => {
row.remove();
updateHiddenInput();
};
const sel = document.createElement('select');
// 🔒 确保即使当前保存的 LoRA 名称不在远程列表中,也能正确加入并显示
const availableOptions = [...loraValues];
if (rowData.lora_name && !availableOptions.includes(rowData.lora_name)) {
availableOptions.push(rowData.lora_name);
}
availableOptions.forEach(v => {
const opt = document.createElement('option');
opt.value = v; opt.innerText = v;
sel.appendChild(opt);
});
sel.value = rowData.lora_name || "None";
sel.onchange = updateHiddenInput;
const strInput = document.createElement('input');
strInput.type = 'number';
strInput.className = 'number-input';
strInput.min = '0.00';
strInput.max = '100.00';
strInput.step = '0.01';
strInput.value = formatStrength(rowData.strength ?? 1.0);
strInput.onchange = () => {
strInput.value = formatStrength(strInput.value);
updateHiddenInput();
};
const toggle = document.createElement('div');
toggle.className = 'toggle-switch' + (rowData.enabled ? ' active' : '');
toggle.onclick = () => {
toggle.classList.toggle('active');
updateHiddenInput();
};
row.appendChild(btnDel);
row.appendChild(sel);
row.appendChild(strInput);
row.appendChild(toggle);
rowsContainer.appendChild(row);
};
masterToggle.onclick = () => {
const rows = rowsContainer.querySelectorAll('.lora-row');
if (rows.length === 0) return;
const isAllOn = masterToggle.classList.contains('all-on');
const targetState = !isAllOn;
rows.forEach(row => {
const toggle = row.querySelector('.toggle-switch');
if (targetState) {
toggle.classList.add('active');
} else {
toggle.classList.remove('active');
}
});
updateHiddenInput();
};
btnAdd.onclick = () => {
addSingleRow({ lora_name: "None", strength: 1.0, enabled: true });
updateHiddenInput();
};
// 💡【核心解析】:解析当前的 LoRA 字符串数据并渲染行
let initData = [];
try {
if (typeof currentValue === 'string') initData = JSON.parse(currentValue || "[]");
else if (Array.isArray(currentValue)) initData = currentValue;
} catch(e){}
rowsContainer.innerHTML = '';
if (Array.isArray(initData) && initData.length > 0) {
initData.forEach(item => addSingleRow(item));
}
updateHiddenInput();
group.appendChild(container);
} else if (type === "COMBO") {
const select = document.createElement('select');
select.id = `input_${key}`;
let values = params.values ? [...params.values] : [];
if (params.folder) {
try {
const response = await fetch(`/appbuilder/ls/${params.folder}`);
if (response.ok) {
const data = await response.json();
values = ["None", ...data];
}
} catch (e) { console.error("Fetch models failed:", e); }
}
if (values.length === 0) values = ["None"];
let targetValue = currentValue;
if (targetValue === undefined || targetValue === null || targetValue === "") {
targetValue = params.default;
}
if (targetValue === undefined || targetValue === null || targetValue === "") {
if (params.folder) {
targetValue = "None";
} else {
targetValue = values[0];
}
}
if (!values.includes(targetValue)) values.unshift(targetValue);
select.innerHTML = '';
values.forEach(val => {
const opt = document.createElement('option');
opt.value = val;
opt.innerText = val;
select.appendChild(opt);
});
select.value = targetValue;
group.appendChild(select);
} else if (type === "BOOLEAN" || type === "BYPASSER") {
const toggleContainer = document.createElement('div');
toggleContainer.className = 'segmented-toggle';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = `input_${key}`;
input.checked = currentValue ?? true;
input.style.display = 'none';
const track = document.createElement('div');
track.className = 'toggle-track';
const labelFalse = document.createElement('div');
labelFalse.className = 'toggle-label-half';
labelFalse.setAttribute('data-val', 'false');
labelFalse.innerText = (type === "BYPASSER") ? t('label_off', 'OFF') : t('label_false', 'False');
const labelTrue = document.createElement('div');
labelTrue.className = 'toggle-label-half';
labelTrue.setAttribute('data-val', 'true');
labelTrue.innerText = (type === "BYPASSER") ? t('label_on', 'ON') : t('label_true', 'True');
const thumb = document.createElement('div');
thumb.className = 'toggle-thumb';
track.appendChild(labelFalse); track.appendChild(labelTrue); track.appendChild(thumb);
toggleContainer.appendChild(input); toggleContainer.appendChild(track);
input.addEventListener('change', () => {
if (window.comfyApp && sourceNodeId) {
const node = window.comfyApp.graph.getNodeById(sourceNodeId);
if (node) {
const widget = node.widgets.find(w => w.name === key);
if (widget) { widget.value = input.checked; if (widget.callback) widget.callback(input.checked); }
}
}
});
toggleContainer.addEventListener('click', () => {
input.checked = !input.checked;
input.dispatchEvent(new Event('change'));
});
let startX = 0;
toggleContainer.addEventListener('touchstart', (e) => { startX = e.touches[0].clientX; }, {passive: true});
toggleContainer.addEventListener('touchend', (e) => {
let endX = e.changedTouches[0].clientX;
let diffX = endX - startX;
if (Math.abs(diffX) > 40) {
if (diffX > 0 && !input.checked) { input.checked = true; input.dispatchEvent(new Event('change')); }
else if (diffX < 0 && input.checked) { input.checked = false; input.dispatchEvent(new Event('change')); }
}
}, {passive: true});
group.appendChild(toggleContainer);
} else if (type === "UPLOADER") {
const uploaderContainer = document.createElement('div');
uploaderContainer.className = 'uploader-wrapper';
const valueInput = document.createElement('input');
valueInput.type = 'hidden';
valueInput.id = `input_${key}`;
valueInput.value = currentValue || '';
uploaderContainer.appendChild(valueInput);
const controlRow = document.createElement('div');
controlRow.className = 'uploader-control-row';
const dropzone = document.createElement('div');
dropzone.className = 'uploader-dropzone';
const text = document.createElement('span');
text.className = 'uploader-text';
const mediaType = params.media || 'image';
text.innerText = t("label_uploader", "Drop {0} here or click to browse",t(mediaType, mediaType));
const filenameLabel = document.createElement('div');
filenameLabel.className = 'uploader-filename';
filenameLabel.innerText = currentValue ? currentValue : '';
dropzone.appendChild(text); dropzone.appendChild(filenameLabel);
const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.style.display = 'none';
if (mediaType === 'image') fileInput.accept = 'image/*';
else if (mediaType === 'video') fileInput.accept = 'video/*';
else if (mediaType === 'audio') fileInput.accept = 'audio/*';
else fileInput.accept = 'image/*,video/*,audio/*';
// 按钮组容器
const btnGroup = document.createElement('div');
btnGroup.className = 'uploader-btn-group';
// 1. 📋 粘贴按钮
const btnPaste = document.createElement('button');
btnPaste.type = 'button';
btnPaste.className = 'uploader-btn btn-paste';
btnPaste.innerText = '📋';
btnPaste.title = t('btn_paste_clipboard', 'Paste from clipboard');
// 2. 👁️ 预览开/关按钮
const previewToggle = document.createElement('button');
previewToggle.type = 'button';
previewToggle.className = 'uploader-btn uploader-preview-toggle';
previewToggle.innerText = '👁️';
previewToggle.style.display = currentValue ? 'flex' : 'none';
btnGroup.appendChild(btnPaste);
btnGroup.appendChild(previewToggle);
controlRow.appendChild(dropzone);
controlRow.appendChild(btnGroup);
uploaderContainer.appendChild(fileInput);
uploaderContainer.appendChild(controlRow);
// 点击粘贴按钮
btnPaste.onclick = (e) => {
e.stopPropagation();
pasteClipboardMedia((file) => handleFileUpload(file));
};
// 支持直接聚焦拖拽框后按 Ctrl+V 粘贴
dropzone.tabIndex = 0;
dropzone.addEventListener('paste', (e) => {
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
if (items) {
for (const item of items) {
if (item.type.indexOf('image') !== -1 || item.type.indexOf('video') !== -1 || item.type.indexOf('audio') !== -1) {
const file = item.getAsFile();
if (file) {
e.preventDefault();
handleFileUpload(file);
return;
}
}
}
}
});
const previewBox = document.createElement('div');
previewBox.className = 'uploader-preview-box collapsed';
uploaderContainer.appendChild(previewBox);
dropzone.onclick = () => { fileInput.click(); };
dropzone.ondragover = (e) => { e.preventDefault(); dropzone.classList.add('dragover'); };
dropzone.ondragenter = (e) => { e.preventDefault(); dropzone.classList.add('dragover'); };
dropzone.ondragleave = () => { dropzone.classList.remove('dragover'); };
dropzone.ondrop = (e) => {
e.preventDefault(); dropzone.classList.remove('dragover');
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { handleFileUpload(e.dataTransfer.files[0]); }
};
fileInput.onchange = () => {
if (fileInput.files && fileInput.files.length > 0) { handleFileUpload(fileInput.files[0]); }
};
valueInput.addEventListener('change', () => {
if (window.comfyApp && sourceNodeId) {
const node = window.comfyApp.graph.getNodeById(sourceNodeId);
if (node) {
const widget = node.widgets.find(w => w.name === key);
if (widget) { widget.value = valueInput.value; if (widget.callback) widget.callback(valueInput.value); }
}
}
});
async function handleFileUpload(file) {
const mime = file.type || "";
const ext = file.name.split('.').pop().toLowerCase();
const imageExts = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'tif', 'tiff', 'jfif', 'jpe', 'tga'];
const videoExts = ['mp4', 'webm', 'mkv', 'mov', 'avi', 'gif', 'flv', '3gp'];
const audioExts = ['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'wma'];
const isImage = mime.startsWith('image/') || imageExts.includes(ext);
const isVideo = mime.startsWith('video/') || videoExts.includes(ext);
const isAudio = mime.startsWith('audio/') || audioExts.includes(ext);
let isValid = false;
if (mediaType === 'image') { isValid = isImage; }
else if (mediaType === 'video') { isValid = isVideo; }
else if (mediaType === 'audio') { isValid = isAudio; }
else { isValid = isImage || isVideo || isAudio; }
if (!isValid) {
text.innerText = t("label_uploader", "Drop {0} here or click to browse",t(mediaType, mediaType));
return;
}
text.innerText = t('label_uploading', 'Uploading...');
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/upload/image', { method: 'POST', body: formData });
if (response.ok) {
const result = await response.json();
valueInput.value = result.name; filenameLabel.innerText = result.name;
text.innerText = t("label_uploader", "Drop {0} here or click to browse",t(mediaType, mediaType));
valueInput._cachedMaskCanvas = null; // 👈 修复问题 3:重置 Mask 画板缓存
valueInput._originalUnmaskedFilename = result.name; // 👈 修复问题 2:记录最原始未被打孔图片
valueInput.dispatchEvent(new Event('change'));
updateMediaPreview(result.name);
} else { text.innerText = t('label_upfailed', 'Upload failed!'); }
} catch (err) { console.error(err); text.innerText = t('label_upfailed', 'Upload failed!'); }
}
function updateMediaPreview(filename) {
previewBox.innerHTML = '';
previewToggle.style.display = 'flex';
if (!filename || filename === 'None' || filename === 'undefined' || filename === '') {
const placeholderImg = document.createElement('img');
placeholderImg.src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'><rect width='100%' height='100%' fill='%23555'/><text x='50%' y='50%' fill='%23999' font-size='16' text-anchor='middle' alignment-baseline='middle'>No Media</text></svg>";
previewBox.appendChild(placeholderImg);
return;
}
previewBox.classList.remove('collapsed');
const fileUrl = `/api/view?filename=${encodeURIComponent(filename)}&type=input`;
let mediaElement;
if (mediaType === 'image') {
mediaElement = document.createElement('img'); mediaElement.src = fileUrl;
const maskBtn = document.createElement('button');
maskBtn.type = 'button';
maskBtn.className = 'uploader-mask-btn';
maskBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil-sparkles-icon lucide-pencil-sparkles"><path d="M10 3H8"/><path d="m15.007 5.008 3.987 3.986"/><path d="M20 15v4"/><path d="M21.174 6.813a2.82 2.82 0 0 0-3.986-3.987L3.842 16.175a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="M22 17h-4"/><path d="M4 5v4"/><path d="M6 7H2"/><path d="M9 2v2"/></svg>';
maskBtn.title = t('edit_mask', 'Edit Mask');
maskBtn.onclick = (e) => {
e.stopPropagation();
openMaskEditor(valueInput.value, valueInput, previewBox);
};
previewBox.appendChild(maskBtn);
} else if (mediaType === 'video') {
mediaElement = document.createElement('video'); mediaElement.src = fileUrl; mediaElement.controls = true;
} else if (mediaType === 'audio') {
mediaElement = document.createElement('audio'); mediaElement.src = fileUrl; mediaElement.controls = true;
} else {
const ext = filename.split('.').pop().toLowerCase();
if (['mp4', 'webm', 'ogg', 'mov'].includes(ext)) {
mediaElement = document.createElement('video'); mediaElement.src = fileUrl; mediaElement.controls = true;
} else if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) {
mediaElement = document.createElement('audio'); mediaElement.src = fileUrl; mediaElement.controls = true;
} else {
mediaElement = document.createElement('img'); mediaElement.src = fileUrl;
}
}
previewBox.appendChild(mediaElement);
}
previewToggle.onclick = () => { previewBox.classList.toggle('collapsed'); };
updateMediaPreview(currentValue || 'None');
group.appendChild(uploaderContainer);
} else if (type === "SEED") {
const parentNode = window.comfyApp ? window.comfyApp.graph.getNodeById(sourceNodeId) : null;
const parentWidget = parentNode ? parentNode.widgets.find(w => w.name === key) : null;
const seedWidgetIndex = parentNode ? parentNode.widgets.indexOf(parentWidget) : -1;
const companionWidget = (seedWidgetIndex !== -1 && parentNode.widgets[seedWidgetIndex + 1]?.name === "control_after_generate")
? parentNode.widgets[seedWidgetIndex + 1]
: null;
const realTimeSeed = parentWidget ? parentWidget.value : (currentValue ?? 0);
const { wrapper: seedContainer, input } = createNumericInput({
type: "INT",
value: realTimeSeed, min: 0, max: 18446744073709551615, step: 1,
precision: 0,
id: `input_${key}`
});
input.className = `${input.className} seed-input`;
const utilGroup = document.createElement('div');
utilGroup.className = 'seed-utility-group';
const gap = document.createElement('div'); gap.style.width = '6px';
const modeActions = ["fixed", "increment", "decrement", "randomize"];
const modeIcons = { "fixed": "⏺️", "increment": "⤴️", "decrement": "⤵️", "randomize": "🔀" };
const btnMode = document.createElement('button');
btnMode.type = 'button'; btnMode.className = 'btn-seed-util-item'; btnMode.id = `input_action_btn_${key}`;
// 💡【核心修复 1】:解耦画布,直接用 dataset.mode 保存内部状态,保证多模式完美循环
const initMode = companionWidget ? (companionWidget.value || "randomize") : (params.seed_mode || "randomize");
btnMode.dataset.mode = initMode;
btnMode.innerText = modeIcons[initMode] || "🔀";
btnMode.title = t("random_mode", "Seed Control: ") + t(`label_${initMode}`, initMode);
btnMode.onclick = (e) => {
e.stopPropagation();
let currentMode = btnMode.dataset.mode || "randomize";
let currentIdx = modeActions.indexOf(currentMode);
if (currentIdx === -1) currentIdx = 3;
const nextIdx = (currentIdx + 1) % modeActions.length;
const nextMode = modeActions[nextIdx];
btnMode.dataset.mode = nextMode;
btnMode.innerText = modeIcons[nextMode];
btnMode.title = t("random_mode", "Seed Control: ") + t(`label_${nextMode}`, nextMode);
if (companionWidget) {
companionWidget.value = nextMode;
if (companionWidget.callback) companionWidget.callback(nextMode);
}
};
const btnReuse = document.createElement('button');
btnReuse.type = 'button'; btnReuse.className = 'btn-seed-util-item'; btnReuse.innerText = '♻️';
btnReuse.title = t("lastSeed_desc", 'Reuse Last Seed');
btnReuse.onclick = (e) => {
e.stopPropagation();
const lastVal = window._lastSeeds ? window._lastSeeds[key] : null;
if (lastVal !== null && lastVal !== undefined) {
input.value = lastVal;
input.dispatchEvent(new Event('change'));
btnMode.dataset.mode = "fixed";
btnMode.innerText = "⏺️";
btnMode.title = t("random_mode", "Seed Control: ") + t(`label_fixed`, "fixed");
if (companionWidget) {
companionWidget.value = "fixed";
if (companionWidget.callback) companionWidget.callback("fixed");
}
}
};
const btnRand = document.createElement('button');
btnRand.type = 'button'; btnRand.className = 'btn-seed-util-item'; btnRand.innerText = '🎲';
btnRand.title = t("randomSeed_desc", 'Randomize Seed');
btnRand.onclick = (e) => {
e.stopPropagation();
const randVal = Math.floor(Math.random() * 1125899906842624).toString();
input.value = randVal; input.dispatchEvent(new Event('change'));
btnMode.dataset.mode = "fixed";
btnMode.innerText = "⏺️";
btnMode.title = t("random_mode", "Seed Control: ") + t(`label_fixed`, "fixed");
if (companionWidget) {
companionWidget.value = "fixed";
if (companionWidget.callback) companionWidget.callback("fixed");
}
};
utilGroup.appendChild(btnMode);
//utilGroup.appendChild(btnReuse);
utilGroup.appendChild(btnRand);
seedContainer.appendChild(gap);
seedContainer.appendChild(utilGroup);
group.appendChild(seedContainer);
} else {
const isMultiline = params.multiline === true;
if (isMultiline) {
const container = document.createElement('div');
container.className = 'input-with-button';
const input = document.createElement('textarea');
input.className = 'tight-textarea'; input.style.height = '57px';
input.value = currentValue ?? ''; input.id = `input_${key}`; input.rows = 1; input.placeholder = params.placeholder || "";
container.appendChild(input);
const editBtn = document.createElement('button');
editBtn.className = 'btn-edit-multiline'; editBtn.type = 'button'; editBtn.title = t('btn_edit', "Multiline Editor");
editBtn.innerText = '📝'; editBtn.onclick = () => openMultilineEditor(input, params.name || key);
container.appendChild(editBtn);
group.appendChild(container);
} else {
const input = document.createElement('input');
input.type = 'text'; input.value = currentValue ?? ''; input.id = `input_${key}`;
if (params.placeholder) input.placeholder = params.placeholder;
group.appendChild(input);
}
}
formContainer.appendChild(group);
}
}
function openMultilineEditor(targetInput, title) {
activeInputEl = targetInput;
document.getElementById('modal-title').innerText = t("label_editing", "📝 Editing: [{0}]", title);
const modalTextarea = document.getElementById('multiline-textarea');
modalTextarea.value = targetInput.value;
if (targetInput.placeholder) { modalTextarea.placeholder = targetInput.placeholder; }
else { modalTextarea.placeholder = ''; }
document.getElementById('multiline-modal').style.display = 'flex';
}
document.getElementById('multiline-modal-close').onclick = closeMultilineModal;
document.getElementById('multiline-btn-cancel').onclick = closeMultilineModal;
document.getElementById('multiline-btn-save').onclick = () => {
if (activeInputEl) {
activeInputEl.value = document.getElementById('multiline-textarea').value;
activeInputEl.dispatchEvent(new Event('change'));
}
closeMultilineModal();
};
function closeMultilineModal() {
document.getElementById('multiline-modal').style.display = 'none';
activeInputEl = null;
}
// 🔍 规范化 URL (剥离 #t=0.001 等哈希标记用于匹配)
const normalizeLightboxUrl = (u) => {
if (!u) return '';
try {
return new URL(u, window.location.href).href.split('#')[0];
} catch(e) {
return u.split('#')[0];
}
};
function openLightbox(src) {
if (src && !src.startsWith('data:image/svg+xml')) {
const items = Array.from(document.querySelectorAll('.gallery-item'));
currentImagesList = items.map(el => normalizeLightboxUrl(el.src || el.currentSrc)).filter(Boolean);
const absoluteSrc = new URL(src, window.location.href).href;
currentLightboxIndex = currentImagesList.indexOf(absoluteSrc);
if (currentLightboxIndex === -1) { currentImagesList = [absoluteSrc]; currentLightboxIndex = 0; }
updateLightboxUI();
document.getElementById('lightbox-modal').style.display = 'flex';
}
}
function navigateLightbox(dir) {
lbLastTapTime = 0; // 👈 每次切图,立刻清空双击计时器
if (currentImagesList.length <= 1) return;
const nextIndex = currentLightboxIndex + dir;
if (nextIndex < 0 || nextIndex >= currentImagesList.length) { return; }
currentLightboxIndex = nextIndex;
updateLightboxUI();
}
// 🖼️ ====== 灯箱手势与缩放全功能引擎 ======
let lbScale = 1;
let lbTx = 0;
let lbTy = 0;
let lbIsDragging = false;
let lbDragStartX = 0;
let lbDragStartY = 0;
let lbStartTx = 0;
let lbStartTy = 0;
let lbStartPinchDist = 0;
let lbStartPinchScale = 1;
let lbLastTapTime = 0;
// 重置缩放状态
function resetLightboxZoom(smooth = false) {
const img = document.getElementById('lightbox-img');
if (!img) return;
lbScale = 1;
lbTx = 0;
lbTy = 0;
lbIsDragging = false;
if (smooth) {
img.classList.add('smooth-transform');
setTimeout(() => img.classList.remove('smooth-transform'), 250);
} else {
img.classList.remove('smooth-transform');
}
updateLightboxTransform();
}
// 刷新图片 Transform 位置与边界约束
function updateLightboxTransform() {
const img = document.getElementById('lightbox-img');
if (!img) return;
// 只有放大后才允许平移,并限制拖拽不能超出屏幕边缘
if (lbScale > 1) {
img.style.webkitUserDrag = 'none'; // 👈 放大状态下:禁掉原生拖出,改为平移大图视角
const imgWidth = img.offsetWidth * lbScale;
const imgHeight = img.offsetHeight * lbScale;
const maxTx = Math.max(0, (imgWidth - window.innerWidth * 0.95) / 2 + 30);
const maxTy = Math.max(0, (imgHeight - window.innerHeight * 0.90) / 2 + 30);
lbTx = Math.min(maxTx, Math.max(-maxTx, lbTx));
lbTy = Math.min(maxTy, Math.max(-maxTy, lbTy));
} else {
img.style.webkitUserDrag = 'auto'; // 👈 1.0x 原始状态下:允许鼠标直接把图片拖到桌面/PS中
lbTx = 0;
lbTy = 0;
}
img.style.transform = `translate(${lbTx}px, ${lbTy}px) scale(${lbScale})`;
img.style.cursor = lbScale > 1 ? (lbIsDragging ? 'grabbing' : 'grab') : 'default';
}
// 双击/双触 缩放切换 (1.0x <-> 2.5x)
function toggleDoubleTapZoom() {
if (lbScale > 1) {
resetLightboxZoom(true);
} else {
lbScale = 2.5;
lbTx = 0;
lbTy = 0;
const img = document.getElementById('lightbox-img');
if (img) {
img.classList.add('smooth-transform');
setTimeout(() => img.classList.remove('smooth-transform'), 250);
}
updateLightboxTransform();
}
}
// 计算双指距离
function getPinchDist(e) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function closeLightbox() {
const lightboxModal = document.getElementById('lightbox-modal');
if (lightboxModal) lightboxModal.style.display = 'none';
// 🎬 关闭灯箱时停止视频播放
const lbVid = document.getElementById('lightbox-video');
if (lbVid) {
lbVid.pause();
lbVid.src = '';
}
resetLightboxZoom(false);
}
function updateLightboxUI() {
const imgUrl = currentImagesList[currentLightboxIndex];
resetLightboxZoom(false); // 切换时自动复原缩放
const lbImg = document.getElementById('lightbox-img');
let lbVid = document.getElementById('lightbox-video');
// 如果灯箱里还没有 <video> 标签,动态创建一个带控制条的播放器
if (!lbVid) {
lbVid = document.createElement('video');
lbVid.id = 'lightbox-video';
lbVid.className = 'lightbox-content';
lbVid.autoplay = true;
lbVid.loop = true;
lbVid.controls = true; // 👈 灯箱大图提供播放/暂停/进度条/音量控制
lbVid.playsInline = true;
lbVid.style.cssText = 'max-width: 95dvw; max-height: 90dvh; object-fit: contain; display: none; z-index: 11100;';
const modal = document.getElementById('lightbox-modal');
const nextBtn = document.getElementById('lightbox-next');
modal.insertBefore(lbVid, nextBtn);
}
const isVideo = isVideoUrl(imgUrl);
if (isVideo) {
if (lbImg) lbImg.style.display = 'none';
lbVid.src = imgUrl;
lbVid.muted = true; // 👈 默认静音
lbVid.defaultMuted = true;
lbVid.style.display = 'block';
lbVid.play().catch(()=>{});
} else {
if (lbVid) {
lbVid.style.display = 'none';
lbVid.pause();
}
if (lbImg) {
lbImg.src = imgUrl;
lbImg.style.display = 'block';
}
}
const prevBtn = document.getElementById('lightbox-prev');
const nextBtn = document.getElementById('lightbox-next');
if (prevBtn && nextBtn) {
prevBtn.style.display = (currentLightboxIndex === 0) ? 'none' : 'flex';
nextBtn.style.display = (currentLightboxIndex === currentImagesList.length - 1) ? 'none' : 'flex';
}
const lbDl = document.getElementById('lightbox-download');
if (lbDl) {
lbDl.href = imgUrl;
const fn = new URL(imgUrl, window.location.href).searchParams.get('filename') || 'media';
lbDl.download = fn;
}
}
function initLightboxEvents() {
const lightboxModal = document.getElementById('lightbox-modal');
const img = document.getElementById('lightbox-img');
if (!lightboxModal || !img) return;
document.getElementById('lightbox-prev').onclick = (e) => { e.stopPropagation(); navigateLightbox(-1); };
document.getElementById('lightbox-next').onclick = (e) => { e.stopPropagation(); navigateLightbox(1); };
document.getElementById('lightbox-close').onclick = (e) => { e.stopPropagation(); closeLightbox(); };
window.addEventListener('keydown', (e) => {
if (lightboxModal.style.display !== 'flex') return;
// 忽略在输入框/文本域打字时的键盘事件
const activeTag = document.activeElement ? document.activeElement.tagName.toLowerCase() : '';
if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
navigateLightbox(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
navigateLightbox(1);
} else if (e.key === 'Escape') {
e.preventDefault();
closeLightbox();
}
});
// 1. 💻 电脑端:鼠标滚轮缩放
lightboxModal.addEventListener('wheel', (e) => {
if (lightboxModal.style.display !== 'flex') return;
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85;
let newScale = lbScale * zoomFactor;
if (newScale < 1) newScale = 1;
if (newScale > 4) newScale = 4;
lbScale = newScale;
updateLightboxTransform();
}, { passive: false });
// 2. 💻 电脑端:鼠标双击放大 / 鼠标拖拽查看
img.addEventListener('dblclick', (e) => {
e.preventDefault();
toggleDoubleTapZoom();
});
img.addEventListener('mousedown', (e) => {
if (lbScale > 1) {
e.preventDefault();
lbIsDragging = true;
lbDragStartX = e.clientX;
lbDragStartY = e.clientY;
lbStartTx = lbTx;
lbStartTy = lbTy;
updateLightboxTransform();
}
});
window.addEventListener('mousemove', (e) => {
if (lbIsDragging && lbScale > 1) {
e.preventDefault();
lbTx = lbStartTx + (e.clientX - lbDragStartX);
lbTy = lbStartTy + (e.clientY - lbDragStartY);
updateLightboxTransform();
}
});
window.addEventListener('mouseup', () => {
if (lbIsDragging) {
lbIsDragging = false;
updateLightboxTransform();
}
});
// 3. 📱 移动端:双指捏合缩放 + 单指拖拽查看 + 双触快速放大 + 1.0x 左右划动翻页
let swipeStartY = 0; // 记录 Y 轴起点
lightboxModal.addEventListener('touchstart', (e) => {
if (lightboxModal.style.display !== 'flex') return;
const isImg = (e.target.id === 'lightbox-img');
// 双触判定 (Double Tap) - 仅限图片本体
const now = Date.now();
if (isImg && e.touches.length === 1 && now - lbLastTapTime < 300) {
e.preventDefault();
toggleDoubleTapZoom();
lbLastTapTime = 0;
return;
}
if (isImg && e.touches.length === 1) lbLastTapTime = now;
if (isImg && e.touches.length === 2) {
lbLastTapTime = 0; // 双指捏合时瞬间作废双击
e.preventDefault();
lbStartPinchDist = getPinchDist(e);
lbStartPinchScale = lbScale;
} else if (e.touches.length === 1) {
swipeStartX = e.touches[0].clientX;
swipeStartY = e.touches[0].clientY; // 记录 Y 轴起点
if (isImg && lbScale > 1) {
lbIsDragging = true;
lbDragStartX = e.touches[0].clientX;
lbDragStartY = e.touches[0].clientY;
lbStartTx = lbTx;
lbStartTy = lbTy;
}
}
}, { passive: false });
lightboxModal.addEventListener('touchmove', (e) => {
if (lightboxModal.style.display !== 'flex') return;
// 🌟 核心修复:只要手指发生明显划动 (>8px),立刻作废双击计时器!
if (e.touches.length === 1) {
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const diffX = Math.abs(currentX - swipeStartX);
const diffY = Math.abs(currentY - swipeStartY);
if (diffX > 8 || diffY > 8) lbLastTapTime = 0; // 👈 手指动了,说明是划动切图/拖拽,绝对不是双击!
}
if (e.touches.length === 2) {
lbLastTapTime = 0;
e.preventDefault();
const dist = getPinchDist(e);
if (lbStartPinchDist > 0) {
let newScale = (dist / lbStartPinchDist) * lbStartPinchScale;
if (newScale < 1) newScale = 1;
if (newScale > 4) newScale = 4;
lbScale = newScale;
updateLightboxTransform();
}
} else if (e.touches.length === 1 && lbIsDragging && lbScale > 1) {
e.preventDefault();
const dx = e.touches[0].clientX - lbDragStartX;
const dy = e.touches[0].clientY - lbDragStartY;
lbTx = lbStartTx + dx;
lbTy = lbStartTy + dy;
updateLightboxTransform();
}
}, { passive: false });
lightboxModal.addEventListener('touchend', (e) => {
if (lightboxModal.style.display !== 'flex') return;
if (e.touches.length < 2) {
lbStartPinchDist = 0;
}
if (e.touches.length === 0) {
if (lbIsDragging) {
lbIsDragging = false;
updateLightboxTransform();
} else if (lbScale === 1) {
// 未放大状态下:单指划动翻页 (上一张/下一张)
const endX = e.changedTouches[0].clientX;
const diffX = endX - swipeStartX;
if (Math.abs(diffX) > 50) {
if (diffX > 0) navigateLightbox(-1);
else navigateLightbox(1);
}
}
}
});
// 点击背景黑色遮罩关闭灯箱
lightboxModal.onclick = (e) => {
if (e.target.id === 'lightbox-modal' || e.target.id === 'lightbox-close') {
closeLightbox();
}
};
}
function applyMobileLayout(targetLogHeight) {
const isDesktop = window.innerWidth >= 768;
if (isDesktop) return;
const logContainer = document.getElementById('log-drawer-container');
const viewerContainer = document.querySelector('.viewer-container');
if (!logContainer || !viewerContainer) return;
const totalHeight = window.innerHeight;
const minRightPanelHeight = 149;
const minViewerHeight = 145;
let preferredPreviewHeight = parseInt(localStorage.getItem('appview_preview_height'));
if (isNaN(preferredPreviewHeight)) { preferredPreviewHeight = totalHeight * 0.3; }
const maxLogHeight = totalHeight - minViewerHeight - minRightPanelHeight;
let finalLogHeight = targetLogHeight;
if (finalLogHeight > maxLogHeight) finalLogHeight = maxLogHeight;
if (finalLogHeight < 0) finalLogHeight = 0;
let expectedRightPanelHeight = totalHeight - preferredPreviewHeight;
let actualViewerHeight = totalHeight - finalLogHeight - expectedRightPanelHeight;
if (actualViewerHeight < minViewerHeight) { actualViewerHeight = minViewerHeight; }
viewerContainer.style.flex = `0 0 ${actualViewerHeight}px`;
logContainer.style.height = `${finalLogHeight}px`;
if (finalLogHeight > 0) {
logHeight = finalLogHeight;
localStorage.setItem('appview_log_height', finalLogHeight);
}
}
function initLogResizeLogic() {
const logResizer = document.getElementById('log-resizer');
const logContainer = document.getElementById('log-drawer-container');
const mainLayout = document.querySelector('.main-layout');
const logContent = document.getElementById('log-content');
const rightPanel = document.querySelector('.right-panel');
let isDraggingLog = false; let dragVisibleBottomY = 0;
if (!logResizer) return;
logResizer.addEventListener('mousedown', startLogDrag); logResizer.addEventListener('touchstart', startLogDrag, { passive: false });
function startLogDrag(e) {
e.preventDefault(); isDraggingLog = true; logContainer.classList.add('dragging');
mainLayout.style.pointerEvents = 'none'; document.body.style.cursor = 'row-resize';
if (logContent) { dragVisibleBottomY = logContent.scrollTop + logContent.clientHeight; }
document.addEventListener('mousemove', logDrag); document.addEventListener('touchmove', logDrag, { passive: false });
document.addEventListener('mouseup', stopLogDrag); document.addEventListener('touchend', stopLogDrag);
}
function logDrag(e) {
if (!isDraggingLog) return;
if (e.cancelable) e.preventDefault();
const clientY = (e.touches && e.touches.length > 0) ? e.touches[0].clientY : e.clientY;
const totalHeight = window.innerHeight;
let newHeight = totalHeight - clientY;
if (newHeight < 100) newHeight = 100;
const isDesktop = window.innerWidth >= 768;
const safetyMargin = isDesktop ? 220 : 295;
if (newHeight > totalHeight - safetyMargin) { newHeight = totalHeight - safetyMargin; }
if (isDesktop) {
logContainer.style.height = `${newHeight}px`; logHeight = newHeight; localStorage.setItem('appview_log_height', newHeight);
} else { applyMobileLayout(newHeight); }
if (logContent) {
let targetScrollTop = dragVisibleBottomY - logContent.clientHeight;
const maxScroll = logContent.scrollHeight - logContent.clientHeight;
if (targetScrollTop < 0) targetScrollTop = 0; if (targetScrollTop > maxScroll) targetScrollTop = maxScroll;
logContent.scrollTop = targetScrollTop;
}
}
function stopLogDrag() {
isDraggingLog = false; logContainer.classList.remove('dragging'); mainLayout.style.pointerEvents = 'auto'; document.body.style.cursor = '';
document.removeEventListener('mousemove', logDrag); document.removeEventListener('touchmove', logDrag);
document.removeEventListener('mouseup', stopLogDrag); document.removeEventListener('touchend', stopLogDrag);
if (logContainer) { logHeight = parseInt(logContainer.style.height) || logHeight; }
}
}
// 📐【自包含完整版 - 双重手势互斥锁+防并发滚动缩放引擎】
function initResizeLogic() {
const resizer = document.getElementById('resizer');
const viewerContainer = document.querySelector('.viewer-container');
const rightPanel = document.querySelector('.right-panel');
const logContainer = document.getElementById('log-drawer-container');
const controlsSection = document.getElementById('dynamic-form'); // 👈 参数滚动容器
let isDragging = false;
let isListScrolling = false;
let scrollDebounceTimer = null;
// 1. 监测参数列表的真实物理滚动
if (controlsSection) {
controlsSection.addEventListener('scroll', () => {
isListScrolling = true;
if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer);
scrollDebounceTimer = setTimeout(() => {
isListScrolling = false;
}, 150);
}, { passive: true });
}
if (!resizer) return;
// ====== 💻 桌面端:鼠标点击即时响应 ======
resizer.addEventListener('mousedown', startMouseDrag);
function startMouseDrag(e) {
e.preventDefault();
startDraggingState();
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
}
// ====== 📱 移动端:触摸状态互斥锁 ======
resizer.addEventListener('touchstart', (e) => {
if (!e.touches || e.touches.length === 0) return;
// 如果列表处于滚动滑动中,不响应拖拽
if (isListScrolling) {
isDragging = false;
return;
}
// 开启拖拽状态,同时冻结列表
startDraggingState();
}, { passive: true });
resizer.addEventListener('touchmove', (e) => {
if (!isDragging) return;
// 确认是在拖拽状态后,拦截原生手势
if (e.cancelable) e.preventDefault();
drag(e);
}, { passive: false });
resizer.addEventListener('touchend', stopDrag);
resizer.addEventListener('touchcancel', stopDrag);
// 🔒 激活拖拽状态(同时强行冻结列表滚动!)
function startDraggingState() {
isDragging = true;
resizer.classList.add('dragging');
viewerContainer.style.pointerEvents = 'none';
rightPanel.style.pointerEvents = 'none';
// 🌟【核心锁死】:在拖拽面板期间,把参数列表的 overflowY 设为 hidden,强行冻结滚动!
if (controlsSection) {
controlsSection.style.overflowY = 'hidden';
}
}
// 拖拽计算核心
function drag(e) {
if (!isDragging) return;
const isDesktop = window.innerWidth >= 768;
const clientX = (e.touches && e.touches.length > 0) ? e.touches[0].clientX : e.clientX;
const clientY = (e.touches && e.touches.length > 0) ? e.touches[0].clientY : e.clientY;
if (isDesktop) {
const totalWidth = window.innerWidth;
let newWidth = totalWidth - clientX;
if (newWidth < 250) newWidth = 250;
if (newWidth > totalWidth - 150) newWidth = totalWidth - 150;
rightPanel.style.flexBasis = `${newWidth}px`;
rightPanel.style.width = `${newWidth}px`;
localStorage.setItem('appview_panel_width', newWidth);
} else {
let newHeight = clientY;
const totalHeight = window.innerHeight;
if (newHeight < 145) newHeight = 145;
const minRightPanelHeight = 149;
if (isLogOpen) {
const availableSpaceForLog = totalHeight - newHeight - minRightPanelHeight;
if (logHeight > availableSpaceForLog) {
let tempLogHeight = availableSpaceForLog;
if (tempLogHeight < 100) {
tempLogHeight = 100;
} else {
if (logContainer) logContainer.style.height = `${tempLogHeight}px`;
logHeight = tempLogHeight;
localStorage.setItem('appview_log_height', logHeight);
}
}
}
const absoluteMaxViewerHeight = totalHeight - (isLogOpen ? logHeight : 0) - minRightPanelHeight;
if (newHeight > absoluteMaxViewerHeight) {
newHeight = absoluteMaxViewerHeight;
}
viewerContainer.style.flex = `0 0 ${newHeight}px`;
const impliedPreferredHeight = newHeight + (isLogOpen ? logHeight : 0);
localStorage.setItem('appview_preview_height', impliedPreferredHeight);
}
}
// 解锁并恢复列表滚动能力
function stopDrag() {
isDragging = false;
resizer.classList.remove('dragging');
viewerContainer.style.pointerEvents = 'auto';
rightPanel.style.pointerEvents = 'auto';
document.body.style.cursor = '';
// 🌟【解锁恢复】:松开手指后,立刻恢复参数列表原有的自然滚动能力
if (controlsSection) {
controlsSection.style.overflowY = 'auto';
}
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
}
}
document.getElementById('btn-run').addEventListener('click', async () => {
// 🌟【新增】:无头独立模式提交逻辑
// 🌟【无头模式提交逻辑】
if (isStandaloneMode && standaloneWorkflowData) {
// 1. 处理 🔀 随机种子按钮
const seedActionBtns = document.querySelectorAll('[id^="input_action_btn_"]');
seedActionBtns.forEach(btn => {
const mode = btn.dataset.mode;
const seedKey = btn.id.replace('input_action_btn_', '');
const seedInput = document.getElementById(`input_${seedKey}`);
if (seedInput) {
if (mode === 'randomize') {
seedInput.value = Math.floor(Math.random() * 1125899906842624).toString();
seedInput.dispatchEvent(new Event('change'));
} else if (mode === 'increment') {
try { seedInput.value = String(BigInt(seedInput.value || 0) + 1n); } catch(e){}
seedInput.dispatchEvent(new Event('change'));
} else if (mode === 'decrement') {
try { seedInput.value = String(BigInt(seedInput.value || 0) - 1n); } catch(e){}
seedInput.dispatchEvent(new Event('change'));
}
}
});
// 2. 核心:调用万能函数抓取当前 DOM 树上所有用户的最新输入值
const formValues = getAppViewFormValues();
console.log("📝 [AppView] 最新收集到的表单参数:", formValues);
window._lastSeeds = window._lastSeeds || {};
Object.keys(formValues).forEach(k => {window._lastSeeds[k] = formValues[k];});
const batchInput = document.getElementById('input-batch-count');
const batchCount = batchInput ? Math.max(1, parseInt(batchInput.value) || 1) : 1;
// 3. UI 状态切换
const statusText = document.getElementById('status-text');
statusText.innerText = t("label_generating", "Generating...");
statusText.style.color = "var(--primary)";
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('btn-run').style.backgroundColor = 'var(--primary)';
document.getElementById('btn-run').innerText = t("button_running", "▷ Running...");
// 4. 转换 Prompt
const apiPrompt = await convertWorkflowToApiPrompt(standaloneWorkflowData, formValues);
console.log("📦 [AppView] 最终提交给后端的 API Prompt:", apiPrompt);
// 5. 提交 Prompt 到 ComfyUI 后端
try {
const res = await fetch('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: apiPrompt,
client_id: standaloneClientId
})
});
if (!res.ok) {
const errJson = await res.json();
alert(t("queue_error", "⚠️ Queue Error\n\n{0}", (errJson.error?.message || res.statusText)));
statusText.innerText = t("label_ready", "Ready");
document.getElementById('btn-run').style.backgroundColor = '#3E8AE2';
document.getElementById('btn-run').innerText = t("button_run", "▷ Run");
}
} catch(e) {
alert(t("queue_error", "⚠️ Queue Error\n\n{0}", e.message));
statusText.innerText = t("label_ready", "Ready");
document.getElementById('btn-run').style.backgroundColor = '#3E8AE2';
document.getElementById('btn-run').innerText = t("button_run", "▷ Run");
}
return;
}
// 下面保持你原有的在画布中提交的 window.comfyApp 逻辑不变...
if (!window.comfyApp || !sourceNodeId) return alert(t("lost_connection","Lost connection to ComfyUI!"));
const node = window.comfyApp.graph.getNodeById(sourceNodeId);
if (!node) return alert(t("node_notfound", "Original node not found!"));
const seedActionBtns = document.querySelectorAll('[id^="input_action_btn_"]');
seedActionBtns.forEach(btn => {
if (btn.innerText === '🔀') {
const seedKey = btn.id.replace('input_action_btn_', '');
const seedInput = document.getElementById(`input_${seedKey}`);
if (seedInput) {
const newSeed = Math.floor(Math.random() * 1125899906842624).toString();
seedInput.value = newSeed; seedInput.dispatchEvent(new Event('change'));
}
}
});
for (const widget of node.widgets) {
const inputEl = document.getElementById(`input_${widget.name}`);
if (inputEl) {
let newValue;
if (inputEl.type === 'checkbox') { newValue = inputEl.checked; }
else if (inputEl.type === 'number' || inputEl.type === 'range') { newValue = Number(inputEl.value); }
else { newValue = inputEl.value; }
if (widget.value !== newValue) { widget.value = newValue; if (widget.callback) widget.callback(newValue); }
if (document.getElementById(`input_action_btn_${widget.name}`)) {
window._lastSeeds = window._lastSeeds || {}; window._lastSeeds[widget.name] = inputEl.value;
}
}
}
const batchInput = document.getElementById('input-batch-count');
const batchCount = batchInput ? Math.max(1, parseInt(batchInput.value) || 1) : 1;
batchInput.value = batchCount;
window.comfyApp.queuePrompt(0, batchCount);
});
async function pasteClipboardMedia(onFileObtained) {
if (navigator.clipboard && navigator.clipboard.read) {
try {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
for (const type of item.types) {
if (type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/')) {
const blob = await item.getType(type);
const ext = type.split('/')[1] || 'png';
const file = new File([blob], `pasted_media_${Date.now()}.${ext}`, { type });
onFileObtained(file);
return;
}
}
}
} catch (err) {
console.warn("Clipboard API Direct read blocked, using prompt fallback:", err);
}
}
showHttpPastePrompt(onFileObtained);
}
function showHttpPastePrompt(onFileObtained) {
const isMobileDevice = isMobile();
let overlay = document.getElementById('http-paste-overlay');
if (overlay) overlay.remove();
overlay = document.createElement('div');
overlay.id = 'http-paste-overlay';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100vw; height: 100dvh;
background: rgba(0,0,0,0.75); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
z-index: 999999; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: var(--text); padding: 20px;
`;
const promptTitle = isMobileDevice ? t("paste_content", "Pasteboard") : t("press_to_paste", "Press Ctrl/Cmd + V to paste");
const promptDesc = isMobileDevice ? t("tap_to_paste", "Tap or touch and hold the box below, then tap【Paste】") : t("shortcut_required","(You must use the keyboard shortcut to paste in a non-secure context)");
overlay.innerHTML = `
<div style="background: var(--panel-bg); border: 1px solid var(--border); padding: 24px 20px; border-radius: 16px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,0.6); width: 100%; max-width: 360px; display: flex; flex-direction: column; align-items: center; gap: 6px;">
<div style="font-size: 36px; line-height: 1;">📋</div>
<div style="font-size: 16px; font-weight: bold; color: var(--text);">${promptTitle}</div>
<div style="font-size: 12px; color: var(--title);">${promptDesc}</div>
<div id="paste-editable-target" contenteditable="true" style="
width: 100%; min-height: 80px; margin-bottom: 8px;
display: ${isMobileDevice ? 'flex' : 'none'};
background: var(--bg); border: 2px dashed var(--primary);
border-radius: 10px; margin-top: 6px; align-items: center;
justify-content: center; padding: 10px; font-size: 13px; color: #888;
outline: none; -webkit-user-select: text; user-select: text;
">
${t("tap_or_hold", "👉 Tap or touch and hold here")}
</div>
<div style="display: ${isMobileDevice ? 'none' : 'block'}; font-size: 12px; font-weight: bold; margin-top: 8px; padding-left: 12px;">${t("waiting_shortcut", "Waiting for key press…")}</div>
<button id="close-paste-overlay" style="padding: 8px 20px; background: var(--border); border: none; color: var(--text); font-weight: bold; border-radius: 6px; cursor: pointer;">${t("cancel", "Cancel")}</button>
</div>
`;
document.body.appendChild(overlay);
const editable = overlay.querySelector('#paste-editable-target');
const closeBtn = overlay.querySelector('#close-paste-overlay');
const cleanup = () => {
if (overlay) overlay.remove();
document.removeEventListener('paste', handlePaste);
};
closeBtn.onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
const handlePaste = (e) => {
e.preventDefault();
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items;
if (items) {
for (const item of items) {
if (item.type.indexOf('image') !== -1 || item.type.indexOf('video') !== -1 || item.type.indexOf('audio') !== -1) {
const file = item.getAsFile();
if (file) {
cleanup();
onFileObtained(file);
return;
}
}
}
}
};
document.addEventListener('paste', handlePaste);
setTimeout(() => {
editable.focus();
if (isMobileDevice) {
const range = document.createRange();
range.selectNodeContents(editable);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}, 100);
}
document.getElementById('btn-stop').addEventListener('click', () => {
fetch('/interrupt', { method: 'POST' }).catch(err => console.error("Interrupt failed:", err));
});
// 🖌️ ====== 遮罩编辑器 (Mask Editor) 核心引擎 ======
let maskState = {
currentValueInput: null,
currentPreviewBox: null,
currentFilename: '',
bgImg: null,
canvas: null,
ctx: null,
cursor: null,
isDrawing: false,
mode: 'draw', // 'draw' 或 'erase'
brushSize: 40,
historyStack: [], // Undo 栈
redoStack: [], // Redo 栈
maxHistory: 20
};
function initMaskModalEvents() {
const modal = document.getElementById('mask-modal');
const closeBtn = document.getElementById('mask-modal-close');
const cancelBtn = document.getElementById('mask-btn-cancel');
const saveBtn = document.getElementById('mask-btn-save');
const undoBtn = document.getElementById('mask-btn-undo');
const redoBtn = document.getElementById('mask-btn-redo');
const drawBtn = document.getElementById('mask-btn-draw');
const eraseBtn = document.getElementById('mask-btn-erase');
const invertBtn = document.getElementById('mask-btn-invert');
const clearBtn = document.getElementById('mask-btn-clear');
const sizeSlider = document.getElementById('mask-brush-size');
const closeModal = () => { modal.style.display = 'none'; };
closeBtn.onclick = closeModal;
cancelBtn.onclick = closeModal;
// 模式切换
drawBtn.onclick = () => {
maskState.mode = 'draw';
drawBtn.classList.add('active');
eraseBtn.classList.remove('active');
};
eraseBtn.onclick = () => {
maskState.mode = 'erase';
eraseBtn.classList.add('active');
drawBtn.classList.remove('active');
};
if (invertBtn) invertBtn.onclick = invertMask;
// 笔刷大小
sizeSlider.oninput = (e) => {
maskState.brushSize = parseInt(e.target.value);
document.getElementById('mask-brush-size-val').innerText = `${maskState.brushSize}px`;
updateCursorSize();
};
// Undo / Redo
undoBtn.onclick = undoMask;
redoBtn.onclick = redoMask;
// 清空
clearBtn.onclick = () => {
saveMaskState();
maskState.ctx.clearRect(0, 0, maskState.canvas.width, maskState.canvas.height);
};
// 保存应用
saveBtn.onclick = applyAndUploadMask;
// 快捷键支持 (Ctrl+Z, Ctrl+Y)
window.addEventListener('keydown', (e) => {
if (modal.style.display !== 'flex') return;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
if (e.shiftKey) redoMask(); else undoMask();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
redoMask();
}
});
}
function updateCursorSize() {
if (!maskState.cursor || !maskState.bgImg) return;
const rect = maskState.bgImg.getBoundingClientRect();
if (!rect.width || rect.width === 0) return; // 👈 避免 DOM 未渲染完成时算成 0
const scale = rect.width / (maskState.bgImg.naturalWidth || 1);
const displaySize = maskState.brushSize * scale;
maskState.cursor.style.width = `${displaySize}px`;
maskState.cursor.style.height = `${displaySize}px`;
}
function saveMaskState() {
if (!maskState.ctx) return;
const snapshot = maskState.ctx.getImageData(0, 0, maskState.canvas.width, maskState.canvas.height);
maskState.historyStack.push(snapshot);
if (maskState.historyStack.length > maskState.maxHistory) maskState.historyStack.shift();
maskState.redoStack = []; // 开启新绘制时清空 Redo
updateUndoRedoButtons();
}
function updateUndoRedoButtons() {
document.getElementById('mask-btn-undo').disabled = maskState.historyStack.length === 0;
document.getElementById('mask-btn-redo').disabled = maskState.redoStack.length === 0;
}
function undoMask() {
if (maskState.historyStack.length === 0) return;
const current = maskState.ctx.getImageData(0, 0, maskState.canvas.width, maskState.canvas.height);
maskState.redoStack.push(current);
const prev = maskState.historyStack.pop();
maskState.ctx.putImageData(prev, 0, 0);
updateUndoRedoButtons();
}
function redoMask() {
if (maskState.redoStack.length === 0) return;
const current = maskState.ctx.getImageData(0, 0, maskState.canvas.width, maskState.canvas.height);
maskState.historyStack.push(current);
const next = maskState.redoStack.pop();
maskState.ctx.putImageData(next, 0, 0);
updateUndoRedoButtons();
}
function invertMask() {
if (!maskState.ctx || !maskState.canvas) return;
saveMaskState();
const nw = maskState.canvas.width;
const nh = maskState.canvas.height;
const imgData = maskState.ctx.getImageData(0, 0, nw, nh);
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 0;
} else {
data[i] = 255; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 255;
}
}
maskState.ctx.putImageData(imgData, 0, 0);
}
function openMaskEditor(filename, valueInputEl, previewBoxEl) {
if (!filename || filename === 'None') return;
maskState.currentValueInput = valueInputEl;
maskState.currentPreviewBox = previewBoxEl;
maskState.currentFilename = filename;
maskState.historyStack = [];
maskState.redoStack = [];
updateUndoRedoButtons();
if (!valueInputEl._originalUnmaskedFilename) valueInputEl._originalUnmaskedFilename = filename;
// 👈 修复问题 2:背景永远载入原始无透孔图片
const displayFilename = valueInputEl._originalUnmaskedFilename;
const modal = document.getElementById('mask-modal');
const bgImg = document.getElementById('mask-bg-img');
const canvas = document.getElementById('mask-draw-canvas');
const cursor = document.getElementById('mask-brush-cursor');
maskState.bgImg = bgImg;
maskState.canvas = canvas;
maskState.ctx = canvas.getContext('2d');
maskState.cursor = cursor;
canvas.style.opacity = '0.4';
bgImg.onload = () => {
const nw = bgImg.naturalWidth;
const nh = bgImg.naturalHeight;
canvas.width = nw;
canvas.height = nh;
maskState.ctx.clearRect(0, 0, nw, nh);
// 如果 DOM 节点上有缓存的 Mask 画布,恢复它
if (valueInputEl._cachedMaskCanvas) {
maskState.ctx.drawImage(valueInputEl._cachedMaskCanvas, 0, 0);
}
initCanvasEvents();
updateCursorSize();
modal.style.display = 'flex';
// 同步滑块初始值 UI
const sizeSlider = document.getElementById('mask-brush-size');
if (sizeSlider) {
sizeSlider.value = maskState.brushSize;
document.getElementById('mask-brush-size-val').innerText = `${maskState.brushSize}px`;
}
// 🌟 等待 DOM Layout 渲染完成后,精准刷新光标圈尺寸
requestAnimationFrame(() => {
setTimeout(() => { updateCursorSize(); }, 50);
});
};
bgImg.src = `/api/view?filename=${encodeURIComponent(displayFilename)}&type=input`;
}
function initCanvasEvents() {
const canvas = maskState.canvas;
const bgImg = maskState.bgImg;
const cursor = maskState.cursor;
// 存储上一次画笔的物理坐标
let lastX = null;
let lastY = null;
const getCanvasCoords = (e) => {
const rect = bgImg.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
clientX: clientX - rect.left,
clientY: clientY - rect.top
};
};
// 执行单段线段绘制/插值
const drawSegment = (x1, y1, x2, y2) => {
const ctx = maskState.ctx;
ctx.lineWidth = maskState.brushSize;
ctx.lineCap = 'round'; // 线的两端为圆头
ctx.lineJoin = 'round'; // 线段拐角处圆滑过渡
if (maskState.mode === 'draw') {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#ff0000'; // 纯红绘制,重叠不会变深
ctx.fillStyle = '#ff0000';
} else {
ctx.globalCompositeOperation = 'destination-out'; // 橡皮擦模式
}
ctx.beginPath();
if (x1 === x2 && y1 === y2) {
// 点按一下(未发生划动)
ctx.arc(x1, y1, maskState.brushSize / 2, 0, Math.PI * 2);
ctx.fill();
} else {
// 划动过程:连接上一点与当前点,消除离散断点
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
};
const startDraw = (e) => {
e.preventDefault();
saveMaskState();
maskState.isDrawing = true;
const pos = getCanvasCoords(e);
lastX = pos.x;
lastY = pos.y;
drawSegment(pos.x, pos.y, pos.x, pos.y);
};
const moveDraw = (e) => {
const pos = getCanvasCoords(e);
// 更新并显示画笔圆圈 Cursor
updateCursorSize(); // 👈 确保尺寸是最新的
cursor.style.display = 'block';
cursor.style.left = `${pos.clientX}px`;
cursor.style.top = `${pos.clientY}px`;
if (!maskState.isDrawing) return;
e.preventDefault();
// 🌟 获取高频触摸点 (Coalesced Events),处理快速划动时的丢失点
const coalesced = (e.getCoalescedEvents && e.getCoalescedEvents()) || [e];
for (const ev of coalesced) {
const currentPos = getCanvasCoords(ev);
drawSegment(lastX, lastY, currentPos.x, currentPos.y);
lastX = currentPos.x;
lastY = currentPos.y;
}
};
const stopDraw = () => {
if (maskState.isDrawing) {
maskState.isDrawing = false;
lastX = null;
lastY = null;
}
};
canvas.onpointerdown = startDraw;
canvas.onpointermove = moveDraw;
canvas.onpointerup = stopDraw;
canvas.onpointerleave = () => {
stopDraw();
cursor.style.display = 'none';
};
bgImg.parentElement.onpointerenter = () => { cursor.style.display = 'block'; };
bgImg.parentElement.onpointerleave = () => { cursor.style.display = 'none'; };
}
async function applyAndUploadMask() {
const saveBtn = document.getElementById('mask-btn-save');
saveBtn.innerText = t('label_uploading', 'Uploading...');
saveBtn.disabled = true;
try {
const nw = maskState.bgImg.naturalWidth;
const nh = maskState.bgImg.naturalHeight;
// 1. 检查如果 Mask 画板全空,直接还原为原图,无需提交 Mask
const maskPixelData = maskState.ctx.getImageData(0, 0, nw, nh).data;
let isMaskEmpty = true;
for (let i = 3; i < maskPixelData.length; i += 4) {
if (maskPixelData[i] > 0) { isMaskEmpty = false; break; }
}
const originalName = maskState.currentValueInput._originalUnmaskedFilename || maskState.currentFilename;
if (isMaskEmpty) {
maskState.currentValueInput.value = originalName;
maskState.currentValueInput._cachedMaskCanvas = null;
maskState.currentValueInput.dispatchEvent(new Event('change'));
const img = maskState.currentPreviewBox.querySelector('img');
if (img) img.src = `/api/view?filename=${encodeURIComponent(originalName)}&type=input&t=${Date.now()}`;
saveBtn.innerText = t('label_save', "Save")
saveBtn.disabled = false;
document.getElementById('mask-modal').style.display = 'none';
return;
}
// 2. 缓存当前 Mask 画布供二次编辑
const cacheCanvas = document.createElement('canvas');
cacheCanvas.width = nw; cacheCanvas.height = nh;
cacheCanvas.getContext('2d').drawImage(maskState.canvas, 0, 0);
maskState.currentValueInput._cachedMaskCanvas = cacheCanvas;
// 🌟 3. 反转 Mask Alpha 通道,适配 ComfyUI (未涂鸦=不透明255,涂鸦=透明0)
const invCanvas = document.createElement('canvas');
invCanvas.width = nw; invCanvas.height = nh;
const invCtx = invCanvas.getContext('2d');
invCtx.fillStyle = '#FFFFFF';
invCtx.fillRect(0, 0, nw, nh); // 填充纯白 (Alpha=255)
invCtx.globalCompositeOperation = 'destination-out';
invCtx.drawImage(maskState.canvas, 0, 0); // 扣掉涂鸦区域 (Alpha=0)
// 4. 将反转后的正确 Alpha Mask 提交给 ComfyUI /upload/mask 接口
invCanvas.toBlob(async (maskBlob) => {
const formData = new FormData();
formData.append('image', maskBlob, 'mask.png');
formData.append('original_ref', JSON.stringify({
filename: originalName,
subfolder: '',
type: 'input'
}));
const res = await fetch('/upload/mask', { method: 'POST', body: formData });
if (res.ok) {
const result = await res.json();
maskState.currentValueInput.value = result.name;
maskState.currentValueInput.dispatchEvent(new Event('change'));
// 刷新前端预览图
const img = maskState.currentPreviewBox.querySelector('img');
if (img) img.src = `/api/view?filename=${encodeURIComponent(result.name)}&type=input&t=${Date.now()}`;
} else {
alert("Failed to save mask via /upload/mask");
}
saveBtn.innerText = t('label_save', "Save");
saveBtn.disabled = false;
document.getElementById('mask-modal').style.display = 'none';
}, 'image/png');
} catch (err) {
console.error("Failed to apply mask:", err);
saveBtn.innerText = t('label_save', "Save")
saveBtn.disabled = false;
}
}
// 初始化 Mask Modal 事件
initMaskModalEvents();
// 📊 ====== 监控面板数据与交互逻辑 ======
let systemMonitorTimer = null;
function formatBytesToGB(bytes) {
if (!bytes || bytes === 0) return "0 GB";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
}
async function fetchAndUpdateSystemMonitor() {
try {
const res = await fetch('/appbuilder/system_stats');
if (!res.ok) return;
const data = await res.json();
// 1. 第一行:CPU/RAM 渲染
const cpuPct = Number(data.system.cpu_utilization || 0);
document.getElementById('mon-cpu-text').innerText = `${cpuPct.toFixed(1)}%`;
const cpuBar = document.getElementById('mon-cpu-bar');
cpuBar.style.width = `${cpuPct}%`;
cpuBar.className = `monitor-bar-fill ${cpuPct > 85 ? 'danger' : (cpuPct > 70 ? 'warn' : '')}`;
const ramTotal = data.system.ram_total || 0;
const ramUsed = data.system.ram_used || 0;
const ramPct = Number(data.system.ram_utilization || 0);
document.getElementById('mon-ram-text').innerText = `${formatBytesToGB(ramUsed)}/${formatBytesToGB(ramTotal)} ${ramPct.toFixed(1)}%`;
const ramBar = document.getElementById('mon-ram-bar');
ramBar.style.width = `${ramPct}%`;
ramBar.className = `monitor-bar-fill ${ramPct > 85 ? 'danger' : (ramPct > 70 ? 'warn' : '')}`;
// 2. 第二行起:NVIDIA GPU 核心用量与 VRAM 渲染
const gpuContainer = document.getElementById('gpu-monitor-container');
gpuContainer.innerHTML = '';
if (data.devices && data.devices.length > 0) {
data.devices.forEach((dev, idx) => {
const gpuCorePct = Number(dev.gpu_utilization || 0);
const vramTotal = dev.vram_total || 0;
const vramUsed = dev.vram_used || 0;
const vramPct = Number(dev.vram_utilization || 0);
let devName = dev.name || `GPU ${idx}`;
if (devName.includes("cuda:")) {
devName = devName.match(/cuda:\d+\s+(.*?)\s*:/)?.[1];
}
const gpuRow = document.createElement('div');
gpuRow.className = 'monitor-row';
gpuRow.innerHTML = `
<div class="monitor-item">
<div class="monitor-label"><span>${devName}</span><span>${gpuCorePct.toFixed(1)}%</span></div>
<div class="monitor-bar-track"><div class="monitor-bar-fill ${gpuCorePct > 85 ? 'danger' : (gpuCorePct > 70 ? 'warn' : '')}" style="width: ${gpuCorePct}%;"></div></div>
</div>
<div class="monitor-item">
<div class="monitor-label"><span>VRAM</span><span>${formatBytesToGB(vramUsed)}/${formatBytesToGB(vramTotal)} ${vramPct.toFixed(1)}%</span></div>
<div class="monitor-bar-track"><div class="monitor-bar-fill ${vramPct > 85 ? 'danger' : (vramPct > 70 ? 'warn' : '')}" style="width: ${vramPct}%;"></div></div>
</div>
`;
gpuContainer.appendChild(gpuRow);
});
}
} catch (err) {
console.error("Fetch system stats failed:", err);
}
}
// 绑定按钮开关
const monitorBtn = document.getElementById('monitor-toggle-btn');
const monitorPanel = document.getElementById('system-monitor-panel');
if (monitorBtn && monitorPanel) {
monitorBtn.onclick = (e) => {
e.stopPropagation();
const isCollapsed = monitorPanel.classList.contains('collapsed');
if (isCollapsed) {
monitorPanel.classList.remove('collapsed');
monitorBtn.classList.add('active');
fetchAndUpdateSystemMonitor();
if (!systemMonitorTimer) systemMonitorTimer = setInterval(fetchAndUpdateSystemMonitor, 1000);
} else {
monitorPanel.classList.add('collapsed');
monitorBtn.classList.remove('active');
if (systemMonitorTimer) {
clearInterval(systemMonitorTimer);
systemMonitorTimer = null;
}
}
};
}
</script>
</body>
</html>