Spaces:
Sleeping
Sleeping
pr1
#2
by
bibibi12345
- opened
- data/models.json +17 -1
- public/index.html +612 -37
- server.js +279 -59
data/models.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
[
|
| 2 |
{ "id": "JuggernautXL", "name": "JuggernautXL", "free": true },
|
| 3 |
-
{ "id": "qwen-image", "name": "qwen-image", "free": false },
|
| 4 |
{ "id": "hidream", "name": "hidream", "free": false },
|
| 5 |
{ "id": "FLUX.1-dev", "name": "FLUX.1-dev", "free": false },
|
| 6 |
{ "id": "FLUX.1-schnell", "name": "FLUX.1-schnell", "free": false },
|
|
@@ -19,11 +18,28 @@
|
|
| 19 |
{ "id": "nova-cartoon-xl", "name": "nova-cartoon-xl", "free": false },
|
| 20 |
{ "id": "orphic-lora", "name": "orphic-lora", "free": false },
|
| 21 |
{ "id": "diagonalge/ConstShaper", "name": "ConstShaper", "free": false },
|
|
|
|
|
|
|
| 22 |
{
|
| 23 |
"id": "hunyuan-image-3",
|
| 24 |
"name": "hunyuan-image-3",
|
| 25 |
"free": false,
|
| 26 |
"minimal": true,
|
| 27 |
"upstream_url": "https://chutes-hunyuan-image-3.chutes.ai/generate"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
]
|
|
|
|
| 1 |
[
|
| 2 |
{ "id": "JuggernautXL", "name": "JuggernautXL", "free": true },
|
|
|
|
| 3 |
{ "id": "hidream", "name": "hidream", "free": false },
|
| 4 |
{ "id": "FLUX.1-dev", "name": "FLUX.1-dev", "free": false },
|
| 5 |
{ "id": "FLUX.1-schnell", "name": "FLUX.1-schnell", "free": false },
|
|
|
|
| 18 |
{ "id": "nova-cartoon-xl", "name": "nova-cartoon-xl", "free": false },
|
| 19 |
{ "id": "orphic-lora", "name": "orphic-lora", "free": false },
|
| 20 |
{ "id": "diagonalge/ConstShaper", "name": "ConstShaper", "free": false },
|
| 21 |
+
{ "id": "qwen-image", "name": "qwen-image", "free": false },
|
| 22 |
+
{ "id": "qwen-image-edit", "name": "qwen-image-edit", "free": false, "upstream_id": "qwen-image", "upstream_url": "https://chutes-qwen-image-edit-2509.chutes.ai/generate" },
|
| 23 |
{
|
| 24 |
"id": "hunyuan-image-3",
|
| 25 |
"name": "hunyuan-image-3",
|
| 26 |
"free": false,
|
| 27 |
"minimal": true,
|
| 28 |
"upstream_url": "https://chutes-hunyuan-image-3.chutes.ai/generate"
|
| 29 |
+
},
|
| 30 |
+
{ "id": "novafurry/NovaFurryXL", "name": "NovaFurryXL", "free": false },
|
| 31 |
+
{ "id": "hidream", "name": "hidream", "free": false, "upstream_url": "https://chutes-hidream.chutes.ai/generate" },
|
| 32 |
+
{
|
| 33 |
+
"id": "z-image-turbo",
|
| 34 |
+
"name": "Z-Image-Turbo",
|
| 35 |
+
"free": false,
|
| 36 |
+
"minimal": true,
|
| 37 |
+
"upstream_url": "https://chutes-z-image-turbo.chutes.ai/generate",
|
| 38 |
+
"default_params": {
|
| 39 |
+
"guidance_scale": 0.0,
|
| 40 |
+
"num_inference_steps": 9,
|
| 41 |
+
"shift": 3.0,
|
| 42 |
+
"max_sequence_length": 512
|
| 43 |
+
}
|
| 44 |
}
|
| 45 |
]
|
public/index.html
CHANGED
|
@@ -339,9 +339,16 @@
|
|
| 339 |
|
| 340 |
.card img {
|
| 341 |
width: 100%;
|
|
|
|
| 342 |
display: block;
|
| 343 |
aspect-ratio: 1;
|
| 344 |
object-fit: cover;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
.card .meta {
|
|
@@ -627,20 +634,119 @@
|
|
| 627 |
width: 100%;
|
| 628 |
}
|
| 629 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
</style>
|
| 631 |
</head>
|
| 632 |
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
<header>
|
| 634 |
<button class="mobile-menu-toggle" id="mobileMenuToggle">☰</button>
|
| 635 |
<h1>Chutes</h1>
|
| 636 |
<div class="controls">
|
| 637 |
<div class="toggle-group">
|
| 638 |
-
<button id="soundToggle" class="toggle-btn"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
<button id="themeToggle" class="toggle-btn">主题</button>
|
| 640 |
</div>
|
| 641 |
<div class="api-input">
|
| 642 |
-
<span
|
| 643 |
-
<input type="password" id="apiKeyInput" placeholder="API
|
| 644 |
</div>
|
| 645 |
</div>
|
| 646 |
</header>
|
|
@@ -655,13 +761,18 @@
|
|
| 655 |
<div class="sidebar-content">
|
| 656 |
<div class="group">
|
| 657 |
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
| 658 |
-
<button id="soundToggleMobile" class="toggle-btn"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
<button id="themeToggleMobile" class="toggle-btn">主题</button>
|
| 660 |
</div>
|
| 661 |
</div>
|
| 662 |
<div class="group">
|
| 663 |
-
<label>API
|
| 664 |
-
<input type="password" id="apiKeyInputMobile" placeholder="
|
| 665 |
</div>
|
| 666 |
<div class="group">
|
| 667 |
<label>模型</label>
|
|
@@ -671,16 +782,47 @@
|
|
| 671 |
<label>提示词</label>
|
| 672 |
<textarea id="promptMobile" rows="3" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
|
| 673 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
<div class="group">
|
| 675 |
<label>反向提示词</label>
|
| 676 |
<textarea id="negative_promptMobile" rows="2" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
|
| 677 |
</div>
|
| 678 |
-
<div class="group
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
<div class="group">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
<label>宽度</label>
|
| 681 |
<input type="number" id="widthMobile" value="1024">
|
| 682 |
</div>
|
| 683 |
-
<div class="group">
|
| 684 |
<label>高度</label>
|
| 685 |
<input type="number" id="heightMobile" value="1024">
|
| 686 |
</div>
|
|
@@ -712,7 +854,11 @@
|
|
| 712 |
</div>
|
| 713 |
|
| 714 |
<!-- Mobile Generate Button -->
|
| 715 |
-
<button class="mobile-generate-btn" id="mobileGenerateBtn"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
|
| 717 |
<main>
|
| 718 |
<section class="panel" id="desktopPanel">
|
|
@@ -727,16 +873,47 @@
|
|
| 727 |
<label>提示词</label>
|
| 728 |
<textarea id="prompt" rows="2" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
|
| 729 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
<div class="group">
|
| 731 |
<label>反向提示词</label>
|
| 732 |
<textarea id="negative_prompt" rows="1" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
|
| 733 |
</div>
|
| 734 |
-
<div class="group
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
<div class="group">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
<label>宽度</label>
|
| 737 |
<input type="number" id="width" value="1024">
|
| 738 |
</div>
|
| 739 |
-
<div class="group">
|
| 740 |
<label>高度</label>
|
| 741 |
<input type="number" id="height" value="1024">
|
| 742 |
</div>
|
|
@@ -772,6 +949,16 @@
|
|
| 772 |
<div id="gallery" class="gallery"></div>
|
| 773 |
</section>
|
| 774 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
<audio id="notify" src="/studio/new-notification-3-398649.mp3" preload="auto"></audio>
|
| 776 |
<script>
|
| 777 |
const qs = s => document.querySelector(s);
|
|
@@ -783,13 +970,15 @@
|
|
| 783 |
|
| 784 |
const state = {
|
| 785 |
models: [],
|
|
|
|
| 786 |
sound: true,
|
| 787 |
theme: 'light',
|
| 788 |
apiKey: '',
|
| 789 |
folderHandle: null,
|
| 790 |
seedRandom: true,
|
| 791 |
isMobile: window.innerWidth <= 768,
|
| 792 |
-
sidebarOpen: false
|
|
|
|
| 793 |
};
|
| 794 |
|
| 795 |
function setTheme(t) {
|
|
@@ -821,14 +1010,24 @@
|
|
| 821 |
const btn = qs('#soundToggle');
|
| 822 |
if (btn) {
|
| 823 |
btn.classList.toggle('active', state.sound);
|
| 824 |
-
|
|
|
|
|
|
|
|
|
|
| 825 |
}
|
| 826 |
|
| 827 |
// 更新移动端按钮
|
| 828 |
const btnMobile = qs('#soundToggleMobile');
|
| 829 |
if (btnMobile) {
|
| 830 |
btnMobile.classList.toggle('active', state.sound);
|
| 831 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
}
|
| 833 |
}
|
| 834 |
|
|
@@ -837,6 +1036,8 @@
|
|
| 837 |
ls.set('apiKey', state.apiKey);
|
| 838 |
const el = qs('#apiKeyInput');
|
| 839 |
if (el && el.value !== state.apiKey) el.value = state.apiKey;
|
|
|
|
|
|
|
| 840 |
}
|
| 841 |
|
| 842 |
function updateSeedToggle() {
|
|
@@ -860,9 +1061,12 @@
|
|
| 860 |
headers: state.apiKey ? { 'x-api-key': state.apiKey } : {}
|
| 861 |
});
|
| 862 |
const j = await r.json();
|
|
|
|
|
|
|
| 863 |
state.models = (j.models || []).map(m => ({
|
| 864 |
id: String(m.id || m.name || ''),
|
| 865 |
-
name: String(m.name || m.id || '')
|
|
|
|
| 866 |
}));
|
| 867 |
renderModels();
|
| 868 |
} catch(e) {
|
|
@@ -886,21 +1090,108 @@
|
|
| 886 |
if (last && last.model) sel.value = last.model;
|
| 887 |
}
|
| 888 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
}
|
| 890 |
|
| 891 |
function currentParams() {
|
| 892 |
const isMobile = window.innerWidth <= 768;
|
| 893 |
const prefix = isMobile ? 'Mobile' : '';
|
| 894 |
-
|
| 895 |
-
|
|
|
|
|
|
|
|
|
|
| 896 |
prompt: (qs(`#prompt${prefix}`).value || '').trim(),
|
| 897 |
negative_prompt: (qs(`#negative_prompt${prefix}`).value || '').trim(),
|
| 898 |
width: Number(qs(`#width${prefix}`).value) || 1024,
|
| 899 |
height: Number(qs(`#height${prefix}`).value) || 1024,
|
| 900 |
-
guidance_scale: Number(qs(`#guidance_scale${prefix}`).value)
|
| 901 |
num_inference_steps: Number(qs(`#num_inference_steps${prefix}`).value) || 20,
|
| 902 |
-
|
|
|
|
|
|
|
|
|
|
| 903 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
}
|
| 905 |
|
| 906 |
function createPlaceholderCard(i, params) {
|
|
@@ -924,6 +1215,11 @@
|
|
| 924 |
img.src = dataUrl;
|
| 925 |
img.alt = params.prompt || 'image';
|
| 926 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
const meta = document.createElement('div');
|
| 928 |
meta.className = 'meta';
|
| 929 |
|
|
@@ -955,10 +1251,19 @@
|
|
| 955 |
<div style="margin-bottom: 4px;">尺寸: ${params.width}x${params.height}</div>
|
| 956 |
<div style="margin-bottom: 4px;">步数: ${params.num_inference_steps} | 引导: ${params.guidance_scale}</div>
|
| 957 |
<div style="margin-bottom: 8px; font-size: 12px; line-height: 1.3; word-break: break-all;">${params.prompt}</div>
|
| 958 |
-
<div class="row">
|
| 959 |
-
<button class="secondary" onclick="downloadImageMobile('${filename}', '${dataUrl}')">下载</button>
|
| 960 |
-
</div>
|
| 961 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
|
| 963 |
meta.appendChild(metaHeader);
|
| 964 |
meta.appendChild(metaContent);
|
|
@@ -980,6 +1285,115 @@
|
|
| 980 |
}
|
| 981 |
}
|
| 982 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
// 拖拽功能
|
| 984 |
let dragState = {
|
| 985 |
isDragging: false,
|
|
@@ -1026,6 +1440,8 @@
|
|
| 1026 |
const rect = btn.getBoundingClientRect();
|
| 1027 |
dragState.initialX = rect.left;
|
| 1028 |
dragState.initialY = rect.top;
|
|
|
|
|
|
|
| 1029 |
|
| 1030 |
e.preventDefault();
|
| 1031 |
}
|
|
@@ -1063,14 +1479,17 @@
|
|
| 1063 |
}
|
| 1064 |
|
| 1065 |
function endDrag(e) {
|
| 1066 |
-
if (!dragState.isDragging) return;
|
| 1067 |
-
|
| 1068 |
const btn = qs('#mobileGenerateBtn');
|
| 1069 |
if (!btn) return;
|
| 1070 |
|
|
|
|
|
|
|
|
|
|
| 1071 |
dragState.isDragging = false;
|
| 1072 |
btn.classList.remove('dragging');
|
| 1073 |
|
|
|
|
|
|
|
| 1074 |
// 如果移动距离很小,视为点击
|
| 1075 |
const moveDistance = Math.sqrt(
|
| 1076 |
Math.pow(dragState.currentX - dragState.initialX, 2) +
|
|
@@ -1079,13 +1498,13 @@
|
|
| 1079 |
|
| 1080 |
if (moveDistance < 10) {
|
| 1081 |
// 触发生成
|
| 1082 |
-
generate();
|
| 1083 |
}
|
| 1084 |
|
| 1085 |
// 保存位置
|
| 1086 |
ls.set('generateBtnPosition', {
|
| 1087 |
-
x: dragState.currentX,
|
| 1088 |
-
y: dragState.currentY
|
| 1089 |
});
|
| 1090 |
}
|
| 1091 |
|
|
@@ -1169,9 +1588,49 @@
|
|
| 1169 |
return new Blob([u8], { type: mime });
|
| 1170 |
}
|
| 1171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
async function saveToChosenFolder(filename, dataUrl) {
|
| 1173 |
if (!state.folderHandle) return false;
|
| 1174 |
try {
|
|
|
|
|
|
|
|
|
|
| 1175 |
const fileHandle = await state.folderHandle.getFileHandle(filename, { create: true });
|
| 1176 |
const writable = await fileHandle.createWritable();
|
| 1177 |
await writable.write(dataURLtoBlob(dataUrl));
|
|
@@ -1200,9 +1659,18 @@
|
|
| 1200 |
return;
|
| 1201 |
}
|
| 1202 |
const handle = await window.showDirectoryPicker();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1203 |
state.folderHandle = handle;
|
| 1204 |
-
|
| 1205 |
-
qs('#
|
|
|
|
| 1206 |
} catch(e) {
|
| 1207 |
qs('#folderStatus').textContent = '未选择';
|
| 1208 |
qs('#folderStatusMobile').textContent = '未选择';
|
|
@@ -1300,6 +1768,11 @@
|
|
| 1300 |
alert('模型与提示词必填');
|
| 1301 |
return;
|
| 1302 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1303 |
|
| 1304 |
const isMobile = window.innerWidth <= 768;
|
| 1305 |
const batchElement = qs(isMobile ? '#batchCountMobile' : '#batchCount');
|
|
@@ -1314,15 +1787,35 @@
|
|
| 1314 |
const tasks = [];
|
| 1315 |
for (let i = 1; i <= count; i++) {
|
| 1316 |
const placeholder = createPlaceholderCard(i, p);
|
| 1317 |
-
const
|
|
|
|
| 1318 |
prompt: p.prompt,
|
| 1319 |
negative_prompt: p.negative_prompt || '',
|
| 1320 |
-
width: p.width,
|
| 1321 |
-
height: p.height,
|
| 1322 |
guidance_scale: p.guidance_scale,
|
| 1323 |
num_inference_steps: p.num_inference_steps,
|
| 1324 |
seed: state.seedRandom ? genRandomSeed() : 0
|
| 1325 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1326 |
const body = { model: p.model, input_args: args };
|
| 1327 |
|
| 1328 |
const task = (async () => {
|
|
@@ -1347,8 +1840,16 @@
|
|
| 1347 |
const fname = `image_${Date.now()}_${Math.random().toString(16).slice(2)}.jpg`;
|
| 1348 |
updateCardWithImage(placeholder, dataUrl, p, fname);
|
| 1349 |
|
|
|
|
| 1350 |
if (state.folderHandle) {
|
| 1351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1352 |
}
|
| 1353 |
|
| 1354 |
if (state.sound) {
|
|
@@ -1422,6 +1923,12 @@
|
|
| 1422 |
|
| 1423 |
qs('#chooseFolder').addEventListener('click', chooseFolder);
|
| 1424 |
qs('#generateBtn').addEventListener('click', generate);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1425 |
|
| 1426 |
// 移动端事件绑定
|
| 1427 |
qs('#mobileMenuToggle').addEventListener('click', toggleSidebar);
|
|
@@ -1440,6 +1947,12 @@
|
|
| 1440 |
generate();
|
| 1441 |
closeSidebar();
|
| 1442 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1443 |
|
| 1444 |
// 初始化拖拽功能
|
| 1445 |
initDragButton();
|
|
@@ -1458,10 +1971,14 @@
|
|
| 1458 |
// 键盘事件
|
| 1459 |
document.addEventListener('keydown', (e) => {
|
| 1460 |
if (e.key === 'Enter' && !state.sidebarOpen) generate();
|
| 1461 |
-
if (e.key === 'Escape'
|
| 1462 |
-
|
| 1463 |
-
|
| 1464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1465 |
window.addEventListener('resize', checkMobile);
|
| 1466 |
checkMobile();
|
| 1467 |
|
|
@@ -1508,7 +2025,65 @@
|
|
| 1508 |
fetchModels();
|
| 1509 |
}
|
| 1510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
init();
|
|
|
|
| 1512 |
</script>
|
| 1513 |
</body>
|
| 1514 |
</html>
|
|
|
|
| 339 |
|
| 340 |
.card img {
|
| 341 |
width: 100%;
|
| 342 |
+
height: auto;
|
| 343 |
display: block;
|
| 344 |
aspect-ratio: 1;
|
| 345 |
object-fit: cover;
|
| 346 |
+
cursor: pointer;
|
| 347 |
+
transition: opacity 0.2s ease;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.card img:hover {
|
| 351 |
+
opacity: 0.9;
|
| 352 |
}
|
| 353 |
|
| 354 |
.card .meta {
|
|
|
|
| 634 |
width: 100%;
|
| 635 |
}
|
| 636 |
}
|
| 637 |
+
|
| 638 |
+
/* SVG图标样式 */
|
| 639 |
+
.icon-sparkles {
|
| 640 |
+
width: 24px;
|
| 641 |
+
height: 24px;
|
| 642 |
+
fill: currentColor;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
/* 大图预览模态框样式 */
|
| 646 |
+
.image-modal {
|
| 647 |
+
position: fixed;
|
| 648 |
+
top: 0;
|
| 649 |
+
left: 0;
|
| 650 |
+
width: 100%;
|
| 651 |
+
height: 100%;
|
| 652 |
+
background: rgba(0, 0, 0, 0.9);
|
| 653 |
+
z-index: 9999;
|
| 654 |
+
display: none;
|
| 655 |
+
align-items: center;
|
| 656 |
+
justify-content: center;
|
| 657 |
+
flex-direction: column;
|
| 658 |
+
padding: 20px;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.image-modal.show {
|
| 662 |
+
display: flex;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
.image-modal-close {
|
| 666 |
+
position: absolute;
|
| 667 |
+
top: 20px;
|
| 668 |
+
right: 20px;
|
| 669 |
+
background: rgba(255, 255, 255, 0.2);
|
| 670 |
+
border: none;
|
| 671 |
+
color: white;
|
| 672 |
+
font-size: 32px;
|
| 673 |
+
cursor: pointer;
|
| 674 |
+
width: 48px;
|
| 675 |
+
height: 48px;
|
| 676 |
+
border-radius: 50%;
|
| 677 |
+
display: flex;
|
| 678 |
+
align-items: center;
|
| 679 |
+
justify-content: center;
|
| 680 |
+
transition: all 0.2s ease;
|
| 681 |
+
z-index: 10001;
|
| 682 |
+
min-height: auto;
|
| 683 |
+
padding: 0;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.image-modal-close:hover {
|
| 687 |
+
background: rgba(255, 255, 255, 0.3);
|
| 688 |
+
transform: scale(1.1);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.image-modal-content {
|
| 692 |
+
max-width: 90%;
|
| 693 |
+
max-height: 85%;
|
| 694 |
+
display: flex;
|
| 695 |
+
align-items: center;
|
| 696 |
+
justify-content: center;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.image-modal-content img {
|
| 700 |
+
max-width: 100%;
|
| 701 |
+
max-height: 100%;
|
| 702 |
+
object-fit: contain;
|
| 703 |
+
border-radius: 8px;
|
| 704 |
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.image-modal-info {
|
| 708 |
+
color: white;
|
| 709 |
+
margin-top: 16px;
|
| 710 |
+
font-size: 14px;
|
| 711 |
+
text-align: center;
|
| 712 |
+
background: rgba(0, 0, 0, 0.5);
|
| 713 |
+
padding: 8px 16px;
|
| 714 |
+
border-radius: 4px;
|
| 715 |
+
}
|
| 716 |
</style>
|
| 717 |
</head>
|
| 718 |
<body>
|
| 719 |
+
<!-- SVG图标定义 -->
|
| 720 |
+
<svg style="display: none;">
|
| 721 |
+
<defs>
|
| 722 |
+
<symbol id="icon-sparkles" viewBox="0 0 24 24">
|
| 723 |
+
<path d="M12 0L14.59 8.41L23 11L14.59 13.59L12 22L9.41 13.59L1 11L9.41 8.41L12 0Z"/>
|
| 724 |
+
<path d="M19 8L20.5 12.5L25 14L20.5 15.5L19 20L17.5 15.5L13 14L17.5 12.5L19 8Z" opacity="0.6"/>
|
| 725 |
+
</symbol>
|
| 726 |
+
<symbol id="icon-bell" viewBox="0 0 24 24">
|
| 727 |
+
<path d="M12 2C11.172 2 10.5 2.672 10.5 3.5V4.191C8.211 4.886 6.5 7.039 6.5 9.6V14L4.5 16V17H19.5V16L17.5 14V9.6C17.5 7.039 15.789 4.886 13.5 4.191V3.5C13.5 2.672 12.828 2 12 2ZM10 18C10 19.1 10.9 20 12 20C13.1 20 14 19.1 14 18H10Z"/>
|
| 728 |
+
</symbol>
|
| 729 |
+
<symbol id="icon-bell-off" viewBox="0 0 24 24">
|
| 730 |
+
<path d="M12 2C11.172 2 10.5 2.672 10.5 3.5V4.191C8.211 4.886 6.5 7.039 6.5 9.6V14L4.5 16V17H19.5V16L17.5 14V9.6C17.5 7.039 15.789 4.886 13.5 4.191V3.5C13.5 2.672 12.828 2 12 2ZM10 18C10 19.1 10.9 20 12 20C13.1 20 14 19.1 14 18H10Z" opacity="0.3"/>
|
| 731 |
+
<path d="M2 2L22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
| 732 |
+
</symbol>
|
| 733 |
+
</defs>
|
| 734 |
+
</svg>
|
| 735 |
<header>
|
| 736 |
<button class="mobile-menu-toggle" id="mobileMenuToggle">☰</button>
|
| 737 |
<h1>Chutes</h1>
|
| 738 |
<div class="controls">
|
| 739 |
<div class="toggle-group">
|
| 740 |
+
<button id="soundToggle" class="toggle-btn">
|
| 741 |
+
<svg width="16" height="16">
|
| 742 |
+
<use href="#icon-bell"></use>
|
| 743 |
+
</svg>
|
| 744 |
+
</button>
|
| 745 |
<button id="themeToggle" class="toggle-btn">主题</button>
|
| 746 |
</div>
|
| 747 |
<div class="api-input">
|
| 748 |
+
<span>密钥</span>
|
| 749 |
+
<input type="password" id="apiKeyInput" placeholder="API 密钥">
|
| 750 |
</div>
|
| 751 |
</div>
|
| 752 |
</header>
|
|
|
|
| 761 |
<div class="sidebar-content">
|
| 762 |
<div class="group">
|
| 763 |
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
| 764 |
+
<button id="soundToggleMobile" class="toggle-btn">
|
| 765 |
+
<svg width="16" height="16" style="margin-right: 4px;">
|
| 766 |
+
<use href="#icon-bell"></use>
|
| 767 |
+
</svg>
|
| 768 |
+
<span>提示音</span>
|
| 769 |
+
</button>
|
| 770 |
<button id="themeToggleMobile" class="toggle-btn">主题</button>
|
| 771 |
</div>
|
| 772 |
</div>
|
| 773 |
<div class="group">
|
| 774 |
+
<label>API 密钥</label>
|
| 775 |
+
<input type="password" id="apiKeyInputMobile" placeholder="粘贴密钥" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font-size: 14px;">
|
| 776 |
</div>
|
| 777 |
<div class="group">
|
| 778 |
<label>模型</label>
|
|
|
|
| 782 |
<label>提示词</label>
|
| 783 |
<textarea id="promptMobile" rows="3" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
|
| 784 |
</div>
|
| 785 |
+
<div class="group" id="groupImagesMobile">
|
| 786 |
+
<label>参考图(最多3张,仅用于 qwen-image-edit)</label>
|
| 787 |
+
<input type="file" id="imagesMobile" accept="image/*" multiple>
|
| 788 |
+
<div class="status" id="imagesStatusMobile">未选择</div>
|
| 789 |
+
</div>
|
| 790 |
+
<div class="group" id="groupTrueCfgMobile">
|
| 791 |
+
<label>True CFG 系数(qwen-image-edit)</label>
|
| 792 |
+
<input type="number" id="true_cfg_scaleMobile" step="0.1" min="0" max="10" value="4">
|
| 793 |
+
</div>
|
| 794 |
<div class="group">
|
| 795 |
<label>反向提示词</label>
|
| 796 |
<textarea id="negative_promptMobile" rows="2" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
|
| 797 |
</div>
|
| 798 |
+
<div class="group" id="groupResolutionMobile" style="display: none;">
|
| 799 |
+
<label>分辨率(仅 hidream)</label>
|
| 800 |
+
<select id="resolutionMobile">
|
| 801 |
+
<option value="1024x1024">1024x1024</option>
|
| 802 |
+
<option value="768x1360">768x1360</option>
|
| 803 |
+
<option value="1360x768">1360x768</option>
|
| 804 |
+
<option value="880x1168">880x1168</option>
|
| 805 |
+
<option value="1168x880">1168x880</option>
|
| 806 |
+
<option value="1248x832">1248x832</option>
|
| 807 |
+
<option value="832x1248">832x1248</option>
|
| 808 |
+
</select>
|
| 809 |
+
</div>
|
| 810 |
+
<div class="group form-grid" id="groupZImageParamsMobile" style="display: none;">
|
| 811 |
<div class="group">
|
| 812 |
+
<label>Shift(Z-Image-Turbo)</label>
|
| 813 |
+
<input type="number" id="shiftMobile" step="0.1" min="1" max="10" value="3.0">
|
| 814 |
+
</div>
|
| 815 |
+
<div class="group">
|
| 816 |
+
<label>Max Sequence Length</label>
|
| 817 |
+
<input type="number" id="max_sequence_lengthMobile" step="1" min="256" max="2048" value="512">
|
| 818 |
+
</div>
|
| 819 |
+
</div>
|
| 820 |
+
<div class="group form-grid">
|
| 821 |
+
<div class="group" id="groupWidthMobile">
|
| 822 |
<label>宽度</label>
|
| 823 |
<input type="number" id="widthMobile" value="1024">
|
| 824 |
</div>
|
| 825 |
+
<div class="group" id="groupHeightMobile">
|
| 826 |
<label>高度</label>
|
| 827 |
<input type="number" id="heightMobile" value="1024">
|
| 828 |
</div>
|
|
|
|
| 854 |
</div>
|
| 855 |
|
| 856 |
<!-- Mobile Generate Button -->
|
| 857 |
+
<button class="mobile-generate-btn" id="mobileGenerateBtn">
|
| 858 |
+
<svg class="icon-sparkles">
|
| 859 |
+
<use href="#icon-sparkles"></use>
|
| 860 |
+
</svg>
|
| 861 |
+
</button>
|
| 862 |
|
| 863 |
<main>
|
| 864 |
<section class="panel" id="desktopPanel">
|
|
|
|
| 873 |
<label>提示词</label>
|
| 874 |
<textarea id="prompt" rows="2" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
|
| 875 |
</div>
|
| 876 |
+
<div class="group" id="groupImages">
|
| 877 |
+
<label>参考图(最多3张,仅用于 qwen-image-edit)</label>
|
| 878 |
+
<input type="file" id="images" accept="image/*" multiple>
|
| 879 |
+
<div class="status" id="imagesStatus">未选择</div>
|
| 880 |
+
</div>
|
| 881 |
+
<div class="group" id="groupTrueCfg">
|
| 882 |
+
<label>True CFG 系数(qwen-image-edit)</label>
|
| 883 |
+
<input type="number" id="true_cfg_scale" step="0.1" min="0" max="10" value="4">
|
| 884 |
+
</div>
|
| 885 |
<div class="group">
|
| 886 |
<label>反向提示词</label>
|
| 887 |
<textarea id="negative_prompt" rows="1" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
|
| 888 |
</div>
|
| 889 |
+
<div class="group" id="groupResolution" style="display: none;">
|
| 890 |
+
<label>分辨率(仅 hidream)</label>
|
| 891 |
+
<select id="resolution">
|
| 892 |
+
<option value="1024x1024">1024x1024</option>
|
| 893 |
+
<option value="768x1360">768x1360</option>
|
| 894 |
+
<option value="1360x768">1360x768</option>
|
| 895 |
+
<option value="880x1168">880x1168</option>
|
| 896 |
+
<option value="1168x880">1168x880</option>
|
| 897 |
+
<option value="1248x832">1248x832</option>
|
| 898 |
+
<option value="832x1248">832x1248</option>
|
| 899 |
+
</select>
|
| 900 |
+
</div>
|
| 901 |
+
<div class="group form-grid" id="groupZImageParams" style="display: none;">
|
| 902 |
+
<div class="group">
|
| 903 |
+
<label>Shift(Z-Image-Turbo)</label>
|
| 904 |
+
<input type="number" id="shift" step="0.1" min="1" max="10" value="3.0">
|
| 905 |
+
</div>
|
| 906 |
<div class="group">
|
| 907 |
+
<label>Max Sequence Length</label>
|
| 908 |
+
<input type="number" id="max_sequence_length" step="1" min="256" max="2048" value="512">
|
| 909 |
+
</div>
|
| 910 |
+
</div>
|
| 911 |
+
<div class="group form-grid">
|
| 912 |
+
<div class="group" id="groupWidth">
|
| 913 |
<label>宽度</label>
|
| 914 |
<input type="number" id="width" value="1024">
|
| 915 |
</div>
|
| 916 |
+
<div class="group" id="groupHeight">
|
| 917 |
<label>高度</label>
|
| 918 |
<input type="number" id="height" value="1024">
|
| 919 |
</div>
|
|
|
|
| 949 |
<div id="gallery" class="gallery"></div>
|
| 950 |
</section>
|
| 951 |
</main>
|
| 952 |
+
|
| 953 |
+
<!-- 大图预览模态框 -->
|
| 954 |
+
<div class="image-modal" id="imageModal">
|
| 955 |
+
<button class="image-modal-close" id="imageModalClose">×</button>
|
| 956 |
+
<div class="image-modal-content">
|
| 957 |
+
<img id="imageModalImg" src="" alt="预览">
|
| 958 |
+
</div>
|
| 959 |
+
<div class="image-modal-info" id="imageModalInfo"></div>
|
| 960 |
+
</div>
|
| 961 |
+
|
| 962 |
<audio id="notify" src="/studio/new-notification-3-398649.mp3" preload="auto"></audio>
|
| 963 |
<script>
|
| 964 |
const qs = s => document.querySelector(s);
|
|
|
|
| 970 |
|
| 971 |
const state = {
|
| 972 |
models: [],
|
| 973 |
+
modelsConfig: [], // 存储完整的模型配置(包括 default_params)
|
| 974 |
sound: true,
|
| 975 |
theme: 'light',
|
| 976 |
apiKey: '',
|
| 977 |
folderHandle: null,
|
| 978 |
seedRandom: true,
|
| 979 |
isMobile: window.innerWidth <= 768,
|
| 980 |
+
sidebarOpen: false,
|
| 981 |
+
imageB64s: []
|
| 982 |
};
|
| 983 |
|
| 984 |
function setTheme(t) {
|
|
|
|
| 1010 |
const btn = qs('#soundToggle');
|
| 1011 |
if (btn) {
|
| 1012 |
btn.classList.toggle('active', state.sound);
|
| 1013 |
+
const svg = btn.querySelector('svg use');
|
| 1014 |
+
if (svg) {
|
| 1015 |
+
svg.setAttribute('href', state.sound ? '#icon-bell' : '#icon-bell-off');
|
| 1016 |
+
}
|
| 1017 |
}
|
| 1018 |
|
| 1019 |
// 更新移动端按钮
|
| 1020 |
const btnMobile = qs('#soundToggleMobile');
|
| 1021 |
if (btnMobile) {
|
| 1022 |
btnMobile.classList.toggle('active', state.sound);
|
| 1023 |
+
const svg = btnMobile.querySelector('svg use');
|
| 1024 |
+
const span = btnMobile.querySelector('span');
|
| 1025 |
+
if (svg) {
|
| 1026 |
+
svg.setAttribute('href', state.sound ? '#icon-bell' : '#icon-bell-off');
|
| 1027 |
+
}
|
| 1028 |
+
if (span) {
|
| 1029 |
+
span.textContent = state.sound ? '开启' : '关闭';
|
| 1030 |
+
}
|
| 1031 |
}
|
| 1032 |
}
|
| 1033 |
|
|
|
|
| 1036 |
ls.set('apiKey', state.apiKey);
|
| 1037 |
const el = qs('#apiKeyInput');
|
| 1038 |
if (el && el.value !== state.apiKey) el.value = state.apiKey;
|
| 1039 |
+
const elMobile = qs('#apiKeyInputMobile');
|
| 1040 |
+
if (elMobile && elMobile.value !== state.apiKey) elMobile.value = state.apiKey;
|
| 1041 |
}
|
| 1042 |
|
| 1043 |
function updateSeedToggle() {
|
|
|
|
| 1061 |
headers: state.apiKey ? { 'x-api-key': state.apiKey } : {}
|
| 1062 |
});
|
| 1063 |
const j = await r.json();
|
| 1064 |
+
// 保存完整的模型配置
|
| 1065 |
+
state.modelsConfig = j.models || [];
|
| 1066 |
state.models = (j.models || []).map(m => ({
|
| 1067 |
id: String(m.id || m.name || ''),
|
| 1068 |
+
name: String(m.name || m.id || ''),
|
| 1069 |
+
default_params: m.default_params || null
|
| 1070 |
}));
|
| 1071 |
renderModels();
|
| 1072 |
} catch(e) {
|
|
|
|
| 1090 |
if (last && last.model) sel.value = last.model;
|
| 1091 |
}
|
| 1092 |
});
|
| 1093 |
+
// 根据当前模型更新参数可见性(桌面与移动端)
|
| 1094 |
+
updateVisibleFieldsFor('');
|
| 1095 |
+
updateVisibleFieldsFor('Mobile');
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
+
function toggleGroup(selector, show) {
|
| 1099 |
+
const el = qs(selector);
|
| 1100 |
+
if (!el) return;
|
| 1101 |
+
el.style.display = show ? '' : 'none';
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
function updateVisibleFieldsFor(suffix) {
|
| 1105 |
+
const sel = qs(`#modelSelect${suffix}`);
|
| 1106 |
+
if (!sel) return;
|
| 1107 |
+
const model = String(sel.value || '').toLowerCase();
|
| 1108 |
+
const isQwenEdit = model === 'qwen-image-edit';
|
| 1109 |
+
const isHidream = model === 'hidream';
|
| 1110 |
+
const isZImageTurbo = model === 'z-image-turbo';
|
| 1111 |
+
|
| 1112 |
+
toggleGroup(`#groupImages${suffix}`, isQwenEdit);
|
| 1113 |
+
toggleGroup(`#groupTrueCfg${suffix}`, isQwenEdit);
|
| 1114 |
+
|
| 1115 |
+
// hidream 使用分辨率;隐藏宽高,显示分辨率下拉
|
| 1116 |
+
toggleGroup(`#groupWidth${suffix}`, !isHidream);
|
| 1117 |
+
toggleGroup(`#groupHeight${suffix}`, !isHidream);
|
| 1118 |
+
toggleGroup(`#groupResolution${suffix}`, isHidream);
|
| 1119 |
+
|
| 1120 |
+
// Z-Image-Turbo 显示专用参数
|
| 1121 |
+
toggleGroup(`#groupZImageParams${suffix}`, isZImageTurbo);
|
| 1122 |
+
|
| 1123 |
+
// 应用模型的默认参数
|
| 1124 |
+
applyModelDefaultParams(model, suffix);
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
// 应用模型的默认参数
|
| 1128 |
+
function applyModelDefaultParams(modelId, suffix) {
|
| 1129 |
+
const modelConfig = state.models.find(m => m.id.toLowerCase() === modelId.toLowerCase());
|
| 1130 |
+
if (!modelConfig || !modelConfig.default_params) return;
|
| 1131 |
+
|
| 1132 |
+
const params = modelConfig.default_params;
|
| 1133 |
+
|
| 1134 |
+
// 应用 guidance_scale
|
| 1135 |
+
if (params.guidance_scale !== undefined) {
|
| 1136 |
+
const el = qs(`#guidance_scale${suffix}`);
|
| 1137 |
+
if (el) el.value = params.guidance_scale;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
// 应用 num_inference_steps
|
| 1141 |
+
if (params.num_inference_steps !== undefined) {
|
| 1142 |
+
const el = qs(`#num_inference_steps${suffix}`);
|
| 1143 |
+
if (el) el.value = params.num_inference_steps;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
// 应用 shift
|
| 1147 |
+
if (params.shift !== undefined) {
|
| 1148 |
+
const el = qs(`#shift${suffix}`);
|
| 1149 |
+
if (el) el.value = params.shift;
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
// 应用 max_sequence_length
|
| 1153 |
+
if (params.max_sequence_length !== undefined) {
|
| 1154 |
+
const el = qs(`#max_sequence_length${suffix}`);
|
| 1155 |
+
if (el) el.value = params.max_sequence_length;
|
| 1156 |
+
}
|
| 1157 |
}
|
| 1158 |
|
| 1159 |
function currentParams() {
|
| 1160 |
const isMobile = window.innerWidth <= 768;
|
| 1161 |
const prefix = isMobile ? 'Mobile' : '';
|
| 1162 |
+
const modelId = qs(`#modelSelect${prefix}`).value || '';
|
| 1163 |
+
const modelConfig = state.models.find(m => m.id === modelId);
|
| 1164 |
+
|
| 1165 |
+
const params = {
|
| 1166 |
+
model: modelId,
|
| 1167 |
prompt: (qs(`#prompt${prefix}`).value || '').trim(),
|
| 1168 |
negative_prompt: (qs(`#negative_prompt${prefix}`).value || '').trim(),
|
| 1169 |
width: Number(qs(`#width${prefix}`).value) || 1024,
|
| 1170 |
height: Number(qs(`#height${prefix}`).value) || 1024,
|
| 1171 |
+
guidance_scale: Number(qs(`#guidance_scale${prefix}`).value),
|
| 1172 |
num_inference_steps: Number(qs(`#num_inference_steps${prefix}`).value) || 20,
|
| 1173 |
+
true_cfg_scale: Number(qs(`#true_cfg_scale${prefix}`)?.value) || 4,
|
| 1174 |
+
image_b64s: state.imageB64s ? state.imageB64s.slice(0, 3) : [],
|
| 1175 |
+
seed: state.seedRandom ? null : 0,
|
| 1176 |
+
resolution: (qs(`#resolution${prefix}`) && qs(`#resolution${prefix}`).value) || ''
|
| 1177 |
};
|
| 1178 |
+
|
| 1179 |
+
// Handle guidance_scale default value properly - only use default if value is NaN (empty input)
|
| 1180 |
+
if (Number.isNaN(params.guidance_scale)) {
|
| 1181 |
+
params.guidance_scale = 6;
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
// 添加 Z-Image-Turbo 特定参数(从 UI 控件读取)
|
| 1185 |
+
const shiftEl = qs(`#shift${prefix}`);
|
| 1186 |
+
const maxSeqEl = qs(`#max_sequence_length${prefix}`);
|
| 1187 |
+
if (shiftEl && shiftEl.offsetParent !== null) {
|
| 1188 |
+
params.shift = Number(shiftEl.value) || 3.0;
|
| 1189 |
+
}
|
| 1190 |
+
if (maxSeqEl && maxSeqEl.offsetParent !== null) {
|
| 1191 |
+
params.max_sequence_length = Number(maxSeqEl.value) || 512;
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
+
return params;
|
| 1195 |
}
|
| 1196 |
|
| 1197 |
function createPlaceholderCard(i, params) {
|
|
|
|
| 1215 |
img.src = dataUrl;
|
| 1216 |
img.alt = params.prompt || 'image';
|
| 1217 |
|
| 1218 |
+
// 添加点击放大功能
|
| 1219 |
+
img.addEventListener('click', () => {
|
| 1220 |
+
showImageModal(dataUrl, params);
|
| 1221 |
+
});
|
| 1222 |
+
|
| 1223 |
const meta = document.createElement('div');
|
| 1224 |
meta.className = 'meta';
|
| 1225 |
|
|
|
|
| 1251 |
<div style="margin-bottom: 4px;">尺寸: ${params.width}x${params.height}</div>
|
| 1252 |
<div style="margin-bottom: 4px;">步数: ${params.num_inference_steps} | 引导: ${params.guidance_scale}</div>
|
| 1253 |
<div style="margin-bottom: 8px; font-size: 12px; line-height: 1.3; word-break: break-all;">${params.prompt}</div>
|
|
|
|
|
|
|
|
|
|
| 1254 |
`;
|
| 1255 |
+
const row = document.createElement('div');
|
| 1256 |
+
row.className = 'row';
|
| 1257 |
+
const dlBtn = document.createElement('button');
|
| 1258 |
+
dlBtn.className = 'secondary';
|
| 1259 |
+
dlBtn.textContent = '下载';
|
| 1260 |
+
dlBtn.addEventListener('click', function(e) {
|
| 1261 |
+
e.preventDefault();
|
| 1262 |
+
e.stopPropagation();
|
| 1263 |
+
downloadImageMobile(filename, dataUrl);
|
| 1264 |
+
});
|
| 1265 |
+
row.appendChild(dlBtn);
|
| 1266 |
+
metaContent.appendChild(row);
|
| 1267 |
|
| 1268 |
meta.appendChild(metaHeader);
|
| 1269 |
meta.appendChild(metaContent);
|
|
|
|
| 1285 |
}
|
| 1286 |
}
|
| 1287 |
|
| 1288 |
+
// Image Viewer (Lightbox)
|
| 1289 |
+
function ensureImageViewer() {
|
| 1290 |
+
let overlay = document.getElementById('imageViewer');
|
| 1291 |
+
if (!overlay) {
|
| 1292 |
+
overlay = document.createElement('div');
|
| 1293 |
+
overlay.id = 'imageViewer';
|
| 1294 |
+
overlay.className = 'image-viewer-overlay';
|
| 1295 |
+
const content = document.createElement('div');
|
| 1296 |
+
content.className = 'image-viewer-content';
|
| 1297 |
+
const img = document.createElement('img');
|
| 1298 |
+
img.alt = 'preview';
|
| 1299 |
+
const closeBtn = document.createElement('button');
|
| 1300 |
+
closeBtn.className = 'image-viewer-close';
|
| 1301 |
+
closeBtn.textContent = '关闭';
|
| 1302 |
+
closeBtn.addEventListener('click', function(e) {
|
| 1303 |
+
e.preventDefault();
|
| 1304 |
+
e.stopPropagation();
|
| 1305 |
+
closeImageViewer();
|
| 1306 |
+
});
|
| 1307 |
+
// click outside content area closes viewer
|
| 1308 |
+
overlay.addEventListener('click', function(e) {
|
| 1309 |
+
if (e.target === overlay) {
|
| 1310 |
+
closeImageViewer();
|
| 1311 |
+
}
|
| 1312 |
+
});
|
| 1313 |
+
content.appendChild(img);
|
| 1314 |
+
content.appendChild(closeBtn);
|
| 1315 |
+
overlay.appendChild(content);
|
| 1316 |
+
document.body.appendChild(overlay);
|
| 1317 |
+
}
|
| 1318 |
+
return overlay;
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
function openImageViewer(dataUrl, params) {
|
| 1322 |
+
const overlay = ensureImageViewer();
|
| 1323 |
+
const img = overlay.querySelector('.image-viewer-content img');
|
| 1324 |
+
img.src = dataUrl;
|
| 1325 |
+
img.alt = (params && params.prompt) ? params.prompt : 'image';
|
| 1326 |
+
overlay.classList.add('show');
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
function closeImageViewer() {
|
| 1330 |
+
const overlay = document.getElementById('imageViewer');
|
| 1331 |
+
if (overlay) {
|
| 1332 |
+
overlay.classList.remove('show');
|
| 1333 |
+
const img = overlay.querySelector('.image-viewer-content img');
|
| 1334 |
+
if (img) img.src = '';
|
| 1335 |
+
}
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
function isViewerOpen() {
|
| 1339 |
+
const overlay = document.getElementById('imageViewer');
|
| 1340 |
+
return !!(overlay && overlay.classList.contains('show'));
|
| 1341 |
+
}
|
| 1342 |
+
// Image Viewer (Lightbox)
|
| 1343 |
+
function ensureImageViewer() {
|
| 1344 |
+
let overlay = document.getElementById('imageViewer');
|
| 1345 |
+
if (!overlay) {
|
| 1346 |
+
overlay = document.createElement('div');
|
| 1347 |
+
overlay.id = 'imageViewer';
|
| 1348 |
+
overlay.className = 'image-viewer-overlay';
|
| 1349 |
+
const content = document.createElement('div');
|
| 1350 |
+
content.className = 'image-viewer-content';
|
| 1351 |
+
const img = document.createElement('img');
|
| 1352 |
+
img.alt = 'preview';
|
| 1353 |
+
const closeBtn = document.createElement('button');
|
| 1354 |
+
closeBtn.className = 'image-viewer-close';
|
| 1355 |
+
closeBtn.textContent = '关闭';
|
| 1356 |
+
closeBtn.addEventListener('click', function(e) {
|
| 1357 |
+
e.preventDefault();
|
| 1358 |
+
e.stopPropagation();
|
| 1359 |
+
closeImageViewer();
|
| 1360 |
+
});
|
| 1361 |
+
// click outside content area closes viewer
|
| 1362 |
+
overlay.addEventListener('click', function(e) {
|
| 1363 |
+
if (e.target === overlay) {
|
| 1364 |
+
closeImageViewer();
|
| 1365 |
+
}
|
| 1366 |
+
});
|
| 1367 |
+
content.appendChild(img);
|
| 1368 |
+
content.appendChild(closeBtn);
|
| 1369 |
+
overlay.appendChild(content);
|
| 1370 |
+
document.body.appendChild(overlay);
|
| 1371 |
+
}
|
| 1372 |
+
return overlay;
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
function openImageViewer(dataUrl, params) {
|
| 1376 |
+
const overlay = ensureImageViewer();
|
| 1377 |
+
const img = overlay.querySelector('.image-viewer-content img');
|
| 1378 |
+
img.src = dataUrl;
|
| 1379 |
+
img.alt = (params && params.prompt) ? params.prompt : 'image';
|
| 1380 |
+
overlay.classList.add('show');
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
function closeImageViewer() {
|
| 1384 |
+
const overlay = document.getElementById('imageViewer');
|
| 1385 |
+
if (overlay) {
|
| 1386 |
+
overlay.classList.remove('show');
|
| 1387 |
+
const img = overlay.querySelector('.image-viewer-content img');
|
| 1388 |
+
if (img) img.src = '';
|
| 1389 |
+
}
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
function isViewerOpen() {
|
| 1393 |
+
const overlay = document.getElementById('imageViewer');
|
| 1394 |
+
return !!(overlay && overlay.classList.contains('show'));
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
// 拖拽功能
|
| 1398 |
let dragState = {
|
| 1399 |
isDragging: false,
|
|
|
|
| 1440 |
const rect = btn.getBoundingClientRect();
|
| 1441 |
dragState.initialX = rect.left;
|
| 1442 |
dragState.initialY = rect.top;
|
| 1443 |
+
dragState.currentX = rect.left;
|
| 1444 |
+
dragState.currentY = rect.top;
|
| 1445 |
|
| 1446 |
e.preventDefault();
|
| 1447 |
}
|
|
|
|
| 1479 |
}
|
| 1480 |
|
| 1481 |
function endDrag(e) {
|
|
|
|
|
|
|
| 1482 |
const btn = qs('#mobileGenerateBtn');
|
| 1483 |
if (!btn) return;
|
| 1484 |
|
| 1485 |
+
const wasDragging = dragState.isDragging;
|
| 1486 |
+
|
| 1487 |
+
// 立即清除拖拽状态
|
| 1488 |
dragState.isDragging = false;
|
| 1489 |
btn.classList.remove('dragging');
|
| 1490 |
|
| 1491 |
+
if (!wasDragging) return;
|
| 1492 |
+
|
| 1493 |
// 如果移动距离很小,视为点击
|
| 1494 |
const moveDistance = Math.sqrt(
|
| 1495 |
Math.pow(dragState.currentX - dragState.initialX, 2) +
|
|
|
|
| 1498 |
|
| 1499 |
if (moveDistance < 10) {
|
| 1500 |
// 触发生成
|
| 1501 |
+
setTimeout(() => generate(), 50);
|
| 1502 |
}
|
| 1503 |
|
| 1504 |
// 保存位置
|
| 1505 |
ls.set('generateBtnPosition', {
|
| 1506 |
+
x: dragState.currentX || dragState.initialX,
|
| 1507 |
+
y: dragState.currentY || dragState.initialY
|
| 1508 |
});
|
| 1509 |
}
|
| 1510 |
|
|
|
|
| 1588 |
return new Blob([u8], { type: mime });
|
| 1589 |
}
|
| 1590 |
|
| 1591 |
+
// 将文件读取为去掉 data: 前缀的纯 base64,限制最多3张
|
| 1592 |
+
async function filesToBase64Raw(fileList) {
|
| 1593 |
+
const files = Array.from(fileList).slice(0, 3);
|
| 1594 |
+
const arr = [];
|
| 1595 |
+
for (const f of files) {
|
| 1596 |
+
const b64 = await new Promise((resolve, reject) => {
|
| 1597 |
+
const reader = new FileReader();
|
| 1598 |
+
reader.onload = () => {
|
| 1599 |
+
const s = String(reader.result || '');
|
| 1600 |
+
const idx = s.indexOf(',');
|
| 1601 |
+
resolve(idx >= 0 ? s.slice(idx + 1) : s);
|
| 1602 |
+
};
|
| 1603 |
+
reader.onerror = () => reject(reader.error || new Error('read failed'));
|
| 1604 |
+
reader.readAsDataURL(f);
|
| 1605 |
+
});
|
| 1606 |
+
arr.push(b64);
|
| 1607 |
+
}
|
| 1608 |
+
return arr;
|
| 1609 |
+
}
|
| 1610 |
+
|
| 1611 |
+
function handleImagesChange(inputEl, statusElId) {
|
| 1612 |
+
try {
|
| 1613 |
+
const files = inputEl && inputEl.files ? inputEl.files : [];
|
| 1614 |
+
if (!files || files.length === 0) {
|
| 1615 |
+
state.imageB64s = [];
|
| 1616 |
+
const st = qs('#' + statusElId);
|
| 1617 |
+
if (st) st.textContent = '未选择';
|
| 1618 |
+
return;
|
| 1619 |
+
}
|
| 1620 |
+
filesToBase64Raw(files).then(list => {
|
| 1621 |
+
state.imageB64s = list;
|
| 1622 |
+
const st = qs('#' + statusElId);
|
| 1623 |
+
if (st) st.textContent = '已选择 ' + list.length + ' 张';
|
| 1624 |
+
}).catch(() => {});
|
| 1625 |
+
} catch(e) {}
|
| 1626 |
+
}
|
| 1627 |
+
|
| 1628 |
async function saveToChosenFolder(filename, dataUrl) {
|
| 1629 |
if (!state.folderHandle) return false;
|
| 1630 |
try {
|
| 1631 |
+
// 如果无写入权限,避免在非用户激活上下文触发授权提示
|
| 1632 |
+
const perm = await state.folderHandle.queryPermission({ mode: 'readwrite' });
|
| 1633 |
+
if (perm !== 'granted') return false;
|
| 1634 |
const fileHandle = await state.folderHandle.getFileHandle(filename, { create: true });
|
| 1635 |
const writable = await fileHandle.createWritable();
|
| 1636 |
await writable.write(dataURLtoBlob(dataUrl));
|
|
|
|
| 1659 |
return;
|
| 1660 |
}
|
| 1661 |
const handle = await window.showDirectoryPicker();
|
| 1662 |
+
// 在用户点击的上下文中请求写入权限
|
| 1663 |
+
let perm = 'prompt';
|
| 1664 |
+
if ('queryPermission' in handle && 'requestPermission' in handle) {
|
| 1665 |
+
perm = await handle.queryPermission({ mode: 'readwrite' });
|
| 1666 |
+
if (perm !== 'granted') {
|
| 1667 |
+
perm = await handle.requestPermission({ mode: 'readwrite' });
|
| 1668 |
+
}
|
| 1669 |
+
}
|
| 1670 |
state.folderHandle = handle;
|
| 1671 |
+
const ok = perm === 'granted';
|
| 1672 |
+
qs('#folderStatus').textContent = ok ? '已选择(可写)' : '已选择(只读)';
|
| 1673 |
+
qs('#folderStatusMobile').textContent = ok ? '已选择(可写)' : '已选择(只读)';
|
| 1674 |
} catch(e) {
|
| 1675 |
qs('#folderStatus').textContent = '未选择';
|
| 1676 |
qs('#folderStatusMobile').textContent = '未选择';
|
|
|
|
| 1768 |
alert('模型与提示词必填');
|
| 1769 |
return;
|
| 1770 |
}
|
| 1771 |
+
// qwen-image-edit 需要至少一张参考图
|
| 1772 |
+
if ((p.model || '').toLowerCase() === 'qwen-image-edit' && (!state.imageB64s || state.imageB64s.length < 1)) {
|
| 1773 |
+
alert('qwen-image-edit 需要至少 1 张参考图');
|
| 1774 |
+
return;
|
| 1775 |
+
}
|
| 1776 |
|
| 1777 |
const isMobile = window.innerWidth <= 768;
|
| 1778 |
const batchElement = qs(isMobile ? '#batchCountMobile' : '#batchCount');
|
|
|
|
| 1787 |
const tasks = [];
|
| 1788 |
for (let i = 1; i <= count; i++) {
|
| 1789 |
const placeholder = createPlaceholderCard(i, p);
|
| 1790 |
+
const isQwenEdit = (p.model || '').toLowerCase() === 'qwen-image-edit';
|
| 1791 |
+
const argsBase = {
|
| 1792 |
prompt: p.prompt,
|
| 1793 |
negative_prompt: p.negative_prompt || '',
|
|
|
|
|
|
|
| 1794 |
guidance_scale: p.guidance_scale,
|
| 1795 |
num_inference_steps: p.num_inference_steps,
|
| 1796 |
seed: state.seedRandom ? genRandomSeed() : 0
|
| 1797 |
};
|
| 1798 |
+
const args = isQwenEdit
|
| 1799 |
+
? {
|
| 1800 |
+
...argsBase,
|
| 1801 |
+
width: p.width,
|
| 1802 |
+
height: p.height,
|
| 1803 |
+
true_cfg_scale: Number(qs(`#true_cfg_scale${isMobile ? 'Mobile' : ''}`)?.value) || 4,
|
| 1804 |
+
...(state.imageB64s && state.imageB64s.length ? { image_b64s: state.imageB64s.slice(0, 3) } : {})
|
| 1805 |
+
}
|
| 1806 |
+
: {
|
| 1807 |
+
...argsBase,
|
| 1808 |
+
width: p.width,
|
| 1809 |
+
height: p.height
|
| 1810 |
+
};
|
| 1811 |
+
// hidream 期望使用 resolution 字段
|
| 1812 |
+
if ((p.model || '').toLowerCase() === 'hidream') {
|
| 1813 |
+
args.resolution = (p.resolution && typeof p.resolution === 'string' && p.resolution.includes('x'))
|
| 1814 |
+
? p.resolution
|
| 1815 |
+
: `${p.width}x${p.height}`;
|
| 1816 |
+
delete args.width;
|
| 1817 |
+
delete args.height;
|
| 1818 |
+
}
|
| 1819 |
const body = { model: p.model, input_args: args };
|
| 1820 |
|
| 1821 |
const task = (async () => {
|
|
|
|
| 1840 |
const fname = `image_${Date.now()}_${Math.random().toString(16).slice(2)}.jpg`;
|
| 1841 |
updateCardWithImage(placeholder, dataUrl, p, fname);
|
| 1842 |
|
| 1843 |
+
let saved = false;
|
| 1844 |
if (state.folderHandle) {
|
| 1845 |
+
try {
|
| 1846 |
+
saved = await saveToChosenFolder(fname, dataUrl);
|
| 1847 |
+
} catch(e) {
|
| 1848 |
+
saved = false;
|
| 1849 |
+
}
|
| 1850 |
+
}
|
| 1851 |
+
if (!saved) {
|
| 1852 |
+
downloadImageMobile(fname, dataUrl);
|
| 1853 |
}
|
| 1854 |
|
| 1855 |
if (state.sound) {
|
|
|
|
| 1923 |
|
| 1924 |
qs('#chooseFolder').addEventListener('click', chooseFolder);
|
| 1925 |
qs('#generateBtn').addEventListener('click', generate);
|
| 1926 |
+
|
| 1927 |
+
// 参考图选择事件
|
| 1928 |
+
const imagesEl = qs('#images');
|
| 1929 |
+
if (imagesEl) imagesEl.addEventListener('change', () => handleImagesChange(imagesEl, 'imagesStatus'));
|
| 1930 |
+
const imagesElM = qs('#imagesMobile');
|
| 1931 |
+
if (imagesElM) imagesElM.addEventListener('change', () => handleImagesChange(imagesElM, 'imagesStatusMobile'));
|
| 1932 |
|
| 1933 |
// 移动端事件绑定
|
| 1934 |
qs('#mobileMenuToggle').addEventListener('click', toggleSidebar);
|
|
|
|
| 1947 |
generate();
|
| 1948 |
closeSidebar();
|
| 1949 |
});
|
| 1950 |
+
|
| 1951 |
+
// 模型切换时动态显示/隐藏特定参数
|
| 1952 |
+
const ms = qs('#modelSelect');
|
| 1953 |
+
if (ms) ms.addEventListener('change', () => updateVisibleFieldsFor(''));
|
| 1954 |
+
const msm = qs('#modelSelectMobile');
|
| 1955 |
+
if (msm) msm.addEventListener('change', () => updateVisibleFieldsFor('Mobile'));
|
| 1956 |
|
| 1957 |
// 初始化拖拽功能
|
| 1958 |
initDragButton();
|
|
|
|
| 1971 |
// 键盘事件
|
| 1972 |
document.addEventListener('keydown', (e) => {
|
| 1973 |
if (e.key === 'Enter' && !state.sidebarOpen) generate();
|
| 1974 |
+
if (e.key === 'Escape') {
|
| 1975 |
+
if (isViewerOpen()) {
|
| 1976 |
+
closeImageViewer();
|
| 1977 |
+
} else if (state.sidebarOpen) {
|
| 1978 |
+
closeSidebar();
|
| 1979 |
+
}
|
| 1980 |
+
}
|
| 1981 |
+
});// 响应式检测
|
| 1982 |
window.addEventListener('resize', checkMobile);
|
| 1983 |
checkMobile();
|
| 1984 |
|
|
|
|
| 2025 |
fetchModels();
|
| 2026 |
}
|
| 2027 |
|
| 2028 |
+
// 大图预览功能
|
| 2029 |
+
function showImageModal(imageUrl, params) {
|
| 2030 |
+
const modal = qs('#imageModal');
|
| 2031 |
+
const modalImg = qs('#imageModalImg');
|
| 2032 |
+
const modalInfo = qs('#imageModalInfo');
|
| 2033 |
+
|
| 2034 |
+
modalImg.src = imageUrl;
|
| 2035 |
+
if (params) {
|
| 2036 |
+
modalInfo.textContent = `${params.width}×${params.height} | ${params.model}`;
|
| 2037 |
+
} else {
|
| 2038 |
+
modalInfo.textContent = '';
|
| 2039 |
+
}
|
| 2040 |
+
modal.classList.add('show');
|
| 2041 |
+
|
| 2042 |
+
// 阻止背景滚动
|
| 2043 |
+
document.body.style.overflow = 'hidden';
|
| 2044 |
+
}
|
| 2045 |
+
|
| 2046 |
+
function closeImageModal() {
|
| 2047 |
+
const modal = qs('#imageModal');
|
| 2048 |
+
modal.classList.remove('show');
|
| 2049 |
+
document.body.style.overflow = '';
|
| 2050 |
+
}
|
| 2051 |
+
|
| 2052 |
+
// 设置模态框事件监听
|
| 2053 |
+
function setupImageModal() {
|
| 2054 |
+
const modal = qs('#imageModal');
|
| 2055 |
+
const closeBtn = qs('#imageModalClose');
|
| 2056 |
+
const modalImg = qs('#imageModalImg');
|
| 2057 |
+
|
| 2058 |
+
if (closeBtn) {
|
| 2059 |
+
closeBtn.addEventListener('click', (e) => {
|
| 2060 |
+
e.stopPropagation();
|
| 2061 |
+
closeImageModal();
|
| 2062 |
+
});
|
| 2063 |
+
}
|
| 2064 |
+
|
| 2065 |
+
if (modal) {
|
| 2066 |
+
modal.addEventListener('click', (e) => {
|
| 2067 |
+
if (e.target === modal) {
|
| 2068 |
+
closeImageModal();
|
| 2069 |
+
}
|
| 2070 |
+
});
|
| 2071 |
+
}
|
| 2072 |
+
|
| 2073 |
+
if (modalImg) {
|
| 2074 |
+
modalImg.addEventListener('click', closeImageModal);
|
| 2075 |
+
}
|
| 2076 |
+
|
| 2077 |
+
// ESC键关闭
|
| 2078 |
+
document.addEventListener('keydown', (e) => {
|
| 2079 |
+
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
| 2080 |
+
closeImageModal();
|
| 2081 |
+
}
|
| 2082 |
+
});
|
| 2083 |
+
}
|
| 2084 |
+
|
| 2085 |
init();
|
| 2086 |
+
setupImageModal();
|
| 2087 |
</script>
|
| 2088 |
</body>
|
| 2089 |
</html>
|
server.js
CHANGED
|
@@ -36,7 +36,7 @@ const ALLOW_CLIENT_API_KEY = /^true$/i.test(process.env.ALLOW_CLIENT_API_KEY ||
|
|
| 36 |
// Strict switches to close any possibility of routing/fallback
|
| 37 |
// - STRICT_NO_ROUTING=true 不做本地映射,除非前端或调用方显式传 upstream_id
|
| 38 |
// - STRICT_NO_FALLBACK=true 强制禁用回落(即使前端未设置 no_fallback)
|
| 39 |
-
const STRICT_NO_ROUTING = /^true$/i.test(process.env.STRICT_NO_ROUTING || '
|
| 40 |
const STRICT_NO_FALLBACK = /^true$/i.test(process.env.STRICT_NO_FALLBACK || 'true');
|
| 41 |
// Auto fallback to another model when upstream capacity/infrastructure errors
|
| 42 |
const AUTO_FALLBACK = /^true$/i.test(process.env.AUTO_FALLBACK || 'true');
|
|
@@ -52,23 +52,34 @@ const FORCE_MINIMAL = /^true$/i.test(process.env.FORCE_MINIMAL || 'false');
|
|
| 52 |
app.use(helmet({
|
| 53 |
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
| 54 |
// Allow embedding on Hugging Face Spaces (disable X-Frame-Options)
|
| 55 |
-
frameguard: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}));
|
| 57 |
|
| 58 |
// Enable CSP and allow inline script/style for this SPA.
|
| 59 |
// Also allow connections to the upstream image API.
|
| 60 |
-
// Allow embedding inside Hugging Face Spaces iframe via frame-ancestors
|
| 61 |
app.use(helmet.contentSecurityPolicy({
|
| 62 |
useDefaults: true,
|
| 63 |
directives: {
|
| 64 |
"default-src": ["'self'"],
|
|
|
|
| 65 |
"script-src": ["'self'", "'unsafe-inline'"],
|
|
|
|
|
|
|
| 66 |
"style-src": ["'self'", "'unsafe-inline'"],
|
|
|
|
| 67 |
"img-src": ["'self'", "data:", "blob:"],
|
| 68 |
-
|
| 69 |
-
"
|
|
|
|
|
|
|
| 70 |
"media-src": ["'self'", "data:", "blob:"],
|
| 71 |
-
"frame-
|
|
|
|
| 72 |
}
|
| 73 |
}));
|
| 74 |
|
|
@@ -80,7 +91,8 @@ app.use((req, res, next) => {
|
|
| 80 |
|
| 81 |
app.use(cors());
|
| 82 |
app.use(compression());
|
| 83 |
-
app.use(express.json({ limit: '
|
|
|
|
| 84 |
app.use(morgan(LOG_LEVEL));
|
| 85 |
|
| 86 |
// Static files
|
|
@@ -218,11 +230,25 @@ app.get('/api/models', async (req, res) => {
|
|
| 218 |
const free = typeof m.free === 'boolean' ? m.free : false;
|
| 219 |
return { id, name, free };
|
| 220 |
});
|
| 221 |
-
// Merge free flags from local mapping
|
| 222 |
if (localList.length) {
|
| 223 |
-
//
|
| 224 |
models = mergeFreeFlags(models, localList);
|
| 225 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
const remoteKeys = new Set(models.map(m => ((m.id || m.name || '') + '').toLowerCase()));
|
| 227 |
for (const lm of localList) {
|
| 228 |
const key = ((lm.id || lm.name || '') + '').toLowerCase();
|
|
@@ -230,7 +256,9 @@ app.get('/api/models', async (req, res) => {
|
|
| 230 |
const id = (lm.id || lm.name || '').toString();
|
| 231 |
const name = (lm.name || id).toString();
|
| 232 |
const free = !!lm.free;
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
remoteKeys.add(key);
|
| 235 |
}
|
| 236 |
}
|
|
@@ -243,7 +271,9 @@ app.get('/api/models', async (req, res) => {
|
|
| 243 |
const id = (m.id || m.slug || m.model || m.name || `model-${idx}`).toString();
|
| 244 |
const name = (m.name || id).toString();
|
| 245 |
const free = !!m.free;
|
| 246 |
-
|
|
|
|
|
|
|
| 247 |
});
|
| 248 |
return res.json({ source: 'local', models: normalized });
|
| 249 |
} catch (err) {
|
|
@@ -282,8 +312,12 @@ app.post('/api/generate', async (req, res) => {
|
|
| 282 |
negative_prompt: (body.negative_prompt ?? (body.input_args ? body.input_args.negative_prompt : undefined) ?? '').toString(),
|
| 283 |
width: clamp(parseInt(body.width ?? (body.input_args ? body.input_args.width : 1024), 10) || 1024, 128, 2048),
|
| 284 |
height: clamp(parseInt(body.height ?? (body.input_args ? body.input_args.height : 1024), 10) || 1024, 128, 2048),
|
| 285 |
-
guidance_scale: clamp(
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
// Seed: support top-level or input_args.seed; if missing/null -> null (omit from payload)
|
| 288 |
seed: (() => {
|
| 289 |
const raw = (body.seed ?? (body.input_args ? body.input_args.seed : undefined));
|
|
@@ -330,9 +364,17 @@ app.post('/api/generate', async (req, res) => {
|
|
| 330 |
return null;
|
| 331 |
}
|
| 332 |
const cfg = getModelConfig(localList, flat.model);
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
|
| 338 |
const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN;
|
|
@@ -342,29 +384,130 @@ app.post('/api/generate', async (req, res) => {
|
|
| 342 |
...(apiToken ? { 'Authorization': `Bearer ${apiToken}` } : {})
|
| 343 |
};
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
const variantA = {
|
| 346 |
model: targetModel,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
input_args: {
|
| 348 |
prompt: flat.prompt,
|
| 349 |
-
negative_prompt: flat.negative_prompt || '',
|
| 350 |
-
width: flat.width,
|
| 351 |
height: flat.height,
|
| 352 |
-
|
| 353 |
num_inference_steps: flat.num_inference_steps,
|
|
|
|
|
|
|
|
|
|
| 354 |
...(flat.seed !== null ? { seed: flat.seed } : {})
|
| 355 |
}
|
| 356 |
-
};
|
| 357 |
|
| 358 |
-
|
| 359 |
-
|
| 360 |
prompt: flat.prompt,
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
| 368 |
|
| 369 |
// Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility
|
| 370 |
const variantCMinimal = {
|
|
@@ -377,6 +520,27 @@ app.post('/api/generate', async (req, res) => {
|
|
| 377 |
}
|
| 378 |
};
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
// duplicate removed
|
| 381 |
|
| 382 |
async function tryCall(payload, label, url) {
|
|
@@ -390,14 +554,16 @@ app.post('/api/generate', async (req, res) => {
|
|
| 390 |
return { ok: true, imageBase64: base64, contentType: ctype, tried: label };
|
| 391 |
}
|
| 392 |
|
| 393 |
-
// Success: JSON
|
| 394 |
if (status >= 200 && status < 300 && /application\/json/i.test(ctype)) {
|
| 395 |
let raw = '';
|
| 396 |
try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
|
| 397 |
try {
|
| 398 |
const j = JSON.parse(raw || '{}');
|
|
|
|
| 399 |
if (j && j.image) {
|
| 400 |
if (typeof j.image === 'string' && j.image.startsWith('data:')) {
|
|
|
|
| 401 |
const match = /^data:([^;]+);base64,(.*)$/i.exec(j.image);
|
| 402 |
if (match) {
|
| 403 |
return { ok: true, imageBase64: match[2], contentType: match[1], tried: label };
|
|
@@ -412,13 +578,17 @@ app.post('/api/generate', async (req, res) => {
|
|
| 412 |
return { ok: true, imageBase64: j.data, contentType: ct, tried: label };
|
| 413 |
}
|
| 414 |
} catch (e) {
|
| 415 |
-
// fallthrough
|
| 416 |
}
|
| 417 |
}
|
| 418 |
|
| 419 |
-
// Error
|
| 420 |
let raw = '';
|
| 421 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
let code = 'UPSTREAM_ERROR';
|
| 423 |
let hint = '';
|
| 424 |
let mappedStatus = status;
|
|
@@ -429,11 +599,12 @@ app.post('/api/generate', async (req, res) => {
|
|
| 429 |
} catch (e) {
|
| 430 |
detailText = raw;
|
| 431 |
}
|
|
|
|
| 432 |
const lower = (detailText || '').toLowerCase();
|
| 433 |
if (lower.includes('exhausted all available targets')) {
|
| 434 |
code = 'UPSTREAM_CAPACITY_EXHAUSTED';
|
| 435 |
hint = '上游容量不足(GPU/目标不可用或排队中),请稍后重试、换模型,或降低分辨率/步数。';
|
| 436 |
-
mappedStatus = 503;
|
| 437 |
} else if (status === 404 && lower.includes('model not found')) {
|
| 438 |
code = 'UPSTREAM_MODEL_NOT_FOUND';
|
| 439 |
hint = '上游模型不存在或标识不匹配。请更换模型,或在 data/models.json 为该模型添加正确的 \"upstream_id\" 映射后重试。';
|
|
@@ -451,23 +622,72 @@ app.post('/api/generate', async (req, res) => {
|
|
| 451 |
|
| 452 |
let result;
|
| 453 |
|
| 454 |
-
// If
|
|
|
|
|
|
|
| 455 |
if (preferMinimal) {
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
}
|
| 460 |
|
| 461 |
-
try
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
function chooseFallback(currentId, list) {
|
| 472 |
const key = (currentId || '').toLowerCase();
|
| 473 |
const free = list.filter(m => m.free && (m.id || m.name || '').toLowerCase() !== key);
|
|
@@ -480,15 +700,13 @@ app.post('/api/generate', async (req, res) => {
|
|
| 480 |
if (fallbackModel && fallbackModel !== targetModel) {
|
| 481 |
const fbA = { ...variantA, model: fallbackModel };
|
| 482 |
const fbB = { ...variantB, model: fallbackModel };
|
| 483 |
-
// Choose URL for fallback model
|
| 484 |
const fbCfg = getModelConfig(localList, fallbackModel);
|
| 485 |
const fbUrl = (fbCfg && fbCfg.upstream_url) ? fbCfg.upstream_url : GENERATE_API_URL;
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
try {
|
| 490 |
-
|
| 491 |
-
} catch (e4) {
|
| 492 |
const status = e4.status || 502;
|
| 493 |
return res.status(status).json({
|
| 494 |
ok: false,
|
|
@@ -499,6 +717,7 @@ app.post('/api/generate', async (req, res) => {
|
|
| 499 |
});
|
| 500 |
}
|
| 501 |
}
|
|
|
|
| 502 |
// success with fallback
|
| 503 |
return res.json({
|
| 504 |
ok: true,
|
|
@@ -519,11 +738,12 @@ app.post('/api/generate', async (req, res) => {
|
|
| 519 |
});
|
| 520 |
}
|
| 521 |
}
|
| 522 |
-
|
|
|
|
| 523 |
return res.status(status).json({
|
| 524 |
ok: false,
|
| 525 |
-
error:
|
| 526 |
-
code:
|
| 527 |
upstream_model: targetModel
|
| 528 |
});
|
| 529 |
}
|
|
|
|
| 36 |
// Strict switches to close any possibility of routing/fallback
|
| 37 |
// - STRICT_NO_ROUTING=true 不做本地映射,除非前端或调用方显式传 upstream_id
|
| 38 |
// - STRICT_NO_FALLBACK=true 强制禁用回落(即使前端未设置 no_fallback)
|
| 39 |
+
const STRICT_NO_ROUTING = /^true$/i.test(process.env.STRICT_NO_ROUTING || 'false');
|
| 40 |
const STRICT_NO_FALLBACK = /^true$/i.test(process.env.STRICT_NO_FALLBACK || 'true');
|
| 41 |
// Auto fallback to another model when upstream capacity/infrastructure errors
|
| 42 |
const AUTO_FALLBACK = /^true$/i.test(process.env.AUTO_FALLBACK || 'true');
|
|
|
|
| 52 |
app.use(helmet({
|
| 53 |
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
| 54 |
// Allow embedding on Hugging Face Spaces (disable X-Frame-Options)
|
| 55 |
+
frameguard: false,
|
| 56 |
+
// Avoid COOP/COEP blocking when embedded in an iframe
|
| 57 |
+
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
| 58 |
+
crossOriginEmbedderPolicy: false,
|
| 59 |
+
originAgentCluster: false
|
| 60 |
}));
|
| 61 |
|
| 62 |
// Enable CSP and allow inline script/style for this SPA.
|
| 63 |
// Also allow connections to the upstream image API.
|
| 64 |
+
// Allow embedding inside Hugging Face Spaces iframe via frame-ancestors/frame-src
|
| 65 |
app.use(helmet.contentSecurityPolicy({
|
| 66 |
useDefaults: true,
|
| 67 |
directives: {
|
| 68 |
"default-src": ["'self'"],
|
| 69 |
+
// Allow SPA inline scripts but forbid inline event attributes per CSP3
|
| 70 |
"script-src": ["'self'", "'unsafe-inline'"],
|
| 71 |
+
"script-src-attr": ["'none'"],
|
| 72 |
+
// Permit external stylesheet from Baomitu CDN via style-src-elem
|
| 73 |
"style-src": ["'self'", "'unsafe-inline'"],
|
| 74 |
+
"style-src-elem": ["'self'", "'unsafe-inline'", "https://lib.baomitu.com"],
|
| 75 |
"img-src": ["'self'", "data:", "blob:"],
|
| 76 |
+
// Allow Baomitu icon fonts
|
| 77 |
+
"font-src": ["'self'", "data:", "https://lib.baomitu.com"],
|
| 78 |
+
// Allow CSS sourcemap requests to Baomitu CDN
|
| 79 |
+
"connect-src": ["'self'", "https://image.chutes.ai", "https://lib.baomitu.com"],
|
| 80 |
"media-src": ["'self'", "data:", "blob:"],
|
| 81 |
+
"frame-src": ["'self'", "https://huggingface.co", "https://*.huggingface.co", "https://*.hf.space"],
|
| 82 |
+
"frame-ancestors": ["'self'", "https://huggingface.co", "https://*.huggingface.co", "https://*.hf.space"]
|
| 83 |
}
|
| 84 |
}));
|
| 85 |
|
|
|
|
| 91 |
|
| 92 |
app.use(cors());
|
| 93 |
app.use(compression());
|
| 94 |
+
app.use(express.json({ limit: '50mb' }));
|
| 95 |
+
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
| 96 |
app.use(morgan(LOG_LEVEL));
|
| 97 |
|
| 98 |
// Static files
|
|
|
|
| 230 |
const free = typeof m.free === 'boolean' ? m.free : false;
|
| 231 |
return { id, name, free };
|
| 232 |
});
|
| 233 |
+
// Merge free flags and default_params from local mapping
|
| 234 |
if (localList.length) {
|
| 235 |
+
// Merge free flags for models that exist remotely
|
| 236 |
models = mergeFreeFlags(models, localList);
|
| 237 |
+
// Also merge default_params from local config
|
| 238 |
+
const localMap = new Map();
|
| 239 |
+
for (const lm of localList) {
|
| 240 |
+
const key = ((lm.id || lm.name || '') + '').toLowerCase();
|
| 241 |
+
if (key) localMap.set(key, lm);
|
| 242 |
+
}
|
| 243 |
+
models = models.map(m => {
|
| 244 |
+
const key = ((m.id || m.name || '') + '').toLowerCase();
|
| 245 |
+
const local = localMap.get(key);
|
| 246 |
+
if (local && local.default_params) {
|
| 247 |
+
return { ...m, default_params: local.default_params };
|
| 248 |
+
}
|
| 249 |
+
return m;
|
| 250 |
+
});
|
| 251 |
+
// Also append local-only models (union), so new models in local config are visible in frontend
|
| 252 |
const remoteKeys = new Set(models.map(m => ((m.id || m.name || '') + '').toLowerCase()));
|
| 253 |
for (const lm of localList) {
|
| 254 |
const key = ((lm.id || lm.name || '') + '').toLowerCase();
|
|
|
|
| 256 |
const id = (lm.id || lm.name || '').toString();
|
| 257 |
const name = (lm.name || id).toString();
|
| 258 |
const free = !!lm.free;
|
| 259 |
+
const model = { id, name, free };
|
| 260 |
+
if (lm.default_params) model.default_params = lm.default_params;
|
| 261 |
+
models.push(model);
|
| 262 |
remoteKeys.add(key);
|
| 263 |
}
|
| 264 |
}
|
|
|
|
| 271 |
const id = (m.id || m.slug || m.model || m.name || `model-${idx}`).toString();
|
| 272 |
const name = (m.name || id).toString();
|
| 273 |
const free = !!m.free;
|
| 274 |
+
const model = { id, name, free };
|
| 275 |
+
if (m.default_params) model.default_params = m.default_params;
|
| 276 |
+
return model;
|
| 277 |
});
|
| 278 |
return res.json({ source: 'local', models: normalized });
|
| 279 |
} catch (err) {
|
|
|
|
| 312 |
negative_prompt: (body.negative_prompt ?? (body.input_args ? body.input_args.negative_prompt : undefined) ?? '').toString(),
|
| 313 |
width: clamp(parseInt(body.width ?? (body.input_args ? body.input_args.width : 1024), 10) || 1024, 128, 2048),
|
| 314 |
height: clamp(parseInt(body.height ?? (body.input_args ? body.input_args.height : 1024), 10) || 1024, 128, 2048),
|
| 315 |
+
guidance_scale: clamp((() => {
|
| 316 |
+
const raw = body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : undefined);
|
| 317 |
+
const parsed = parseFloat(raw);
|
| 318 |
+
return Number.isNaN(parsed) ? 7.5 : parsed;
|
| 319 |
+
})(), 0, 20),
|
| 320 |
+
num_inference_steps: clamp(parseInt(body.num_inference_steps ?? (body.input_args ? body.input_args.num_inference_steps : 25), 10) || 25, 1, 100),
|
| 321 |
// Seed: support top-level or input_args.seed; if missing/null -> null (omit from payload)
|
| 322 |
seed: (() => {
|
| 323 |
const raw = (body.seed ?? (body.input_args ? body.input_args.seed : undefined));
|
|
|
|
| 364 |
return null;
|
| 365 |
}
|
| 366 |
const cfg = getModelConfig(localList, flat.model);
|
| 367 |
+
let generateUrl = (cfg && cfg.upstream_url) ? cfg.upstream_url : GENERATE_API_URL;
|
| 368 |
+
let preferMinimal = FORCE_MINIMAL || !!(cfg && cfg.minimal === true);
|
| 369 |
+
const isHidream = (
|
| 370 |
+
typeof flat.model === 'string' && flat.model.toLowerCase() === 'hidream'
|
| 371 |
+
) || /hidream/i.test(generateUrl) || /hidream/i.test(String(targetModel || ''));
|
| 372 |
+
const isQwenEdit = (
|
| 373 |
+
typeof flat.model === 'string' && flat.model.toLowerCase() === 'qwen-image-edit'
|
| 374 |
+
) || /qwen-image-edit/i.test(generateUrl) || /qwen-image-edit/i.test(String(targetModel || ''));
|
| 375 |
+
const isZImageTurbo = (
|
| 376 |
+
typeof flat.model === 'string' && flat.model.toLowerCase() === 'z-image-turbo'
|
| 377 |
+
) || /z-image-turbo/i.test(generateUrl) || /z-image-turbo/i.test(String(targetModel || ''));
|
| 378 |
|
| 379 |
|
| 380 |
const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN;
|
|
|
|
| 384 |
...(apiToken ? { 'Authorization': `Bearer ${apiToken}` } : {})
|
| 385 |
};
|
| 386 |
|
| 387 |
+
// hidream: 优先使用 models.json 的 upstream_url;否则使用 HIDREAM_UPSTREAM_URL;否则保持默认 GENERATE_API_URL
|
| 388 |
+
if (isHidream) {
|
| 389 |
+
const envUrl = process.env.HIDREAM_UPSTREAM_URL || '';
|
| 390 |
+
function isValidUrl(u) {
|
| 391 |
+
try { new URL(u); return true; } catch { return false; }
|
| 392 |
+
}
|
| 393 |
+
if (cfg && cfg.upstream_url && isValidUrl(cfg.upstream_url)) {
|
| 394 |
+
generateUrl = cfg.upstream_url;
|
| 395 |
+
} else if (envUrl && isValidUrl(envUrl)) {
|
| 396 |
+
generateUrl = envUrl;
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// Normalize special-case upstream ids that differ from local display ids
|
| 401 |
+
// qwen-image-edit 在上游共用 qwen-image 的路由/标识
|
| 402 |
+
if (isQwenEdit && String(targetModel || '').toLowerCase() === 'qwen-image-edit') {
|
| 403 |
+
targetModel = 'qwen-image';
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// Pass-through extras (e.g., image_b64s, true_cfg_scale, resolution, shift, max_sequence_length, etc.)
|
| 407 |
+
const inputExtras = (body && typeof body.input_args === 'object') ? { ...body.input_args } : {};
|
| 408 |
+
if (isHidream) {
|
| 409 |
+
// hidream 仅接受 resolution;确保存在并移除 width/height 噪声
|
| 410 |
+
if (!inputExtras.resolution) {
|
| 411 |
+
inputExtras.resolution = `${flat.width}x${flat.height}`;
|
| 412 |
+
}
|
| 413 |
+
delete inputExtras.width;
|
| 414 |
+
delete inputExtras.height;
|
| 415 |
+
}
|
| 416 |
+
if (!isQwenEdit) {
|
| 417 |
+
// 非 qwen-image-edit 时不传参考图与 true_cfg_scale
|
| 418 |
+
delete inputExtras.image_b64s;
|
| 419 |
+
delete inputExtras.true_cfg_scale;
|
| 420 |
+
}
|
| 421 |
+
// Z-Image-Turbo 特殊参数处理
|
| 422 |
+
if (isZImageTurbo) {
|
| 423 |
+
// 保留 shift 和 max_sequence_length 参数
|
| 424 |
+
if (inputExtras.shift === undefined) {
|
| 425 |
+
inputExtras.shift = 3.0; // 默认值
|
| 426 |
+
}
|
| 427 |
+
if (inputExtras.max_sequence_length === undefined) {
|
| 428 |
+
inputExtras.max_sequence_length = 512; // 默认值
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
// Build top-level extras for flat payloads (some upstreams expect image_b64s/true_cfg_scale at top-level)
|
| 433 |
+
const topLevelExtras = {};
|
| 434 |
+
const maybeImageB64s = (body && (body.image_b64s ?? (body.input_args && body.input_args.image_b64s)));
|
| 435 |
+
const maybeTrueCfg = (body && (body.true_cfg_scale ?? (body.input_args && body.input_args.true_cfg_scale)));
|
| 436 |
+
if (isQwenEdit) {
|
| 437 |
+
if (maybeImageB64s) topLevelExtras.image_b64s = maybeImageB64s;
|
| 438 |
+
if (maybeTrueCfg !== undefined && maybeTrueCfg !== null) topLevelExtras.true_cfg_scale = maybeTrueCfg;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
// qwen-image-edit: 校验参考图数量(1-3)
|
| 442 |
+
if (isQwenEdit) {
|
| 443 |
+
const imgs = inputExtras.image_b64s || topLevelExtras.image_b64s;
|
| 444 |
+
const validCount = Array.isArray(imgs) ? imgs.length : 0;
|
| 445 |
+
if (validCount < 1 || validCount > 3) {
|
| 446 |
+
return res.status(400).json({ ok: false, error: 'qwen-image-edit 需要 1-3 张参考图 (image_b64s)' });
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
const commonArgs = {
|
| 451 |
+
prompt: flat.prompt,
|
| 452 |
+
negative_prompt: flat.negative_prompt || '',
|
| 453 |
+
guidance_scale: flat.guidance_scale,
|
| 454 |
+
num_inference_steps: flat.num_inference_steps,
|
| 455 |
+
...(flat.seed !== null ? { seed: flat.seed } : {})
|
| 456 |
+
};
|
| 457 |
const variantA = {
|
| 458 |
model: targetModel,
|
| 459 |
+
input_args: isHidream
|
| 460 |
+
? { ...inputExtras, ...commonArgs }
|
| 461 |
+
: { ...inputExtras, ...commonArgs, width: flat.width, height: flat.height }
|
| 462 |
+
};
|
| 463 |
+
|
| 464 |
+
const variantB = isHidream
|
| 465 |
+
? {
|
| 466 |
+
model: targetModel,
|
| 467 |
+
input_args: { ...inputExtras, ...commonArgs }
|
| 468 |
+
}
|
| 469 |
+
: {
|
| 470 |
+
model: targetModel,
|
| 471 |
+
...topLevelExtras,
|
| 472 |
+
...commonArgs,
|
| 473 |
+
width: flat.width,
|
| 474 |
+
height: flat.height
|
| 475 |
+
};
|
| 476 |
+
|
| 477 |
+
// Hidream-specific flat payload expected by chutes-hidream endpoint (no input_args)
|
| 478 |
+
const variantHidreamFlat = isHidream ? {
|
| 479 |
+
prompt: flat.prompt,
|
| 480 |
+
resolution: (inputExtras && inputExtras.resolution) ? String(inputExtras.resolution) : `${flat.width}x${flat.height}`,
|
| 481 |
+
guidance_scale: flat.guidance_scale,
|
| 482 |
+
num_inference_steps: flat.num_inference_steps,
|
| 483 |
+
...(flat.seed !== null ? { seed: flat.seed } : {})
|
| 484 |
+
} : null;
|
| 485 |
+
|
| 486 |
+
// Z-Image-Turbo payload with input_args wrapper (required by the endpoint)
|
| 487 |
+
const variantZImageNested = isZImageTurbo ? {
|
| 488 |
input_args: {
|
| 489 |
prompt: flat.prompt,
|
|
|
|
|
|
|
| 490 |
height: flat.height,
|
| 491 |
+
width: flat.width,
|
| 492 |
num_inference_steps: flat.num_inference_steps,
|
| 493 |
+
guidance_scale: flat.guidance_scale,
|
| 494 |
+
shift: (inputExtras && inputExtras.shift !== undefined) ? Number(inputExtras.shift) : 3.0,
|
| 495 |
+
max_sequence_length: (inputExtras && inputExtras.max_sequence_length !== undefined) ? Number(inputExtras.max_sequence_length) : 512,
|
| 496 |
...(flat.seed !== null ? { seed: flat.seed } : {})
|
| 497 |
}
|
| 498 |
+
} : null;
|
| 499 |
|
| 500 |
+
// Z-Image-Turbo minimal payload (top-level prompt only, as shown in curl example)
|
| 501 |
+
const variantZImageMinimal = isZImageTurbo ? {
|
| 502 |
prompt: flat.prompt,
|
| 503 |
+
height: flat.height,
|
| 504 |
+
width: flat.width,
|
| 505 |
+
num_inference_steps: flat.num_inference_steps,
|
| 506 |
+
guidance_scale: flat.guidance_scale,
|
| 507 |
+
shift: (inputExtras && inputExtras.shift !== undefined) ? Number(inputExtras.shift) : 3.0,
|
| 508 |
+
max_sequence_length: (inputExtras && inputExtras.max_sequence_length !== undefined) ? Number(inputExtras.max_sequence_length) : 512,
|
| 509 |
+
...(flat.seed !== null ? { seed: flat.seed } : {})
|
| 510 |
+
} : null;
|
| 511 |
|
| 512 |
// Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility
|
| 513 |
const variantCMinimal = {
|
|
|
|
| 520 |
}
|
| 521 |
};
|
| 522 |
|
| 523 |
+
// Flat minimal payload for model-specific upstreams that expect top-level { prompt }
|
| 524 |
+
const variantFlatMinimal = {
|
| 525 |
+
prompt: flat.prompt,
|
| 526 |
+
size: `${flat.width}x${flat.height}`,
|
| 527 |
+
steps: flat.num_inference_steps,
|
| 528 |
+
...(flat.seed !== null ? { seed: flat.seed } : {})
|
| 529 |
+
};
|
| 530 |
+
|
| 531 |
+
// Qwen-image-edit: official top-level flat payload (no model, includes refs)
|
| 532 |
+
const variantQwenFlat = isQwenEdit ? {
|
| 533 |
+
prompt: flat.prompt,
|
| 534 |
+
negative_prompt: flat.negative_prompt || '',
|
| 535 |
+
width: flat.width,
|
| 536 |
+
height: flat.height,
|
| 537 |
+
guidance_scale: flat.guidance_scale,
|
| 538 |
+
num_inference_steps: flat.num_inference_steps,
|
| 539 |
+
...(flat.seed !== null ? { seed: flat.seed } : {}),
|
| 540 |
+
...(inputExtras && inputExtras.image_b64s ? { image_b64s: inputExtras.image_b64s } : (topLevelExtras.image_b64s ? { image_b64s: topLevelExtras.image_b64s } : {})),
|
| 541 |
+
...(inputExtras && (inputExtras.true_cfg_scale !== undefined && inputExtras.true_cfg_scale !== null) ? { true_cfg_scale: inputExtras.true_cfg_scale } : (topLevelExtras.true_cfg_scale !== undefined ? { true_cfg_scale: topLevelExtras.true_cfg_scale } : {}))
|
| 542 |
+
} : null;
|
| 543 |
+
|
| 544 |
// duplicate removed
|
| 545 |
|
| 546 |
async function tryCall(payload, label, url) {
|
|
|
|
| 554 |
return { ok: true, imageBase64: base64, contentType: ctype, tried: label };
|
| 555 |
}
|
| 556 |
|
| 557 |
+
// Success: JSON response that may contain base64 or data URL
|
| 558 |
if (status >= 200 && status < 300 && /application\/json/i.test(ctype)) {
|
| 559 |
let raw = '';
|
| 560 |
try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
|
| 561 |
try {
|
| 562 |
const j = JSON.parse(raw || '{}');
|
| 563 |
+
// Common patterns: { image: "data:..."} or { image: "<base64>", contentType: "image/jpeg" } or { data: "<base64>" }
|
| 564 |
if (j && j.image) {
|
| 565 |
if (typeof j.image === 'string' && j.image.startsWith('data:')) {
|
| 566 |
+
// Already data URL; extract base64 and contentType
|
| 567 |
const match = /^data:([^;]+);base64,(.*)$/i.exec(j.image);
|
| 568 |
if (match) {
|
| 569 |
return { ok: true, imageBase64: match[2], contentType: match[1], tried: label };
|
|
|
|
| 578 |
return { ok: true, imageBase64: j.data, contentType: ct, tried: label };
|
| 579 |
}
|
| 580 |
} catch (e) {
|
| 581 |
+
// fallthrough to error mapping below
|
| 582 |
}
|
| 583 |
}
|
| 584 |
|
| 585 |
+
// Error handling branch (non-2xx or unrecognized payload)
|
| 586 |
let raw = '';
|
| 587 |
+
try {
|
| 588 |
+
raw = Buffer.from(resp.data).toString();
|
| 589 |
+
} catch (e) {}
|
| 590 |
+
|
| 591 |
+
// Friendly diagnostics mapping
|
| 592 |
let code = 'UPSTREAM_ERROR';
|
| 593 |
let hint = '';
|
| 594 |
let mappedStatus = status;
|
|
|
|
| 599 |
} catch (e) {
|
| 600 |
detailText = raw;
|
| 601 |
}
|
| 602 |
+
|
| 603 |
const lower = (detailText || '').toLowerCase();
|
| 604 |
if (lower.includes('exhausted all available targets')) {
|
| 605 |
code = 'UPSTREAM_CAPACITY_EXHAUSTED';
|
| 606 |
hint = '上游容量不足(GPU/目标不可用或排队中),请稍后重试、换模型,或降低分辨率/步数。';
|
| 607 |
+
mappedStatus = 503; // service unavailable
|
| 608 |
} else if (status === 404 && lower.includes('model not found')) {
|
| 609 |
code = 'UPSTREAM_MODEL_NOT_FOUND';
|
| 610 |
hint = '上游模型不存在或标识不匹配。请更换模型,或在 data/models.json 为该模型添加正确的 \"upstream_id\" 映射后重试。';
|
|
|
|
| 622 |
|
| 623 |
let result;
|
| 624 |
|
| 625 |
+
// If the model prefers minimal payload, choose ordering per model.
|
| 626 |
+
// For hunyuan-image-3: nested-minimal (input_args with size) FIRST to ensure size is honored.
|
| 627 |
+
// For Z-Image-Turbo: use dedicated nested payload FIRST
|
| 628 |
if (preferMinimal) {
|
| 629 |
+
const isHunyuan =
|
| 630 |
+
(typeof flat.model === 'string' && flat.model.toLowerCase() === 'hunyuan-image-3') ||
|
| 631 |
+
/hunyuan-image-3/i.test(generateUrl) ||
|
| 632 |
+
/hunyuan-image-3/i.test(String(targetModel || ''));
|
| 633 |
+
|
| 634 |
+
if (isZImageTurbo) {
|
| 635 |
+
// Z-Image-Turbo: try minimal payload first (as shown in curl example)
|
| 636 |
+
try {
|
| 637 |
+
result = await tryCall(variantZImageMinimal, 'z-image-turbo-minimal', generateUrl);
|
| 638 |
+
} catch (e0) {}
|
| 639 |
+
} else if (isHunyuan) {
|
| 640 |
+
try {
|
| 641 |
+
result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl);
|
| 642 |
+
} catch (e0) {}
|
| 643 |
+
} else {
|
| 644 |
+
try {
|
| 645 |
+
result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl);
|
| 646 |
+
} catch (e0) {}
|
| 647 |
+
if (!result) {
|
| 648 |
+
try {
|
| 649 |
+
result = await tryCall(variantCMinimal, 'nested-minimal', generateUrl);
|
| 650 |
+
} catch (e1) {}
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
}
|
| 654 |
|
| 655 |
+
// Only try full payload if minimal didn't succeed
|
| 656 |
+
if (!result) {
|
| 657 |
+
let lastError = null;
|
| 658 |
+
|
| 659 |
+
if (!result) {
|
| 660 |
+
try { result = await tryCall(variantA, 'nested', generateUrl); }
|
| 661 |
+
catch (e1) { lastError = e1; }
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
if (!result) {
|
| 665 |
+
try { result = await tryCall(variantB, 'flat', generateUrl); }
|
| 666 |
+
catch (e2) { lastError = e2; }
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
// qwen-image-edit official flat payload (no model)
|
| 670 |
+
if (!result && isQwenEdit && variantQwenFlat) {
|
| 671 |
+
try { result = await tryCall(variantQwenFlat, 'qwen-flat', generateUrl); }
|
| 672 |
+
catch (eQ) { lastError = eQ; }
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
// hidream official flat payload (no model)
|
| 676 |
+
if (!result && isHidream && variantHidreamFlat) {
|
| 677 |
+
try { result = await tryCall(variantHidreamFlat, 'hidream-flat', generateUrl); }
|
| 678 |
+
catch (eH) { lastError = eH; }
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
// Z-Image-Turbo nested payload - fallback if not tried in minimal mode
|
| 682 |
+
if (!result && isZImageTurbo && variantZImageNested && !preferMinimal) {
|
| 683 |
+
try { result = await tryCall(variantZImageNested, 'z-image-turbo-nested', generateUrl); }
|
| 684 |
+
catch (eZ) { lastError = eZ; }
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
if (!result) {
|
| 688 |
+
// Auto fallback to another model when capacity/infrastructure errors (disabled when NO_FALLBACK=true)
|
| 689 |
+
const capacityCodes = ['UPSTREAM_CAPACITY_EXHAUSTED','UPSTREAM_NO_INSTANCES','UPSTREAM_INFRASTRUCTURE','UPSTREAM_BAD_GATEWAY'];
|
| 690 |
+
if (AUTO_FALLBACK && !NO_FALLBACK && lastError && capacityCodes.includes(lastError.code || '')) {
|
| 691 |
function chooseFallback(currentId, list) {
|
| 692 |
const key = (currentId || '').toLowerCase();
|
| 693 |
const free = list.filter(m => m.free && (m.id || m.name || '').toLowerCase() !== key);
|
|
|
|
| 700 |
if (fallbackModel && fallbackModel !== targetModel) {
|
| 701 |
const fbA = { ...variantA, model: fallbackModel };
|
| 702 |
const fbB = { ...variantB, model: fallbackModel };
|
|
|
|
| 703 |
const fbCfg = getModelConfig(localList, fallbackModel);
|
| 704 |
const fbUrl = (fbCfg && fbCfg.upstream_url) ? fbCfg.upstream_url : GENERATE_API_URL;
|
| 705 |
+
|
| 706 |
+
try { result = await tryCall(fbA, 'nested-fallback', fbUrl); }
|
| 707 |
+
catch (e3) {
|
| 708 |
+
try { result = await tryCall(fbB, 'flat-fallback', fbUrl); }
|
| 709 |
+
catch (e4) {
|
|
|
|
| 710 |
const status = e4.status || 502;
|
| 711 |
return res.status(status).json({
|
| 712 |
ok: false,
|
|
|
|
| 717 |
});
|
| 718 |
}
|
| 719 |
}
|
| 720 |
+
|
| 721 |
// success with fallback
|
| 722 |
return res.json({
|
| 723 |
ok: true,
|
|
|
|
| 738 |
});
|
| 739 |
}
|
| 740 |
}
|
| 741 |
+
|
| 742 |
+
const status = (lastError && lastError.status) || 502;
|
| 743 |
return res.status(status).json({
|
| 744 |
ok: false,
|
| 745 |
+
error: (lastError && (lastError.hint || lastError.message)) || 'Upstream error',
|
| 746 |
+
code: (lastError && lastError.code) || 'UPSTREAM_ERROR',
|
| 747 |
upstream_model: targetModel
|
| 748 |
});
|
| 749 |
}
|