4125 lines
222 KiB
HTML
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">×</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">×</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> |