Files changed (3) hide show
  1. data/models.json +17 -1
  2. public/index.html +612 -37
  3. 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">提示音</button>
 
 
 
 
639
  <button id="themeToggle" class="toggle-btn">主题</button>
640
  </div>
641
  <div class="api-input">
642
- <span>Key</span>
643
- <input type="password" id="apiKeyInput" placeholder="API Token">
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">提示音</button>
 
 
 
 
 
659
  <button id="themeToggleMobile" class="toggle-btn">主题</button>
660
  </div>
661
  </div>
662
  <div class="group">
663
- <label>API Key</label>
664
- <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;">
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 form-grid">
 
 
 
 
 
 
 
 
 
 
 
 
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">✨</button>
 
 
 
 
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 form-grid">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- btn.textContent = state.sound ? '🔔' : '🔕';
 
 
 
825
  }
826
 
827
  // 更新移动端按钮
828
  const btnMobile = qs('#soundToggleMobile');
829
  if (btnMobile) {
830
  btnMobile.classList.toggle('active', state.sound);
831
- btnMobile.textContent = state.sound ? '🔔 开启' : '🔕 关闭';
 
 
 
 
 
 
 
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
- return {
895
- model: qs(`#modelSelect${prefix}`).value || '',
 
 
 
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) || 6,
901
  num_inference_steps: Number(qs(`#num_inference_steps${prefix}`).value) || 20,
902
- seed: state.seedRandom ? null : 0
 
 
 
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
- qs('#folderStatus').textContent = '已选择';
1205
- qs('#folderStatusMobile').textContent = '已选择';
 
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 args = {
 
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
- await saveToChosenFolder(fname, dataUrl);
 
 
 
 
 
 
 
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' && state.sidebarOpen) closeSidebar();
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 || '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,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
- "font-src": ["'self'", "data:"],
69
- "connect-src": ["'self'", "https://image.chutes.ai"],
 
 
70
  "media-src": ["'self'", "data:", "blob:"],
71
- "frame-ancestors": ["'self'", "https://huggingface.co", "https://*.huggingface.co"]
 
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: '2mb' }));
 
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
- models.push({ id, name, free });
 
 
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
- return { id, name, free };
 
 
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(parseFloat(body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : 7.5)) || 7.5, 1, 20),
286
- num_inference_steps: clamp(parseInt(body.num_inference_steps ?? (body.input_args ? body.input_args.num_inference_steps : 25), 10) || 25, 1, 50),
 
 
 
 
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
- const generateUrl = (cfg && cfg.upstream_url) ? cfg.upstream_url : GENERATE_API_URL;
334
- const preferMinimal = FORCE_MINIMAL || !!(cfg && cfg.minimal === true);
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
- guidance_scale: flat.guidance_scale,
353
  num_inference_steps: flat.num_inference_steps,
 
 
 
354
  ...(flat.seed !== null ? { seed: flat.seed } : {})
355
  }
356
- };
357
 
358
- const variantB = {
359
- model: targetModel,
360
  prompt: flat.prompt,
361
- negative_prompt: flat.negative_prompt || '',
362
- width: flat.width,
363
- height: flat.height,
364
- guidance_scale: flat.guidance_scale,
365
- num_inference_steps: flat.num_inference_steps,
366
- ...(flat.seed !== null ? { seed: flat.seed } : {})
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 payload that contains image data
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 mapping
420
  let raw = '';
421
- try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
 
 
 
 
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 this model prefers minimal payload (e.g. hunyuan-image-3), try minimal first
 
 
455
  if (preferMinimal) {
456
- try {
457
- result = await tryCall(variantCMinimal, 'minimal', generateUrl);
458
- } catch (e0) { /* continue */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  }
460
 
461
- try {
462
- result = await tryCall(variantA, 'nested', generateUrl);
463
- } catch (e1) {
464
- try {
465
- result = await tryCall(variantB, 'flat', generateUrl);
466
- } catch (e2) {
467
- // Auto fallback to another model when capacity/infrastructure errors (disabled when NO_FALLBACK=true)
468
- const capacityCodes = ['UPSTREAM_CAPACITY_EXHAUSTED','UPSTREAM_NO_INSTANCES','UPSTREAM_INFRASTRUCTURE','UPSTREAM_BAD_GATEWAY'];
469
- if (AUTO_FALLBACK && !NO_FALLBACK && capacityCodes.includes(e2.code || '') ) {
470
- // choose fallback model (prefer free and different from current)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- try {
487
- result = await tryCall(fbA, 'nested-fallback', fbUrl);
488
- } catch (e3) {
489
- try {
490
- result = await tryCall(fbB, 'flat-fallback', fbUrl);
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
- const status = e2.status || 502;
 
523
  return res.status(status).json({
524
  ok: false,
525
- error: e2.hint || e2.message || 'Upstream error',
526
- code: e2.code || 'UPSTREAM_ERROR',
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
  }