bibibi12345 commited on
Commit
354cde7
·
1 Parent(s): 7f5e77e

fixed hunyuan size. fixed saving. fixed image preview size. added large preview.

Browse files
Files changed (2) hide show
  1. public/index.html +198 -12
  2. server.js +78 -71
public/index.html CHANGED
@@ -339,9 +339,9 @@
339
 
340
  .card img {
341
  width: 100%;
 
342
  display: block;
343
- aspect-ratio: 1;
344
- object-fit: cover;
345
  }
346
 
347
  .card .meta {
@@ -627,6 +627,44 @@
627
  width: 100%;
628
  }
629
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
630
  </style>
631
  </head>
632
  <body>
@@ -923,6 +961,12 @@
923
  const img = document.createElement('img');
924
  img.src = dataUrl;
925
  img.alt = params.prompt || 'image';
 
 
 
 
 
 
926
 
927
  const meta = document.createElement('div');
928
  meta.className = 'meta';
@@ -955,10 +999,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 +1033,115 @@
980
  }
981
  }
982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  // 拖拽功能
984
  let dragState = {
985
  isDragging: false,
@@ -1172,6 +1334,9 @@
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 +1365,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 = '未选择';
@@ -1347,8 +1521,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) {
@@ -1458,10 +1640,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
 
 
339
 
340
  .card img {
341
  width: 100%;
342
+ height: auto;
343
  display: block;
344
+ object-fit: contain;
 
345
  }
346
 
347
  .card .meta {
 
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>
 
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');
972
  meta.className = 'meta';
 
999
  <div style="margin-bottom: 4px;">尺寸: ${params.width}x${params.height}</div>
1000
  <div style="margin-bottom: 4px;">步数: ${params.num_inference_steps} | 引导: ${params.guidance_scale}</div>
1001
  <div style="margin-bottom: 8px; font-size: 12px; line-height: 1.3; word-break: break-all;">${params.prompt}</div>
 
 
 
1002
  `;
1003
+ const row = document.createElement('div');
1004
+ row.className = 'row';
1005
+ const dlBtn = document.createElement('button');
1006
+ dlBtn.className = 'secondary';
1007
+ dlBtn.textContent = '下载';
1008
+ dlBtn.addEventListener('click', function(e) {
1009
+ e.preventDefault();
1010
+ e.stopPropagation();
1011
+ downloadImageMobile(filename, dataUrl);
1012
+ });
1013
+ row.appendChild(dlBtn);
1014
+ metaContent.appendChild(row);
1015
 
1016
  meta.appendChild(metaHeader);
1017
  meta.appendChild(metaContent);
 
1033
  }
1034
  }
1035
 
1036
+ // Image Viewer (Lightbox)
1037
+ function ensureImageViewer() {
1038
+ let overlay = document.getElementById('imageViewer');
1039
+ if (!overlay) {
1040
+ overlay = document.createElement('div');
1041
+ overlay.id = 'imageViewer';
1042
+ overlay.className = 'image-viewer-overlay';
1043
+ const content = document.createElement('div');
1044
+ content.className = 'image-viewer-content';
1045
+ const img = document.createElement('img');
1046
+ img.alt = 'preview';
1047
+ const closeBtn = document.createElement('button');
1048
+ closeBtn.className = 'image-viewer-close';
1049
+ closeBtn.textContent = '关闭';
1050
+ closeBtn.addEventListener('click', function(e) {
1051
+ e.preventDefault();
1052
+ e.stopPropagation();
1053
+ closeImageViewer();
1054
+ });
1055
+ // click outside content area closes viewer
1056
+ overlay.addEventListener('click', function(e) {
1057
+ if (e.target === overlay) {
1058
+ closeImageViewer();
1059
+ }
1060
+ });
1061
+ content.appendChild(img);
1062
+ content.appendChild(closeBtn);
1063
+ overlay.appendChild(content);
1064
+ document.body.appendChild(overlay);
1065
+ }
1066
+ return overlay;
1067
+ }
1068
+
1069
+ function openImageViewer(dataUrl, params) {
1070
+ const overlay = ensureImageViewer();
1071
+ const img = overlay.querySelector('.image-viewer-content img');
1072
+ img.src = dataUrl;
1073
+ img.alt = (params && params.prompt) ? params.prompt : 'image';
1074
+ overlay.classList.add('show');
1075
+ }
1076
+
1077
+ function closeImageViewer() {
1078
+ const overlay = document.getElementById('imageViewer');
1079
+ if (overlay) {
1080
+ overlay.classList.remove('show');
1081
+ const img = overlay.querySelector('.image-viewer-content img');
1082
+ if (img) img.src = '';
1083
+ }
1084
+ }
1085
+
1086
+ function isViewerOpen() {
1087
+ const overlay = document.getElementById('imageViewer');
1088
+ return !!(overlay && overlay.classList.contains('show'));
1089
+ }
1090
+ // Image Viewer (Lightbox)
1091
+ function ensureImageViewer() {
1092
+ let overlay = document.getElementById('imageViewer');
1093
+ if (!overlay) {
1094
+ overlay = document.createElement('div');
1095
+ overlay.id = 'imageViewer';
1096
+ overlay.className = 'image-viewer-overlay';
1097
+ const content = document.createElement('div');
1098
+ content.className = 'image-viewer-content';
1099
+ const img = document.createElement('img');
1100
+ img.alt = 'preview';
1101
+ const closeBtn = document.createElement('button');
1102
+ closeBtn.className = 'image-viewer-close';
1103
+ closeBtn.textContent = '关闭';
1104
+ closeBtn.addEventListener('click', function(e) {
1105
+ e.preventDefault();
1106
+ e.stopPropagation();
1107
+ closeImageViewer();
1108
+ });
1109
+ // click outside content area closes viewer
1110
+ overlay.addEventListener('click', function(e) {
1111
+ if (e.target === overlay) {
1112
+ closeImageViewer();
1113
+ }
1114
+ });
1115
+ content.appendChild(img);
1116
+ content.appendChild(closeBtn);
1117
+ overlay.appendChild(content);
1118
+ document.body.appendChild(overlay);
1119
+ }
1120
+ return overlay;
1121
+ }
1122
+
1123
+ function openImageViewer(dataUrl, params) {
1124
+ const overlay = ensureImageViewer();
1125
+ const img = overlay.querySelector('.image-viewer-content img');
1126
+ img.src = dataUrl;
1127
+ img.alt = (params && params.prompt) ? params.prompt : 'image';
1128
+ overlay.classList.add('show');
1129
+ }
1130
+
1131
+ function closeImageViewer() {
1132
+ const overlay = document.getElementById('imageViewer');
1133
+ if (overlay) {
1134
+ overlay.classList.remove('show');
1135
+ const img = overlay.querySelector('.image-viewer-content img');
1136
+ if (img) img.src = '';
1137
+ }
1138
+ }
1139
+
1140
+ function isViewerOpen() {
1141
+ const overlay = document.getElementById('imageViewer');
1142
+ return !!(overlay && overlay.classList.contains('show'));
1143
+ }
1144
+
1145
  // 拖拽功能
1146
  let dragState = {
1147
  isDragging: false,
 
1334
  async function saveToChosenFolder(filename, dataUrl) {
1335
  if (!state.folderHandle) return false;
1336
  try {
1337
+ // 如果无写入权限,避免在非用户激活上下文触发授权提示
1338
+ const perm = await state.folderHandle.queryPermission({ mode: 'readwrite' });
1339
+ if (perm !== 'granted') return false;
1340
  const fileHandle = await state.folderHandle.getFileHandle(filename, { create: true });
1341
  const writable = await fileHandle.createWritable();
1342
  await writable.write(dataURLtoBlob(dataUrl));
 
1365
  return;
1366
  }
1367
  const handle = await window.showDirectoryPicker();
1368
+ // 在用户点击的上下文中请求写入权限
1369
+ let perm = 'prompt';
1370
+ if ('queryPermission' in handle && 'requestPermission' in handle) {
1371
+ perm = await handle.queryPermission({ mode: 'readwrite' });
1372
+ if (perm !== 'granted') {
1373
+ perm = await handle.requestPermission({ mode: 'readwrite' });
1374
+ }
1375
+ }
1376
  state.folderHandle = handle;
1377
+ const ok = perm === 'granted';
1378
+ qs('#folderStatus').textContent = ok ? '已选择(可写)' : '已选择(只读)';
1379
+ qs('#folderStatusMobile').textContent = ok ? '已选择(可写)' : '已选择(只读)';
1380
  } catch(e) {
1381
  qs('#folderStatus').textContent = '未选择';
1382
  qs('#folderStatusMobile').textContent = '未选择';
 
1521
  const fname = `image_${Date.now()}_${Math.random().toString(16).slice(2)}.jpg`;
1522
  updateCardWithImage(placeholder, dataUrl, p, fname);
1523
 
1524
+ let saved = false;
1525
  if (state.folderHandle) {
1526
+ try {
1527
+ saved = await saveToChosenFolder(fname, dataUrl);
1528
+ } catch(e) {
1529
+ saved = false;
1530
+ }
1531
+ }
1532
+ if (!saved) {
1533
+ downloadImageMobile(fname, dataUrl);
1534
  }
1535
 
1536
  if (state.sound) {
 
1640
  // 键盘事件
1641
  document.addEventListener('keydown', (e) => {
1642
  if (e.key === 'Enter' && !state.sidebarOpen) generate();
1643
+ if (e.key === 'Escape') {
1644
+ if (isViewerOpen()) {
1645
+ closeImageViewer();
1646
+ } else if (state.sidebarOpen) {
1647
+ closeSidebar();
1648
+ }
1649
+ }
1650
+ });// 响应式检测
1651
  window.addEventListener('resize', checkMobile);
1652
  checkMobile();
1653
 
server.js CHANGED
@@ -62,11 +62,17 @@ 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
  }
@@ -369,12 +375,10 @@ app.post('/api/generate', async (req, res) => {
369
  // Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility
370
  const variantCMinimal = {
371
  model: targetModel,
372
- input_args: {
373
- prompt: flat.prompt,
374
- size: `${flat.width}x${flat.height}`,
375
- steps: flat.num_inference_steps,
376
- ...(flat.seed !== null ? { seed: flat.seed } : {})
377
- }
378
  };
379
 
380
  // duplicate removed
@@ -457,78 +461,81 @@ app.post('/api/generate', async (req, res) => {
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);
474
- if (free.length) return (free[0].upstream_id || free[0].id || free[0].name);
475
- const any = list.find(m => (m.id || m.name || '').toLowerCase() !== key);
476
- if (any) return (any.upstream_id || any.id || any.name);
477
- return null;
478
- }
479
- const fallbackModel = chooseFallback(targetModel, localList);
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,
495
- error: e4.hint || e4.message || 'Upstream error',
496
- code: e4.code || 'UPSTREAM_ERROR',
497
- upstream_model: targetModel,
498
- fallback_model: fallbackModel
499
- });
 
 
 
 
500
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  }
502
- // success with fallback
503
- return res.json({
504
- ok: true,
505
- image: `data:${result.contentType};base64,${result.imageBase64}`,
506
- contentType: result.contentType,
507
- meta: {
508
- model: flat.model,
509
- upstream_model: targetModel,
510
- fallback_used: true,
511
- fallback_model: fallbackModel,
512
- width: flat.width,
513
- height: flat.height,
514
- guidance_scale: flat.guidance_scale,
515
- num_inference_steps: flat.num_inference_steps,
516
- seed: flat.seed
517
- },
518
- tried: result.tried
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
  }
530
  }
531
 
 
 
532
  return res.json({
533
  ok: true,
534
  image: `data:${result.contentType};base64,${result.imageBase64}`,
 
62
  useDefaults: true,
63
  directives: {
64
  "default-src": ["'self'"],
65
+ // Allow SPA inline scripts but forbid inline event attributes per CSP3
66
  "script-src": ["'self'", "'unsafe-inline'"],
67
+ "script-src-attr": ["'none'"],
68
+ // Permit external stylesheet from Baomitu CDN via style-src-elem
69
  "style-src": ["'self'", "'unsafe-inline'"],
70
+ "style-src-elem": ["'self'", "'unsafe-inline'", "https://lib.baomitu.com"],
71
  "img-src": ["'self'", "data:", "blob:"],
72
+ // Allow Baomitu icon fonts
73
+ "font-src": ["'self'", "data:", "https://lib.baomitu.com"],
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
  }
 
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
 
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}`,