bibibi12345 commited on
Commit
1847d87
·
2 Parent(s): 354cde79e42bad

Merge branch 'main' of https://huggingface.co/spaces/Logankunfall/chutes into pr/1

Browse files
Files changed (3) hide show
  1. data/models.json +16 -19
  2. public/index.html +454 -70
  3. server.js +321 -106
data/models.json CHANGED
@@ -1,29 +1,26 @@
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 },
7
- { "id": "chroma", "name": "chroma", "free": true },
8
- { "id": "neta-lumina", "name": "neta-lumina", "free": true },
9
- { "id": "nova-anime3d-xl", "name": "nova-anime3d-xl", "free": false },
10
- { "id": "Illustrij", "name": "Illustrij", "free": false },
11
- { "id": "stabilityai/stable-diffusion-xl-base-1.0", "name": "stable-diffusion-xl-base-1.0", "free": false },
12
- { "id": "iLustMix", "name": "iLustMix", "free": false },
13
- { "id": "Animij", "name": "Animij", "free": false },
14
- { "id": "NovaFurryXL", "name": "NovaFurryXL", "free": false },
15
- { "id": "Lykon/dreamshaper-xl-1-0", "name": "dreamshaper-xl-1-0", "free": false },
16
- { "id": "Shitao/OmniGen-v1", "name": "OmniGen-v1", "free": false },
17
- { "id": "diagonalge/Booba", "name": "Booba", "free": false },
18
- { "id": "HassakuXL", "name": "HassakuXL", "free": false },
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": "qwen-image", "name": "qwen-image", "free": false },
3
+ { "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" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  {
5
  "id": "hunyuan-image-3",
6
  "name": "hunyuan-image-3",
7
  "free": false,
8
  "minimal": true,
9
  "upstream_url": "https://chutes-hunyuan-image-3.chutes.ai/generate"
10
+ },
11
+ { "id": "novafurry/NovaFurryXL", "name": "NovaFurryXL", "free": false },
12
+ { "id": "hidream", "name": "hidream", "free": false, "upstream_url": "https://chutes-hidream.chutes.ai/generate" },
13
+ {
14
+ "id": "z-image-turbo",
15
+ "name": "Z-Image-Turbo",
16
+ "free": false,
17
+ "minimal": true,
18
+ "upstream_url": "https://chutes-z-image-turbo.chutes.ai/generate",
19
+ "default_params": {
20
+ "guidance_scale": 0.0,
21
+ "num_inference_steps": 9,
22
+ "shift": 3.0,
23
+ "max_sequence_length": 512
24
+ }
25
  }
26
  ]
public/index.html CHANGED
@@ -341,7 +341,14 @@
341
  width: 100%;
342
  height: auto;
343
  display: block;
344
- object-fit: contain;
 
 
 
 
 
 
 
345
  }
346
 
347
  .card .meta {
@@ -627,58 +634,119 @@
627
  width: 100%;
628
  }
629
  }
630
-
631
- /* Image Viewer (Lightbox) */
632
- .image-viewer-overlay {
633
- position: fixed;
634
- inset: 0;
635
- background: rgba(0, 0, 0, 0.7);
636
- display: none;
637
- align-items: center;
638
- justify-content: center;
639
- z-index: 1000;
640
- }
641
- .image-viewer-overlay.show { display: flex; }
642
- .image-viewer-content {
643
- position: relative;
644
- max-width: 90vw;
645
- max-height: 90vh;
646
- }
647
- .image-viewer-content img {
648
- max-width: 90vw;
649
- max-height: 90vh;
650
- width: auto;
651
- height: auto;
652
- object-fit: contain;
653
- box-shadow: var(--shadow-lg);
654
- border-radius: 8px;
655
- }
656
- .image-viewer-close {
657
- position: absolute;
658
- right: 8px;
659
- top: 8px;
660
- background: var(--bg);
661
- color: var(--fg);
662
- border: 1px solid var(--border);
663
- border-radius: 6px;
664
- padding: 6px 10px;
665
- cursor: pointer;
666
- min-height: auto;
667
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  </style>
669
  </head>
670
  <body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  <header>
672
  <button class="mobile-menu-toggle" id="mobileMenuToggle">☰</button>
673
  <h1>Chutes</h1>
674
  <div class="controls">
675
  <div class="toggle-group">
676
- <button id="soundToggle" class="toggle-btn">提示音</button>
 
 
 
 
677
  <button id="themeToggle" class="toggle-btn">主题</button>
678
  </div>
679
  <div class="api-input">
680
- <span>Key</span>
681
- <input type="password" id="apiKeyInput" placeholder="API Token">
682
  </div>
683
  </div>
684
  </header>
@@ -693,13 +761,18 @@
693
  <div class="sidebar-content">
694
  <div class="group">
695
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
696
- <button id="soundToggleMobile" class="toggle-btn">提示音</button>
 
 
 
 
 
697
  <button id="themeToggleMobile" class="toggle-btn">主题</button>
698
  </div>
699
  </div>
700
  <div class="group">
701
- <label>API Key</label>
702
- <input type="password" id="apiKeyInputMobile" placeholder="粘贴 Token" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font-size: 14px;">
703
  </div>
704
  <div class="group">
705
  <label>模型</label>
@@ -709,16 +782,47 @@
709
  <label>提示词</label>
710
  <textarea id="promptMobile" rows="3" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
711
  </div>
 
 
 
 
 
 
 
 
 
712
  <div class="group">
713
  <label>反向提示词</label>
714
  <textarea id="negative_promptMobile" rows="2" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
715
  </div>
716
- <div class="group form-grid">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  <div class="group">
 
 
 
 
 
 
718
  <label>宽度</label>
719
  <input type="number" id="widthMobile" value="1024">
720
  </div>
721
- <div class="group">
722
  <label>高度</label>
723
  <input type="number" id="heightMobile" value="1024">
724
  </div>
@@ -750,7 +854,11 @@
750
  </div>
751
 
752
  <!-- Mobile Generate Button -->
753
- <button class="mobile-generate-btn" id="mobileGenerateBtn">✨</button>
 
 
 
 
754
 
755
  <main>
756
  <section class="panel" id="desktopPanel">
@@ -765,16 +873,47 @@
765
  <label>提示词</label>
766
  <textarea id="prompt" rows="2" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
767
  </div>
 
 
 
 
 
 
 
 
 
768
  <div class="group">
769
  <label>反向提示词</label>
770
  <textarea id="negative_prompt" rows="1" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
771
  </div>
772
- <div class="group form-grid">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
  <div class="group">
 
 
 
 
 
 
774
  <label>宽度</label>
775
  <input type="number" id="width" value="1024">
776
  </div>
777
- <div class="group">
778
  <label>高度</label>
779
  <input type="number" id="height" value="1024">
780
  </div>
@@ -810,6 +949,16 @@
810
  <div id="gallery" class="gallery"></div>
811
  </section>
812
  </main>
 
 
 
 
 
 
 
 
 
 
813
  <audio id="notify" src="/studio/new-notification-3-398649.mp3" preload="auto"></audio>
814
  <script>
815
  const qs = s => document.querySelector(s);
@@ -821,13 +970,15 @@
821
 
822
  const state = {
823
  models: [],
 
824
  sound: true,
825
  theme: 'light',
826
  apiKey: '',
827
  folderHandle: null,
828
  seedRandom: true,
829
  isMobile: window.innerWidth <= 768,
830
- sidebarOpen: false
 
831
  };
832
 
833
  function setTheme(t) {
@@ -859,14 +1010,24 @@
859
  const btn = qs('#soundToggle');
860
  if (btn) {
861
  btn.classList.toggle('active', state.sound);
862
- btn.textContent = state.sound ? '🔔' : '🔕';
 
 
 
863
  }
864
 
865
  // 更新移动端按钮
866
  const btnMobile = qs('#soundToggleMobile');
867
  if (btnMobile) {
868
  btnMobile.classList.toggle('active', state.sound);
869
- btnMobile.textContent = state.sound ? '🔔 开启' : '🔕 关闭';
 
 
 
 
 
 
 
870
  }
871
  }
872
 
@@ -875,6 +1036,8 @@
875
  ls.set('apiKey', state.apiKey);
876
  const el = qs('#apiKeyInput');
877
  if (el && el.value !== state.apiKey) el.value = state.apiKey;
 
 
878
  }
879
 
880
  function updateSeedToggle() {
@@ -898,9 +1061,12 @@
898
  headers: state.apiKey ? { 'x-api-key': state.apiKey } : {}
899
  });
900
  const j = await r.json();
 
 
901
  state.models = (j.models || []).map(m => ({
902
  id: String(m.id || m.name || ''),
903
- name: String(m.name || m.id || '')
 
904
  }));
905
  renderModels();
906
  } catch(e) {
@@ -924,21 +1090,103 @@
924
  if (last && last.model) sel.value = last.model;
925
  }
926
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  }
928
 
929
  function currentParams() {
930
  const isMobile = window.innerWidth <= 768;
931
  const prefix = isMobile ? 'Mobile' : '';
932
- return {
933
- model: qs(`#modelSelect${prefix}`).value || '',
 
 
 
934
  prompt: (qs(`#prompt${prefix}`).value || '').trim(),
935
  negative_prompt: (qs(`#negative_prompt${prefix}`).value || '').trim(),
936
  width: Number(qs(`#width${prefix}`).value) || 1024,
937
  height: Number(qs(`#height${prefix}`).value) || 1024,
938
  guidance_scale: Number(qs(`#guidance_scale${prefix}`).value) || 6,
939
  num_inference_steps: Number(qs(`#num_inference_steps${prefix}`).value) || 20,
940
- seed: state.seedRandom ? null : 0
 
 
 
941
  };
 
 
 
 
 
 
 
 
 
 
 
 
942
  }
943
 
944
  function createPlaceholderCard(i, params) {
@@ -961,11 +1209,10 @@
961
  const img = document.createElement('img');
962
  img.src = dataUrl;
963
  img.alt = params.prompt || 'image';
964
- img.style.cursor = 'zoom-in';
965
- img.addEventListener('click', function(e) {
966
- e.preventDefault();
967
- e.stopPropagation();
968
- openImageViewer(dataUrl, params);
969
  });
970
 
971
  const meta = document.createElement('div');
@@ -1188,6 +1435,8 @@ function isViewerOpen() {
1188
  const rect = btn.getBoundingClientRect();
1189
  dragState.initialX = rect.left;
1190
  dragState.initialY = rect.top;
 
 
1191
 
1192
  e.preventDefault();
1193
  }
@@ -1225,14 +1474,17 @@ function isViewerOpen() {
1225
  }
1226
 
1227
  function endDrag(e) {
1228
- if (!dragState.isDragging) return;
1229
-
1230
  const btn = qs('#mobileGenerateBtn');
1231
  if (!btn) return;
1232
 
 
 
 
1233
  dragState.isDragging = false;
1234
  btn.classList.remove('dragging');
1235
 
 
 
1236
  // 如果移动距离很小,视为点击
1237
  const moveDistance = Math.sqrt(
1238
  Math.pow(dragState.currentX - dragState.initialX, 2) +
@@ -1241,13 +1493,13 @@ function isViewerOpen() {
1241
 
1242
  if (moveDistance < 10) {
1243
  // 触发生成
1244
- generate();
1245
  }
1246
 
1247
  // 保存位置
1248
  ls.set('generateBtnPosition', {
1249
- x: dragState.currentX,
1250
- y: dragState.currentY
1251
  });
1252
  }
1253
 
@@ -1331,6 +1583,43 @@ function isViewerOpen() {
1331
  return new Blob([u8], { type: mime });
1332
  }
1333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1334
  async function saveToChosenFolder(filename, dataUrl) {
1335
  if (!state.folderHandle) return false;
1336
  try {
@@ -1474,6 +1763,11 @@ function isViewerOpen() {
1474
  alert('模型与提示词必填');
1475
  return;
1476
  }
 
 
 
 
 
1477
 
1478
  const isMobile = window.innerWidth <= 768;
1479
  const batchElement = qs(isMobile ? '#batchCountMobile' : '#batchCount');
@@ -1488,15 +1782,35 @@ function isViewerOpen() {
1488
  const tasks = [];
1489
  for (let i = 1; i <= count; i++) {
1490
  const placeholder = createPlaceholderCard(i, p);
1491
- const args = {
 
1492
  prompt: p.prompt,
1493
  negative_prompt: p.negative_prompt || '',
1494
- width: p.width,
1495
- height: p.height,
1496
  guidance_scale: p.guidance_scale,
1497
  num_inference_steps: p.num_inference_steps,
1498
  seed: state.seedRandom ? genRandomSeed() : 0
1499
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1500
  const body = { model: p.model, input_args: args };
1501
 
1502
  const task = (async () => {
@@ -1604,6 +1918,12 @@ function isViewerOpen() {
1604
 
1605
  qs('#chooseFolder').addEventListener('click', chooseFolder);
1606
  qs('#generateBtn').addEventListener('click', generate);
 
 
 
 
 
 
1607
 
1608
  // 移动端事件绑定
1609
  qs('#mobileMenuToggle').addEventListener('click', toggleSidebar);
@@ -1622,6 +1942,12 @@ function isViewerOpen() {
1622
  generate();
1623
  closeSidebar();
1624
  });
 
 
 
 
 
 
1625
 
1626
  // 初始化拖拽功能
1627
  initDragButton();
@@ -1694,7 +2020,65 @@ function isViewerOpen() {
1694
  fetchModels();
1695
  }
1696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1697
  init();
 
1698
  </script>
1699
  </body>
1700
  </html>
 
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) || 6,
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
+ // 添加 Z-Image-Turbo 特定参数(从 UI 控件读取)
1180
+ const shiftEl = qs(`#shift${prefix}`);
1181
+ const maxSeqEl = qs(`#max_sequence_length${prefix}`);
1182
+ if (shiftEl && shiftEl.offsetParent !== null) {
1183
+ params.shift = Number(shiftEl.value) || 3.0;
1184
+ }
1185
+ if (maxSeqEl && maxSeqEl.offsetParent !== null) {
1186
+ params.max_sequence_length = Number(maxSeqEl.value) || 512;
1187
+ }
1188
+
1189
+ return params;
1190
  }
1191
 
1192
  function createPlaceholderCard(i, params) {
 
1209
  const img = document.createElement('img');
1210
  img.src = dataUrl;
1211
  img.alt = params.prompt || 'image';
1212
+
1213
+ // 添加点击放大功能
1214
+ img.addEventListener('click', () => {
1215
+ showImageModal(dataUrl, params);
 
1216
  });
1217
 
1218
  const meta = document.createElement('div');
 
1435
  const rect = btn.getBoundingClientRect();
1436
  dragState.initialX = rect.left;
1437
  dragState.initialY = rect.top;
1438
+ dragState.currentX = rect.left;
1439
+ dragState.currentY = rect.top;
1440
 
1441
  e.preventDefault();
1442
  }
 
1474
  }
1475
 
1476
  function endDrag(e) {
 
 
1477
  const btn = qs('#mobileGenerateBtn');
1478
  if (!btn) return;
1479
 
1480
+ const wasDragging = dragState.isDragging;
1481
+
1482
+ // 立即清除拖拽状态
1483
  dragState.isDragging = false;
1484
  btn.classList.remove('dragging');
1485
 
1486
+ if (!wasDragging) return;
1487
+
1488
  // 如果移动距离很小,视为点击
1489
  const moveDistance = Math.sqrt(
1490
  Math.pow(dragState.currentX - dragState.initialX, 2) +
 
1493
 
1494
  if (moveDistance < 10) {
1495
  // 触发生成
1496
+ setTimeout(() => generate(), 50);
1497
  }
1498
 
1499
  // 保存位置
1500
  ls.set('generateBtnPosition', {
1501
+ x: dragState.currentX || dragState.initialX,
1502
+ y: dragState.currentY || dragState.initialY
1503
  });
1504
  }
1505
 
 
1583
  return new Blob([u8], { type: mime });
1584
  }
1585
 
1586
+ // 将文件读取为去掉 data: 前缀的纯 base64,限制最多3张
1587
+ async function filesToBase64Raw(fileList) {
1588
+ const files = Array.from(fileList).slice(0, 3);
1589
+ const arr = [];
1590
+ for (const f of files) {
1591
+ const b64 = await new Promise((resolve, reject) => {
1592
+ const reader = new FileReader();
1593
+ reader.onload = () => {
1594
+ const s = String(reader.result || '');
1595
+ const idx = s.indexOf(',');
1596
+ resolve(idx >= 0 ? s.slice(idx + 1) : s);
1597
+ };
1598
+ reader.onerror = () => reject(reader.error || new Error('read failed'));
1599
+ reader.readAsDataURL(f);
1600
+ });
1601
+ arr.push(b64);
1602
+ }
1603
+ return arr;
1604
+ }
1605
+
1606
+ function handleImagesChange(inputEl, statusElId) {
1607
+ try {
1608
+ const files = inputEl && inputEl.files ? inputEl.files : [];
1609
+ if (!files || files.length === 0) {
1610
+ state.imageB64s = [];
1611
+ const st = qs('#' + statusElId);
1612
+ if (st) st.textContent = '未选择';
1613
+ return;
1614
+ }
1615
+ filesToBase64Raw(files).then(list => {
1616
+ state.imageB64s = list;
1617
+ const st = qs('#' + statusElId);
1618
+ if (st) st.textContent = '已选择 ' + list.length + ' 张';
1619
+ }).catch(() => {});
1620
+ } catch(e) {}
1621
+ }
1622
+
1623
  async function saveToChosenFolder(filename, dataUrl) {
1624
  if (!state.folderHandle) return false;
1625
  try {
 
1763
  alert('模型与提示词必填');
1764
  return;
1765
  }
1766
+ // qwen-image-edit 需要至少一张参考图
1767
+ if ((p.model || '').toLowerCase() === 'qwen-image-edit' && (!state.imageB64s || state.imageB64s.length < 1)) {
1768
+ alert('qwen-image-edit 需要至少 1 张参考图');
1769
+ return;
1770
+ }
1771
 
1772
  const isMobile = window.innerWidth <= 768;
1773
  const batchElement = qs(isMobile ? '#batchCountMobile' : '#batchCount');
 
1782
  const tasks = [];
1783
  for (let i = 1; i <= count; i++) {
1784
  const placeholder = createPlaceholderCard(i, p);
1785
+ const isQwenEdit = (p.model || '').toLowerCase() === 'qwen-image-edit';
1786
+ const argsBase = {
1787
  prompt: p.prompt,
1788
  negative_prompt: p.negative_prompt || '',
 
 
1789
  guidance_scale: p.guidance_scale,
1790
  num_inference_steps: p.num_inference_steps,
1791
  seed: state.seedRandom ? genRandomSeed() : 0
1792
  };
1793
+ const args = isQwenEdit
1794
+ ? {
1795
+ ...argsBase,
1796
+ width: p.width,
1797
+ height: p.height,
1798
+ true_cfg_scale: Number(qs(`#true_cfg_scale${isMobile ? 'Mobile' : ''}`)?.value) || 4,
1799
+ ...(state.imageB64s && state.imageB64s.length ? { image_b64s: state.imageB64s.slice(0, 3) } : {})
1800
+ }
1801
+ : {
1802
+ ...argsBase,
1803
+ width: p.width,
1804
+ height: p.height
1805
+ };
1806
+ // hidream 期望使用 resolution 字段
1807
+ if ((p.model || '').toLowerCase() === 'hidream') {
1808
+ args.resolution = (p.resolution && typeof p.resolution === 'string' && p.resolution.includes('x'))
1809
+ ? p.resolution
1810
+ : `${p.width}x${p.height}`;
1811
+ delete args.width;
1812
+ delete args.height;
1813
+ }
1814
  const body = { model: p.model, input_args: args };
1815
 
1816
  const task = (async () => {
 
1918
 
1919
  qs('#chooseFolder').addEventListener('click', chooseFolder);
1920
  qs('#generateBtn').addEventListener('click', generate);
1921
+
1922
+ // 参考图选择事件
1923
+ const imagesEl = qs('#images');
1924
+ if (imagesEl) imagesEl.addEventListener('change', () => handleImagesChange(imagesEl, 'imagesStatus'));
1925
+ const imagesElM = qs('#imagesMobile');
1926
+ if (imagesElM) imagesElM.addEventListener('change', () => handleImagesChange(imagesElM, 'imagesStatusMobile'));
1927
 
1928
  // 移动端事件绑定
1929
  qs('#mobileMenuToggle').addEventListener('click', toggleSidebar);
 
1942
  generate();
1943
  closeSidebar();
1944
  });
1945
+
1946
+ // 模型切换时动态显示/隐藏特定参数
1947
+ const ms = qs('#modelSelect');
1948
+ if (ms) ms.addEventListener('change', () => updateVisibleFieldsFor(''));
1949
+ const msm = qs('#modelSelectMobile');
1950
+ if (msm) msm.addEventListener('change', () => updateVisibleFieldsFor('Mobile'));
1951
 
1952
  // 初始化拖拽功能
1953
  initDragButton();
 
2020
  fetchModels();
2021
  }
2022
 
2023
+ // 大图预览功能
2024
+ function showImageModal(imageUrl, params) {
2025
+ const modal = qs('#imageModal');
2026
+ const modalImg = qs('#imageModalImg');
2027
+ const modalInfo = qs('#imageModalInfo');
2028
+
2029
+ modalImg.src = imageUrl;
2030
+ if (params) {
2031
+ modalInfo.textContent = `${params.width}×${params.height} | ${params.model}`;
2032
+ } else {
2033
+ modalInfo.textContent = '';
2034
+ }
2035
+ modal.classList.add('show');
2036
+
2037
+ // 阻止背景滚动
2038
+ document.body.style.overflow = 'hidden';
2039
+ }
2040
+
2041
+ function closeImageModal() {
2042
+ const modal = qs('#imageModal');
2043
+ modal.classList.remove('show');
2044
+ document.body.style.overflow = '';
2045
+ }
2046
+
2047
+ // 设置模态框事件监听
2048
+ function setupImageModal() {
2049
+ const modal = qs('#imageModal');
2050
+ const closeBtn = qs('#imageModalClose');
2051
+ const modalImg = qs('#imageModalImg');
2052
+
2053
+ if (closeBtn) {
2054
+ closeBtn.addEventListener('click', (e) => {
2055
+ e.stopPropagation();
2056
+ closeImageModal();
2057
+ });
2058
+ }
2059
+
2060
+ if (modal) {
2061
+ modal.addEventListener('click', (e) => {
2062
+ if (e.target === modal) {
2063
+ closeImageModal();
2064
+ }
2065
+ });
2066
+ }
2067
+
2068
+ if (modalImg) {
2069
+ modalImg.addEventListener('click', closeImageModal);
2070
+ }
2071
+
2072
+ // ESC键关闭
2073
+ document.addEventListener('keydown', (e) => {
2074
+ if (e.key === 'Escape' && modal.classList.contains('show')) {
2075
+ closeImageModal();
2076
+ }
2077
+ });
2078
+ }
2079
+
2080
  init();
2081
+ setupImageModal();
2082
  </script>
2083
  </body>
2084
  </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 || 'true');
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,12 +52,16 @@ 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: {
@@ -74,7 +78,8 @@ app.use(helmet.contentSecurityPolicy({
74
  // Allow CSS sourcemap requests to Baomitu CDN
75
  "connect-src": ["'self'", "https://image.chutes.ai", "https://lib.baomitu.com"],
76
  "media-src": ["'self'", "data:", "blob:"],
77
- "frame-ancestors": ["'self'", "https://huggingface.co", "https://*.huggingface.co"]
 
78
  }
79
  }));
80
 
@@ -86,7 +91,8 @@ app.use((req, res, next) => {
86
 
87
  app.use(cors());
88
  app.use(compression());
89
- app.use(express.json({ limit: '2mb' }));
 
90
  app.use(morgan(LOG_LEVEL));
91
 
92
  // Static files
@@ -224,11 +230,25 @@ app.get('/api/models', async (req, res) => {
224
  const free = typeof m.free === 'boolean' ? m.free : false;
225
  return { id, name, free };
226
  });
227
- // Merge free flags from local mapping
228
  if (localList.length) {
229
- // 先合并免费标记
230
  models = mergeFreeFlags(models, localList);
231
- // 再把仅存在于本地配置但远端没有的模型追加进去(并集)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  const remoteKeys = new Set(models.map(m => ((m.id || m.name || '') + '').toLowerCase()));
233
  for (const lm of localList) {
234
  const key = ((lm.id || lm.name || '') + '').toLowerCase();
@@ -236,7 +256,9 @@ app.get('/api/models', async (req, res) => {
236
  const id = (lm.id || lm.name || '').toString();
237
  const name = (lm.name || id).toString();
238
  const free = !!lm.free;
239
- models.push({ id, name, free });
 
 
240
  remoteKeys.add(key);
241
  }
242
  }
@@ -249,7 +271,9 @@ app.get('/api/models', async (req, res) => {
249
  const id = (m.id || m.slug || m.model || m.name || `model-${idx}`).toString();
250
  const name = (m.name || id).toString();
251
  const free = !!m.free;
252
- return { id, name, free };
 
 
253
  });
254
  return res.json({ source: 'local', models: normalized });
255
  } catch (err) {
@@ -288,8 +312,8 @@ app.post('/api/generate', async (req, res) => {
288
  negative_prompt: (body.negative_prompt ?? (body.input_args ? body.input_args.negative_prompt : undefined) ?? '').toString(),
289
  width: clamp(parseInt(body.width ?? (body.input_args ? body.input_args.width : 1024), 10) || 1024, 128, 2048),
290
  height: clamp(parseInt(body.height ?? (body.input_args ? body.input_args.height : 1024), 10) || 1024, 128, 2048),
291
- guidance_scale: clamp(parseFloat(body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : 7.5)) || 7.5, 1, 20),
292
- num_inference_steps: clamp(parseInt(body.num_inference_steps ?? (body.input_args ? body.input_args.num_inference_steps : 25), 10) || 25, 1, 50),
293
  // Seed: support top-level or input_args.seed; if missing/null -> null (omit from payload)
294
  seed: (() => {
295
  const raw = (body.seed ?? (body.input_args ? body.input_args.seed : undefined));
@@ -336,9 +360,17 @@ app.post('/api/generate', async (req, res) => {
336
  return null;
337
  }
338
  const cfg = getModelConfig(localList, flat.model);
339
- const generateUrl = (cfg && cfg.upstream_url) ? cfg.upstream_url : GENERATE_API_URL;
340
- const preferMinimal = FORCE_MINIMAL || !!(cfg && cfg.minimal === true);
341
-
 
 
 
 
 
 
 
 
342
 
343
 
344
  const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN;
@@ -348,39 +380,156 @@ app.post('/api/generate', async (req, res) => {
348
  ...(apiToken ? { 'Authorization': `Bearer ${apiToken}` } : {})
349
  };
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  const variantA = {
352
  model: targetModel,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  input_args: {
354
  prompt: flat.prompt,
355
- negative_prompt: flat.negative_prompt || '',
356
- width: flat.width,
357
  height: flat.height,
358
- guidance_scale: flat.guidance_scale,
359
  num_inference_steps: flat.num_inference_steps,
 
 
 
360
  ...(flat.seed !== null ? { seed: flat.seed } : {})
361
  }
362
- };
363
 
364
- const variantB = {
365
- model: targetModel,
366
- prompt: flat.prompt,
367
- negative_prompt: flat.negative_prompt || '',
368
- width: flat.width,
369
- height: flat.height,
370
- guidance_scale: flat.guidance_scale,
371
- num_inference_steps: flat.num_inference_steps,
372
- ...(flat.seed !== null ? { seed: flat.seed } : {})
373
- };
374
 
375
  // Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility
376
  const variantCMinimal = {
377
  model: targetModel,
 
 
 
 
 
 
 
 
 
 
378
  prompt: flat.prompt,
379
  size: `${flat.width}x${flat.height}`,
380
  steps: flat.num_inference_steps,
381
  ...(flat.seed !== null ? { seed: flat.seed } : {})
382
  };
383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  // duplicate removed
385
 
386
  async function tryCall(payload, label, url) {
@@ -394,14 +543,16 @@ app.post('/api/generate', async (req, res) => {
394
  return { ok: true, imageBase64: base64, contentType: ctype, tried: label };
395
  }
396
 
397
- // Success: JSON payload that contains image data
398
  if (status >= 200 && status < 300 && /application\/json/i.test(ctype)) {
399
  let raw = '';
400
  try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
401
  try {
402
  const j = JSON.parse(raw || '{}');
 
403
  if (j && j.image) {
404
  if (typeof j.image === 'string' && j.image.startsWith('data:')) {
 
405
  const match = /^data:([^;]+);base64,(.*)$/i.exec(j.image);
406
  if (match) {
407
  return { ok: true, imageBase64: match[2], contentType: match[1], tried: label };
@@ -416,13 +567,17 @@ app.post('/api/generate', async (req, res) => {
416
  return { ok: true, imageBase64: j.data, contentType: ct, tried: label };
417
  }
418
  } catch (e) {
419
- // fallthrough
420
  }
421
  }
422
 
423
- // Error mapping
424
  let raw = '';
425
- try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
 
 
 
 
426
  let code = 'UPSTREAM_ERROR';
427
  let hint = '';
428
  let mappedStatus = status;
@@ -433,11 +588,12 @@ app.post('/api/generate', async (req, res) => {
433
  } catch (e) {
434
  detailText = raw;
435
  }
 
436
  const lower = (detailText || '').toLowerCase();
437
  if (lower.includes('exhausted all available targets')) {
438
  code = 'UPSTREAM_CAPACITY_EXHAUSTED';
439
  hint = '上游容量不足(GPU/目标不可用或排队中),请稍后重试、换模型,或降低分辨率/步数。';
440
- mappedStatus = 503;
441
  } else if (status === 404 && lower.includes('model not found')) {
442
  code = 'UPSTREAM_MODEL_NOT_FOUND';
443
  hint = '上游模型不存在或标识不匹配。请更换模型,或在 data/models.json 为该模型添加正确的 \"upstream_id\" 映射后重试。';
@@ -455,87 +611,146 @@ app.post('/api/generate', async (req, res) => {
455
 
456
  let result;
457
 
458
- // If this model prefers minimal payload (e.g. hunyuan-image-3), try minimal first
 
 
459
  if (preferMinimal) {
460
- try {
461
- result = await tryCall(variantCMinimal, 'minimal', generateUrl);
462
- } catch (e0) { /* continue */ }
463
- }
464
- else {
465
- try {
466
- result = await tryCall(variantA, 'nested', generateUrl);
467
- } catch (e1) {
 
 
 
 
 
 
 
 
 
 
 
468
  try {
469
- result = await tryCall(variantB, 'flat', generateUrl);
470
- } catch (e2) {
471
- // Auto fallback to another model when capacity/infrastructure errors (disabled when NO_FALLBACK=true)
472
- const capacityCodes = ['UPSTREAM_CAPACITY_EXHAUSTED','UPSTREAM_NO_INSTANCES','UPSTREAM_INFRASTRUCTURE','UPSTREAM_BAD_GATEWAY'];
473
- if (AUTO_FALLBACK && !NO_FALLBACK && capacityCodes.includes(e2.code || '') ) {
474
- // choose fallback model (prefer free and different from current)
475
- function chooseFallback(currentId, list) {
476
- const key = (currentId || '').toLowerCase();
477
- const free = list.filter(m => m.free && (m.id || m.name || '').toLowerCase() !== key);
478
- if (free.length) return (free[0].upstream_id || free[0].id || free[0].name);
479
- const any = list.find(m => (m.id || m.name || '').toLowerCase() !== key);
480
- if (any) return (any.upstream_id || any.id || any.name);
481
- return null;
482
- }
483
- const fallbackModel = chooseFallback(targetModel, localList);
484
- if (fallbackModel && fallbackModel !== targetModel) {
485
- const fbA = { ...variantA, model: fallbackModel };
486
- const fbB = { ...variantB, model: fallbackModel };
487
- // Choose URL for fallback model
488
- const fbCfg = getModelConfig(localList, fallbackModel);
489
- const fbUrl = (fbCfg && fbCfg.upstream_url) ? fbCfg.upstream_url : GENERATE_API_URL;
490
- try {
491
- result = await tryCall(fbA, 'nested-fallback', fbUrl);
492
- } catch (e3) {
493
- try {
494
- result = await tryCall(fbB, 'flat-fallback', fbUrl);
495
- } catch (e4) {
496
- const status = e4.status || 502;
497
- return res.status(status).json({
498
- ok: false,
499
- error: e4.hint || e4.message || 'Upstream error',
500
- code: e4.code || 'UPSTREAM_ERROR',
501
- upstream_model: targetModel,
502
- fallback_model: fallbackModel
503
- });
504
- }
505
- }
506
- // success with fallback
507
- return res.json({
508
- ok: true,
509
- image: `data:${result.contentType};base64,${result.imageBase64}`,
510
- contentType: result.contentType,
511
- meta: {
512
- model: flat.model,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  upstream_model: targetModel,
514
- fallback_used: true,
515
- fallback_model: fallbackModel,
516
- width: flat.width,
517
- height: flat.height,
518
- guidance_scale: flat.guidance_scale,
519
- num_inference_steps: flat.num_inference_steps,
520
- seed: flat.seed
521
- },
522
- tried: result.tried
523
- });
524
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  }
526
- const status = e2.status || 502;
527
- return res.status(status).json({
528
- ok: false,
529
- error: e2.hint || e2.message || 'Upstream error',
530
- code: e2.code || 'UPSTREAM_ERROR',
531
- upstream_model: targetModel
532
- });
533
  }
 
 
 
 
 
 
 
 
534
  }
535
  }
536
 
537
-
538
-
539
  return res.json({
540
  ok: true,
541
  image: `data:${result.contentType};base64,${result.imageBase64}`,
 
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: {
 
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(parseFloat(body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : 7.5)) || 7.5, 0, 20),
316
+ num_inference_steps: clamp(parseInt(body.num_inference_steps ?? (body.input_args ? body.input_args.num_inference_steps : 25), 10) || 25, 1, 100),
317
  // Seed: support top-level or input_args.seed; if missing/null -> null (omit from payload)
318
  seed: (() => {
319
  const raw = (body.seed ?? (body.input_args ? body.input_args.seed : undefined));
 
360
  return null;
361
  }
362
  const cfg = getModelConfig(localList, flat.model);
363
+ let generateUrl = (cfg && cfg.upstream_url) ? cfg.upstream_url : GENERATE_API_URL;
364
+ let preferMinimal = FORCE_MINIMAL || !!(cfg && cfg.minimal === true);
365
+ const isHidream = (
366
+ typeof flat.model === 'string' && flat.model.toLowerCase() === 'hidream'
367
+ ) || /hidream/i.test(generateUrl) || /hidream/i.test(String(targetModel || ''));
368
+ const isQwenEdit = (
369
+ typeof flat.model === 'string' && flat.model.toLowerCase() === 'qwen-image-edit'
370
+ ) || /qwen-image-edit/i.test(generateUrl) || /qwen-image-edit/i.test(String(targetModel || ''));
371
+ const isZImageTurbo = (
372
+ typeof flat.model === 'string' && flat.model.toLowerCase() === 'z-image-turbo'
373
+ ) || /z-image-turbo/i.test(generateUrl) || /z-image-turbo/i.test(String(targetModel || ''));
374
 
375
 
376
  const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN;
 
380
  ...(apiToken ? { 'Authorization': `Bearer ${apiToken}` } : {})
381
  };
382
 
383
+ // hidream: 优先使用 models.json 的 upstream_url;否则使用 HIDREAM_UPSTREAM_URL;否则保持默认 GENERATE_API_URL
384
+ if (isHidream) {
385
+ const envUrl = process.env.HIDREAM_UPSTREAM_URL || '';
386
+ function isValidUrl(u) {
387
+ try { new URL(u); return true; } catch { return false; }
388
+ }
389
+ if (cfg && cfg.upstream_url && isValidUrl(cfg.upstream_url)) {
390
+ generateUrl = cfg.upstream_url;
391
+ } else if (envUrl && isValidUrl(envUrl)) {
392
+ generateUrl = envUrl;
393
+ }
394
+ }
395
+
396
+ // Normalize special-case upstream ids that differ from local display ids
397
+ // qwen-image-edit 在上游共用 qwen-image 的路由/标识
398
+ if (isQwenEdit && String(targetModel || '').toLowerCase() === 'qwen-image-edit') {
399
+ targetModel = 'qwen-image';
400
+ }
401
+
402
+ // Pass-through extras (e.g., image_b64s, true_cfg_scale, resolution, shift, max_sequence_length, etc.)
403
+ const inputExtras = (body && typeof body.input_args === 'object') ? { ...body.input_args } : {};
404
+ if (isHidream) {
405
+ // hidream 仅接受 resolution;确保存在并移除 width/height 噪声
406
+ if (!inputExtras.resolution) {
407
+ inputExtras.resolution = `${flat.width}x${flat.height}`;
408
+ }
409
+ delete inputExtras.width;
410
+ delete inputExtras.height;
411
+ }
412
+ if (!isQwenEdit) {
413
+ // 非 qwen-image-edit 时不传参考图与 true_cfg_scale
414
+ delete inputExtras.image_b64s;
415
+ delete inputExtras.true_cfg_scale;
416
+ }
417
+ // Z-Image-Turbo 特殊参数处理
418
+ if (isZImageTurbo) {
419
+ // 保留 shift 和 max_sequence_length 参数
420
+ if (inputExtras.shift === undefined) {
421
+ inputExtras.shift = 3.0; // 默认值
422
+ }
423
+ if (inputExtras.max_sequence_length === undefined) {
424
+ inputExtras.max_sequence_length = 512; // 默认值
425
+ }
426
+ }
427
+
428
+ // Build top-level extras for flat payloads (some upstreams expect image_b64s/true_cfg_scale at top-level)
429
+ const topLevelExtras = {};
430
+ const maybeImageB64s = (body && (body.image_b64s ?? (body.input_args && body.input_args.image_b64s)));
431
+ const maybeTrueCfg = (body && (body.true_cfg_scale ?? (body.input_args && body.input_args.true_cfg_scale)));
432
+ if (isQwenEdit) {
433
+ if (maybeImageB64s) topLevelExtras.image_b64s = maybeImageB64s;
434
+ if (maybeTrueCfg !== undefined && maybeTrueCfg !== null) topLevelExtras.true_cfg_scale = maybeTrueCfg;
435
+ }
436
+
437
+ // qwen-image-edit: 校验参考图数量(1-3)
438
+ if (isQwenEdit) {
439
+ const imgs = inputExtras.image_b64s || topLevelExtras.image_b64s;
440
+ const validCount = Array.isArray(imgs) ? imgs.length : 0;
441
+ if (validCount < 1 || validCount > 3) {
442
+ return res.status(400).json({ ok: false, error: 'qwen-image-edit 需要 1-3 张参考图 (image_b64s)' });
443
+ }
444
+ }
445
+
446
+ const commonArgs = {
447
+ prompt: flat.prompt,
448
+ negative_prompt: flat.negative_prompt || '',
449
+ guidance_scale: flat.guidance_scale,
450
+ num_inference_steps: flat.num_inference_steps,
451
+ ...(flat.seed !== null ? { seed: flat.seed } : {})
452
+ };
453
  const variantA = {
454
  model: targetModel,
455
+ input_args: isHidream
456
+ ? { ...inputExtras, ...commonArgs }
457
+ : { ...inputExtras, ...commonArgs, width: flat.width, height: flat.height }
458
+ };
459
+
460
+ const variantB = isHidream
461
+ ? {
462
+ model: targetModel,
463
+ input_args: { ...inputExtras, ...commonArgs }
464
+ }
465
+ : {
466
+ model: targetModel,
467
+ ...topLevelExtras,
468
+ ...commonArgs,
469
+ width: flat.width,
470
+ height: flat.height
471
+ };
472
+
473
+ // Hidream-specific flat payload expected by chutes-hidream endpoint (no input_args)
474
+ const variantHidreamFlat = isHidream ? {
475
+ prompt: flat.prompt,
476
+ resolution: (inputExtras && inputExtras.resolution) ? String(inputExtras.resolution) : `${flat.width}x${flat.height}`,
477
+ guidance_scale: flat.guidance_scale,
478
+ num_inference_steps: flat.num_inference_steps,
479
+ ...(flat.seed !== null ? { seed: flat.seed } : {})
480
+ } : null;
481
+
482
+ // Z-Image-Turbo payload with input_args wrapper (required by the endpoint)
483
+ const variantZImageNested = isZImageTurbo ? {
484
  input_args: {
485
  prompt: flat.prompt,
 
 
486
  height: flat.height,
487
+ width: flat.width,
488
  num_inference_steps: flat.num_inference_steps,
489
+ guidance_scale: flat.guidance_scale,
490
+ shift: (inputExtras && inputExtras.shift !== undefined) ? Number(inputExtras.shift) : 3.0,
491
+ max_sequence_length: (inputExtras && inputExtras.max_sequence_length !== undefined) ? Number(inputExtras.max_sequence_length) : 512,
492
  ...(flat.seed !== null ? { seed: flat.seed } : {})
493
  }
494
+ } : null;
495
 
496
+ // Z-Image-Turbo minimal payload (top-level prompt only, as shown in curl example)
497
+ const variantZImageMinimal = isZImageTurbo ? {
498
+ prompt: flat.prompt
499
+ } : null;
 
 
 
 
 
 
500
 
501
  // Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility
502
  const variantCMinimal = {
503
  model: targetModel,
504
+ input_args: {
505
+ prompt: flat.prompt,
506
+ size: `${flat.width}x${flat.height}`,
507
+ steps: flat.num_inference_steps,
508
+ ...(flat.seed !== null ? { seed: flat.seed } : {})
509
+ }
510
+ };
511
+
512
+ // Flat minimal payload for model-specific upstreams that expect top-level { prompt }
513
+ const variantFlatMinimal = {
514
  prompt: flat.prompt,
515
  size: `${flat.width}x${flat.height}`,
516
  steps: flat.num_inference_steps,
517
  ...(flat.seed !== null ? { seed: flat.seed } : {})
518
  };
519
 
520
+ // Qwen-image-edit: official top-level flat payload (no model, includes refs)
521
+ const variantQwenFlat = isQwenEdit ? {
522
+ prompt: flat.prompt,
523
+ negative_prompt: flat.negative_prompt || '',
524
+ width: flat.width,
525
+ height: flat.height,
526
+ guidance_scale: flat.guidance_scale,
527
+ num_inference_steps: flat.num_inference_steps,
528
+ ...(flat.seed !== null ? { seed: flat.seed } : {}),
529
+ ...(inputExtras && inputExtras.image_b64s ? { image_b64s: inputExtras.image_b64s } : (topLevelExtras.image_b64s ? { image_b64s: topLevelExtras.image_b64s } : {})),
530
+ ...(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 } : {}))
531
+ } : null;
532
+
533
  // duplicate removed
534
 
535
  async function tryCall(payload, label, url) {
 
543
  return { ok: true, imageBase64: base64, contentType: ctype, tried: label };
544
  }
545
 
546
+ // Success: JSON response that may contain base64 or data URL
547
  if (status >= 200 && status < 300 && /application\/json/i.test(ctype)) {
548
  let raw = '';
549
  try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
550
  try {
551
  const j = JSON.parse(raw || '{}');
552
+ // Common patterns: { image: "data:..."} or { image: "<base64>", contentType: "image/jpeg" } or { data: "<base64>" }
553
  if (j && j.image) {
554
  if (typeof j.image === 'string' && j.image.startsWith('data:')) {
555
+ // Already data URL; extract base64 and contentType
556
  const match = /^data:([^;]+);base64,(.*)$/i.exec(j.image);
557
  if (match) {
558
  return { ok: true, imageBase64: match[2], contentType: match[1], tried: label };
 
567
  return { ok: true, imageBase64: j.data, contentType: ct, tried: label };
568
  }
569
  } catch (e) {
570
+ // fallthrough to error mapping below
571
  }
572
  }
573
 
574
+ // Error handling branch (non-2xx or unrecognized payload)
575
  let raw = '';
576
+ try {
577
+ raw = Buffer.from(resp.data).toString();
578
+ } catch (e) {}
579
+
580
+ // Friendly diagnostics mapping
581
  let code = 'UPSTREAM_ERROR';
582
  let hint = '';
583
  let mappedStatus = status;
 
588
  } catch (e) {
589
  detailText = raw;
590
  }
591
+
592
  const lower = (detailText || '').toLowerCase();
593
  if (lower.includes('exhausted all available targets')) {
594
  code = 'UPSTREAM_CAPACITY_EXHAUSTED';
595
  hint = '上游容量不足(GPU/目标不可用或排队中),请稍后重试、换模型,或降低分辨率/步数。';
596
+ mappedStatus = 503; // service unavailable
597
  } else if (status === 404 && lower.includes('model not found')) {
598
  code = 'UPSTREAM_MODEL_NOT_FOUND';
599
  hint = '上游模型不存在或标识不匹配。请更换模型,或在 data/models.json 为该模型添加正确的 \"upstream_id\" 映射后重试。';
 
611
 
612
  let result;
613
 
614
+ // If the model prefers minimal payload, choose ordering per model.
615
+ // For hunyuan-image-3: nested-minimal (input_args with size) FIRST to ensure size is honored.
616
+ // For Z-Image-Turbo: use dedicated nested payload FIRST
617
  if (preferMinimal) {
618
+ const isHunyuan =
619
+ (typeof flat.model === 'string' && flat.model.toLowerCase() === 'hunyuan-image-3') ||
620
+ /hunyuan-image-3/i.test(generateUrl) ||
621
+ /hunyuan-image-3/i.test(String(targetModel || ''));
622
+
623
+ if (isZImageTurbo) {
624
+ // Z-Image-Turbo: try minimal payload first (as shown in curl example)
625
+ if (variantZImageMinimal) {
626
+ try {
627
+ result = await tryCall(variantZImageMinimal, 'z-image-turbo-minimal', generateUrl);
628
+ } catch (e0) {}
629
+ }
630
+ // If minimal fails, try full nested payload
631
+ if (!result && variantZImageNested) {
632
+ try {
633
+ result = await tryCall(variantZImageNested, 'z-image-turbo-nested', generateUrl);
634
+ } catch (e1) {}
635
+ }
636
+ } else if (isHunyuan) {
637
  try {
638
+ result = await tryCall(variantCMinimal, 'nested-minimal', generateUrl);
639
+ } catch (e0) {}
640
+ if (!result) {
641
+ try {
642
+ result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl);
643
+ } catch (e1) {}
644
+ }
645
+ } else {
646
+ try {
647
+ result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl);
648
+ } catch (e0) {}
649
+ if (!result) {
650
+ try {
651
+ result = await tryCall(variantCMinimal, 'nested-minimal', generateUrl);
652
+ } catch (e1) {}
653
+ }
654
+ }
655
+ }
656
+
657
+ // Only try full payload if minimal didn't succeed
658
+ if (!result) {
659
+ let lastError = null;
660
+
661
+ if (!result) {
662
+ try { result = await tryCall(variantA, 'nested', generateUrl); }
663
+ catch (e1) { lastError = e1; }
664
+ }
665
+
666
+ if (!result) {
667
+ try { result = await tryCall(variantB, 'flat', generateUrl); }
668
+ catch (e2) { lastError = e2; }
669
+ }
670
+
671
+ // qwen-image-edit official flat payload (no model)
672
+ if (!result && isQwenEdit && variantQwenFlat) {
673
+ try { result = await tryCall(variantQwenFlat, 'qwen-flat', generateUrl); }
674
+ catch (eQ) { lastError = eQ; }
675
+ }
676
+
677
+ // hidream official flat payload (no model)
678
+ if (!result && isHidream && variantHidreamFlat) {
679
+ try { result = await tryCall(variantHidreamFlat, 'hidream-flat', generateUrl); }
680
+ catch (eH) { lastError = eH; }
681
+ }
682
+
683
+ // Z-Image-Turbo nested payload - fallback if not tried in minimal mode
684
+ if (!result && isZImageTurbo && variantZImageNested && !preferMinimal) {
685
+ try { result = await tryCall(variantZImageNested, 'z-image-turbo-nested', generateUrl); }
686
+ catch (eZ) { lastError = eZ; }
687
+ }
688
+
689
+ if (!result) {
690
+ // Auto fallback to another model when capacity/infrastructure errors (disabled when NO_FALLBACK=true)
691
+ const capacityCodes = ['UPSTREAM_CAPACITY_EXHAUSTED','UPSTREAM_NO_INSTANCES','UPSTREAM_INFRASTRUCTURE','UPSTREAM_BAD_GATEWAY'];
692
+ if (AUTO_FALLBACK && !NO_FALLBACK && lastError && capacityCodes.includes(lastError.code || '')) {
693
+ function chooseFallback(currentId, list) {
694
+ const key = (currentId || '').toLowerCase();
695
+ const free = list.filter(m => m.free && (m.id || m.name || '').toLowerCase() !== key);
696
+ if (free.length) return (free[0].upstream_id || free[0].id || free[0].name);
697
+ const any = list.find(m => (m.id || m.name || '').toLowerCase() !== key);
698
+ if (any) return (any.upstream_id || any.id || any.name);
699
+ return null;
700
+ }
701
+ const fallbackModel = chooseFallback(targetModel, localList);
702
+ if (fallbackModel && fallbackModel !== targetModel) {
703
+ const fbA = { ...variantA, model: fallbackModel };
704
+ const fbB = { ...variantB, model: fallbackModel };
705
+ const fbCfg = getModelConfig(localList, fallbackModel);
706
+ const fbUrl = (fbCfg && fbCfg.upstream_url) ? fbCfg.upstream_url : GENERATE_API_URL;
707
+
708
+ try { result = await tryCall(fbA, 'nested-fallback', fbUrl); }
709
+ catch (e3) {
710
+ try { result = await tryCall(fbB, 'flat-fallback', fbUrl); }
711
+ catch (e4) {
712
+ const status = e4.status || 502;
713
+ return res.status(status).json({
714
+ ok: false,
715
+ error: e4.hint || e4.message || 'Upstream error',
716
+ code: e4.code || 'UPSTREAM_ERROR',
717
  upstream_model: targetModel,
718
+ fallback_model: fallbackModel
719
+ });
720
+ }
 
 
 
 
 
 
 
721
  }
722
+
723
+ // success with fallback
724
+ return res.json({
725
+ ok: true,
726
+ image: `data:${result.contentType};base64,${result.imageBase64}`,
727
+ contentType: result.contentType,
728
+ meta: {
729
+ model: flat.model,
730
+ upstream_model: targetModel,
731
+ fallback_used: true,
732
+ fallback_model: fallbackModel,
733
+ width: flat.width,
734
+ height: flat.height,
735
+ guidance_scale: flat.guidance_scale,
736
+ num_inference_steps: flat.num_inference_steps,
737
+ seed: flat.seed
738
+ },
739
+ tried: result.tried
740
+ });
741
  }
 
 
 
 
 
 
 
742
  }
743
+
744
+ const status = (lastError && lastError.status) || 502;
745
+ return res.status(status).json({
746
+ ok: false,
747
+ error: (lastError && (lastError.hint || lastError.message)) || 'Upstream error',
748
+ code: (lastError && lastError.code) || 'UPSTREAM_ERROR',
749
+ upstream_model: targetModel
750
+ });
751
  }
752
  }
753
 
 
 
754
  return res.json({
755
  ok: true,
756
  image: `data:${result.contentType};base64,${result.imageBase64}`,