PageSourceSearch

https://sinonim.org/txt/_image.js?47

js sinonim.org collected 2026-10-02 07:09:55 UTC 76,500 bytes, 1,881 lines download raw bytes

1var SELF      = '/data.php?image';
2var COSTS     = window.IMAGE_GENERATION_COSTS;
3var FORM_KEY  = 'ai_image_form';
4var PROMPT_KEY = 'ai_image_prompt:' + window.location.pathname;
5var IMG_KEY   = 'ai_image_result';
6var TASK_KEY  = 'ai_image_task';
7var POLL_MS   = 6000;
8var TASK_TTL  = 30 * 60 * 1000; // Covers the full OpenAI -> Nano Banana -> WAN -> Seedream chain.
9var pollTimer = null;
10var imagePollVersion = 0;
11var imageProgressTimer = null;
12var imageProgressStartedAt = 0;
13var imageProgressServerState = 'starting';
14var isPaidUser = false;
15var _btnEnabledAt = 0;
16var imageCooldownTimer = null;
17function imageCooldownUntil() {
18  var cookie = document.cookie.match(/(?:^|;\s*)image_generation_until=(\d+)/);
19  return Math.max(_btnEnabledAt, cookie ? Number(cookie[1]) * 1000 : 0);
20}
21var UPLOADS_KEY = 'ai_image_uploads';
22
23/* Список загруженных изображений: [{ id, previewSrc, kieUrl, status: 'uploading'|'ok'|'error', errMsg }] */
24var uploadedImages = [];
25
26function scheduleUploadExpiry(id, savedAt) {
27  var TTL = 2 * 60 * 60 * 1000;
28  var remaining = TTL - (Date.now() - savedAt);
29  if (remaining <= 0) { return; }
30  setTimeout(function() { removeImage(id); }, remaining);
31}
32
33function saveUploads() {
34  try {
35    var toSave = uploadedImages
36      .filter(function(img) { return img.status === 'ok' && img.kieUrl; })
37      .map(function(img) {
38        return { id: img.id, kieUrl: img.kieUrl, savedAt: img.savedAt || Date.now() };
39      });
40    localStorage.setItem(UPLOADS_KEY, JSON.stringify(toSave));
41  } catch(e) {}
42}
43
44function restoreUploads() {
45  try {
46    var saved = JSON.parse(localStorage.getItem(UPLOADS_KEY));
47    if (!saved || !saved.length) return;
48    var TTL_14 = 2 * 60 * 60 * 1000;
49    var now    = Date.now();
50    saved.forEach(function(item) {
51      if (item.savedAt && (now - item.savedAt) > TTL_14) return; // устарело — пропускаем
52      var restoredSavedAt = item.savedAt || now;
53      uploadedImages.push({
54        id:         item.id,
55        previewSrc: sugUrl(item.kieUrl),
56        kieUrl:     item.kieUrl,
57        savedAt:    restoredSavedAt,
58        status:     'ok'
59      });
60      scheduleUploadExpiry(item.id, restoredSavedAt);
61    });
62    renderPreviews();
63  } catch(e) {}
64}
65
66function getMaxImages() {
67  var ns = document.getElementById('nsmode');
68  if (ns && ns.checked) return 1;
69  var fm = document.getElementById('fastModel');
70  var max = (fm && fm.checked) ? 9 : 14;
71  var landing = window.imageLandingConfig;
72  if (landing && landing.maxImages) max = Math.min(max, parseInt(landing.maxImages, 10) || max);
73  return max;
74}
75
76function updateUploadMaxHint() {
77  var hint = document.getElementById('uploadMaxHint');
78  if (hint) hint.textContent = 'Нажмите, перетащите или вставьте изображения (Ctrl+V, от 1 до ' + getMaxImages() + ')';
79}
80
81function updateNsUploadWarn() {
82  var ns = document.getElementById('nsmode');
83  var nsWarn = document.getElementById('nsUploadWarn');
84  if (ns && ns.checked && !nsWarn) {
85    nsWarn = document.createElement('div');
86    nsWarn.id = 'nsUploadWarn';
87    nsWarn.style.cssText = 'margin-top:8px;padding:8px 12px;border-radius:8px;' +
88      'background:#fff8e1;border:1px solid #ffe082;font-size:13px;color:#7a5f00;line-height:1.5;';
89    nsWarn.innerHTML = '&#9888;&#65039;&nbsp;Для модели с минимальной модерацией допускается не более&nbsp;1&nbsp;исходного изображения. Если нужно больше, то уберите эту галочку.';
90    document.getElementById('uploadZone').insertAdjacentElement('afterend', nsWarn);
91  }
92  if (nsWarn) nsWarn.style.display = ns && ns.checked ? 'block' : 'none';
93}
94
95var MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ
96var PREPARED_FILE_SIZE = 19 * 1024 * 1024; // запас для серверной загрузки и WAN
97var _imgBlocked = false;
98var _ratioRequired = false;
99
100function loadImageForPrepare(file) {
101  return new Promise(function(resolve, reject) {
102    var src = URL.createObjectURL(file);
103    var image = new Image();
104    image.onload = function() {
105      URL.revokeObjectURL(src);
106      resolve(image);
107    };
108    image.onerror = function() {
109      URL.revokeObjectURL(src);
110      reject(new Error('Браузер не смог прочитать изображение'));
111    };
112    image.src = src;
113  });
114}
115
116function canvasToImageBlob(canvas, mime, quality) {
117  return new Promise(function(resolve) {
118    canvas.toBlob(function(blob) { resolve(blob); }, mime, quality);
119  });
120}
121
122async function prepareLargeImage(file) {
123  var image = await loadImageForPrepare(file);
124  var width = image.naturalWidth || image.width;
125  var height = image.naturalHeight || image.height;
126  if (!width || !height || width * height > 50000000) {
127    throw new Error('Слишком большое разрешение изображения');
128  }
129
130  var baseScale = Math.min(1, 4096 / Math.max(width, height));
131  var attempts = [
132    [baseScale, 0.88],
133    [baseScale * 0.85, 0.80],
134    [baseScale * 0.70, 0.72],
135    [baseScale * 0.55, 0.66]
136  ];
137  var originalBase = (file.name || 'image').replace(/\.[^.]+$/, '');
138
139  for (var i = 0; i < attempts.length; i++) {
140    var scale = Math.max(0.1, attempts[i][0]);
141    var canvas = document.createElement('canvas');
142    canvas.width = Math.max(1, Math.round(width * scale));
143    canvas.height = Math.max(1, Math.round(height * scale));
144    var ctx = canvas.getContext('2d');
145    if (!ctx) throw new Error('Браузер не поддерживает уменьшение изображений');
146    ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
147
148    var blob = await canvasToImageBlob(canvas, 'image/webp', attempts[i][1]);
149    if (!blob) blob = await canvasToImageBlob(canvas, 'image/jpeg', attempts[i][1]);
150    canvas.width = canvas.height = 1;
151    if (blob && blob.size > 0 && blob.size <= PREPARED_FILE_SIZE) {
152      var ext = blob.type === 'image/webp' ? '.webp' : '.jpg';
153      return { blob: blob, name: originalBase + '_prepared' + ext };
154    }
155  }
156  throw new Error('Не удалось уменьшить изображение до 19 МБ');
157}
158
159/* Отмечает кнопку «Сгенерировать» как заблокированную.
160   Оставляем её кликабельной, чтобы generate() мог показать причину. */
161function updateBtnState() {
162  var btn = document.getElementById('btn');
163  if (!btn) return;
164  _imgBlocked = uploadedImages.some(function(img) { return img.status !== 'ok'; });
165  btn.classList.toggle('is-blocked', _imgBlocked || _ratioRequired);
166  if (!btn.classList.contains('is-generating') && Date.now() >= imageCooldownUntil()) {
167    btn.disabled = false;
168  }
169}
170/* ────────────────────────────────
171   Upload zone
172──────────────────────────────── */
173var uploadZone = document.getElementById('uploadZone');
174var fileInput  = document.getElementById('fileInput');
175
176uploadZone.addEventListener('click', function(e) {
177  if (e.target.classList.contains('preview-remove')) return;
178  fileInput.click();
179});
180
181fileInput.addEventListener('change', function() {
182  handleFiles(this.files);
183  this.value = '';
184});
185
186/* Отдельная съёмка основной камерой, обычный выбор файла остаётся без изменений */
187(function addImageCameraButton() {
188  if (document.getElementById('imageCameraWrap')) return;
189
190  var cameraWrap = document.createElement('div');
191  cameraWrap.id = 'imageCameraWrap';
192  cameraWrap.style.cssText = 'margin-top:10px;text-align:center;';
193  cameraWrap.innerHTML =
194    '<input id="imageCameraInput" type="file" accept="image/*" capture="environment" style="display:none">' +
195    '<button id="imageCameraButton" class="upload-zone" type="button" style="box-sizing:border-box;width:100%;appearance:none;-webkit-appearance:none;font-family:inherit;color:inherit;">' +
196      '<span class="upload-zone-inner"><span class="upload-icon">&#x1F4F7;</span><span class="upload-hint"><strong>Или сделайте фото</strong></span></span>' +
197    '</button>';
198  uploadZone.insertAdjacentElement('afterend', cameraWrap);
199
200  var cameraInput = document.getElementById('imageCameraInput');
201  document.getElementById('imageCameraButton').addEventListener('click', function() {
202    cameraInput.value = '';
203    cameraInput.click();
204  });
205  cameraInput.addEventListener('change', function() {
206    if (this.files && this.files.length) handleFiles(this.files);
207    this.value = '';
208  });
209})();
210
211uploadZone.addEventListener('dragover', function(e) {
212  e.preventDefault();
213  this.classList.add('dragover');
214});
215uploadZone.addEventListener('dragleave', function() {
216  this.classList.remove('dragover');
217});
218uploadZone.addEventListener('drop', function(e) {
219  e.preventDefault();
220  this.classList.remove('dragover');
221  handleFiles(e.dataTransfer.files);
222});
223
224document.addEventListener('paste', function(e) {
225  var clipboard = e.clipboardData;
226  if (!clipboard) return;
227
228  var allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
229  var images = [];
230  Array.from(clipboard.items || []).forEach(function(item) {
231    if (item.kind !== 'file' || allowedTypes.indexOf(item.type) === -1) return;
232    var file = item.getAsFile();
233    if (file) images.push(file);
234  });
235
236  if (!images.length) return;
237  e.preventDefault();
238  uploadZone.classList.add('dragover');
239  setTimeout(function() { uploadZone.classList.remove('dragover'); }, 350);
240  handleFiles(images);
241});
242
243function handleFiles(files) {
244  var MAX = getMaxImages();
245  var current = uploadedImages.length;
246  var available = MAX - current;
247
248  if (available <= 0) {
249    alert('Максимум ' + MAX + ' изображений уже добавлено');
250    return;
251  }
252
253  var list = Array.from(files);
254
255  if (list.length > available) {
256    list = list.slice(0, available);
257    alert('Добавлено только ' + available + ' файла(ов) — достигнут лимит ' + MAX);
258  }
259
260  list.forEach(function(file) { uploadFile(file); });
261}
262
263function uploadFile(file) {
264  var id          = 'img_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
265  var previewSrc  = URL.createObjectURL(file);
266  var item        = { id: id, previewSrc: previewSrc, kieUrl: null, status: 'uploading', stage: 'uploading', errMsg: '' };
267  
268  uploadedImages.push(item);
269  renderPreviews();
270
271  // This template is shared by landing pages: only /image opts into preparation.
272  if (/^\/image\/?$/.test(window.location.pathname) && window.SinonimImagePrepare) {
273    item.stage = 'preparing';
274    renderPreviews();
275    SinonimImagePrepare.prepare(file, {
276      maxBytes: PREPARED_FILE_SIZE,
277      maxEdge: file.size > MAX_FILE_SIZE ? 4096 : 0,
278      allowResize: file.size > MAX_FILE_SIZE
279    }).then(function(prepared) {
280      if (uploadedImages.indexOf(item) === -1) return;
281      if (!prepared.prepared && file.size > MAX_FILE_SIZE) {
282        item.status = 'error';
283        item.errMsg = 'Браузер не смог подготовить файл. Выберите изображение размером до 20 МБ.';
284        renderPreviews();
285        return;
286      }
287      if (prepared.prepared) {
288        URL.revokeObjectURL(item.previewSrc);
289        item.previewSrc = URL.createObjectURL(prepared.blob);
290      }
291      // If Canvas fails, the original reaches the existing server normalizer.
292      item.stage = 'uploading';
293      renderPreviews();
294      sendImageFile(item, prepared.blob, prepared.name);
295    });
296    return;
297  }
298
299  if (file.size > MAX_FILE_SIZE) {
300    item.stage = 'preparing';
301    renderPreviews();
302    prepareLargeImage(file).then(function(prepared) {
303      item.stage = 'uploading';
304      renderPreviews();
305      sendImageFile(item, prepared.blob, prepared.name);
306    }).catch(function(error) {
307      item.status = 'error';
308      item.errMsg = error && error.message ? error.message : 'Не удалось уменьшить изображение';
309      renderPreviews();
310    });
311    return;
312  }
313
314  sendImageFile(item, file, file.name || 'image');
315}
316
317function sendImageFile(item, file, filename) {
318  var fd = new FormData();
319  fd.append('file', file, filename);
320
321  fetch(SELF + '&action=upload', { method: 'POST', body: fd })
322    .then(function(r) { return r.json(); })
323    .then(function(json) {
324      if (json.error) {
325        item.status = 'error';
326        item.errMsg = json.error;
327      } else {
328        item.kieUrl = json.url;
329        item.status = 'ok';
330        item.stage = 'ready';
331        item.savedAt = Date.now();
332        saveUploads();
333        scheduleUploadExpiry(item.id, item.savedAt);
334      }
335      renderPreviews();
336    })
337    .catch(function() {
338      item.status = 'error';
339      item.errMsg = 'Ошибка сети';
340      renderPreviews();
341    });
342}
343
344function removeImage(id) {
345  uploadedImages = uploadedImages.filter(function(img) { return img.id !== id; });
346  renderPreviews();
347  saveUploads();
348}
349
350function refineImage(kieUrl) {
351    if (!kieUrl) return;
352
353    // Очищаем все текущие изображения
354    uploadedImages = [];
355
356    // &#9889; Доработка при минимальной модерации → переключаем на быструю модель
357    var ns = document.getElementById('nsmode');
358    var fm = document.getElementById('fastModel');
359    if (ns && ns.checked && fm) {
360        fm.checked = true;
361        fm.dispatchEvent(new Event('change', { bubbles: true }));
362        var nsWarn = document.getElementById('nsUploadWarn');
363        if (nsWarn) nsWarn.style.display = 'none';
364    }
365
366    var id = 'img_ref_' + Date.now() + '_' + Math.random().toString(36).slice(2, 
3666);
367    uploadedImages.push({
368        id:         id,
369        previewSrc: sugUrl(kieUrl),
370        kieUrl:     kieUrl,
371        status:     'ok'
372    });
373    renderPreviews();
374    saveUploads();
375	document.getElementById('prompt').value = '';
376	saveForm();
377    document.getElementById('prompt').scrollIntoView({ behavior: 'smooth', block: 'center' });
378    setTimeout(function() { document.getElementById('prompt').focus(); }, 400);
379}
380
381function renderPreviews() {
382  var container = document.getElementById('uploadPreviews');
383  if (!uploadedImages.length) {
384    container.innerHTML = '';
385    updateBtnState();
386    return;
387  }
388
389  var html = '';
390  uploadedImages.forEach(function(img) {
391    html += '<div class="preview-item" id="pi_' + img.id + '">';
392    html += '<img src="' + img.previewSrc + '" onerror="sugProxyImageFallback(this)" alt="">';
393
394    if (img.status === 'uploading') {
395      html += '<div class="preview-overlay"' + (img.stage === 'preparing' ? ' style="flex-direction:column;gap:5px"' : '') + '><div class="preview-spinner"></div>';
396      if (img.stage === 'preparing') html += '<span style="font-size:10px;line-height:1;color:#fff">Подготавливаем…</span>';
397      html += '</div>';
398    } else if (img.status === 'error') {
399      html += '<div class="preview-error-badge" title="' + escAttr(img.errMsg || 'Ошибка') + '">&#10060;</div>';
400    }
401
402    html += '<button class="preview-remove" onclick="removeImage(\'' + img.id + '\')" title="Удалить">&#215;</button>';
403    html += '</div>';
404  });
405
406  container.innerHTML = html;
407  updateBtnState();
408}
409
410function getReadyImageUrls() {
411  return uploadedImages
412    .filter(function(img) { return img.status === 'ok' && img.kieUrl; })
413    .map(function(img) { return img.kieUrl; });
414}
415
416function hasPendingUploads() {
417  return uploadedImages.some(function(img) { return img.status === 'uploading'; });
418}
419
420/* Общий компонент также используется в генераторе видео. */
421var imagePromptImprover = window.initPromptImprover({
422  promptId: 'prompt',
423  buttonId: 'improvePromptBtn',
424  panelId: 'improvePromptPanel',
425  endpoint: SELF + '&action=improve_prompt',
426  target: 'image',
427  hasPendingUploads: hasPendingUploads,
428  getImageUrls: function() { return getReadyImageUrls().slice(0, 5); },
429  onBalance: function(balance) {
430    var balanceEl = document.querySelector('.zaprosovProNum');
431    if (balanceEl) balanceEl.textContent = balance;
432  },
433  onFreeRemaining: function(remaining) {
434    var costEl = document.querySelector('.prompt-improve-cost');
435    if (costEl) costEl.textContent = 'осталось бесплатно: ' + remaining;
436  }
437});
438
439function updateImprovePromptButton() {
440  imagePromptImprover.update();
441}
442
443/* ────────────────────────────────
444   Persist: форма
445──────────────────────────────── */
446var imageFastPreferenceKey = '';
447function imageModelSelection() {
448  var selection = {};
449  ['fastModel', 'textQuality', 'nsmode'].forEach(function(id) {
450    var el = document.getElementById(id);
451    selection[id] = !!(el && el.checked);
452  });
453  return selection;
454}
455
456function initImageFastPreference() {
457  var config = window.IMAGE_FAST_PREFERENCE;
458  var fm = document.getElementById('fastModel');
459  if (!config || !config.enabled || !(config.userId > 0) || !fm) return;
460  var key = 'imageFastPreferenceV1:' + config.userId;
461  var selection, first = false;
462  try {
463    var saved = localStorage.getItem(key);
464    if (saved !== null) {
465      selection = JSON.parse(saved);
466      if (!selection || ['fastModel', 'textQuality', 'nsmode'].some(function(id) { return typeof selection[id] !== 'boolean'; })) return;
467    } else {
468      if (!config.eligible) return;
469      selection = {fastModel:true, textQuality:false, nsmode:false};
470      // Если нельзя сохранить выбор, не включаем модель заново при каждом открытии.
471      localStorage.setItem(key, JSON.stringify(selection));
472      first = true;
473    }
474  } catch(e) { return; }
475  ['fastModel', 'textQuality', 'nsmode'].forEach(function(id) {
476    var el = document.getElementById(id);
477    if (el) el.checked = selection[id];
478  });
479  imageFastPreferenceKey = key;
480  var notice = document.getElementById('fastModelAutoNotice');
481  if (notice) notice.hidden = !first;
482}
483
484function saveForm() {
485  try {
486    localStorage.setItem(PROMPT_KEY, document.getElementById('prompt').value);
487    localStorage.setItem(FORM_KEY, JSON.stringify({
488      format:     document.getElementById('format').value,
489      resolution: document.getElementById('resolution').value,
490      ratio:      document.getElementById('ratio').value,
491      fastModel: document.getElementById('fastModel') ? document.getElementById('fastModel').
491checked : false,
492	  nsmode:       document.getElementById('nsmode')       ? document.getElementById('nsmode').checked       : false,
493	  textQuality:  document.getElementById('textQuality')  ? document.getElementById('textQuality').checked  : false,
494	  ruAudience: document.getElementById('ruAudience') ? document.getElementById('ruAudience').checked : true
495    }));
496  } catch(e) {}
497  if (imageFastPreferenceKey) {
498    try { localStorage.setItem(imageFastPreferenceKey, JSON.stringify(imageModelSelection())); } catch(e) {}
499  }
500  updateImprovePromptButton();
501}
502
503function restoreForm() {
504	var savedPrompt = null;
505	try { savedPrompt = localStorage.getItem(PROMPT_KEY); } catch(e) {}
506	if (savedPrompt !== null) document.getElementById('prompt').value = savedPrompt;
507
508	var state;
509	try { state = JSON.parse(localStorage.getItem(FORM_KEY)); } catch(e) {}
510	if (!state) return;
511	if (savedPrompt === null && state.page === window.location.pathname && state.prompt) {
512		document.getElementById('prompt').value = state.prompt;
513		try { localStorage.setItem(PROMPT_KEY, state.prompt); } catch(e) {}
514	}
515	if (state.format)     document.getElementById('format').value     = state.format;
516	if (state.resolution) document.getElementById('resolution').value = state.resolution;
517	if (state.ratio) {
518	document.getElementById('ratio').value = state.ratio;
519	document.querySelectorAll('.ratio-pill').forEach(function(p) {
520	  p.classList.toggle('active', p.dataset.value === state.ratio);
521	});
522	}
523	if (state.fastModel !== undefined) {
524	  var fm = document.getElementById('fastModel');
525	  if (fm) fm.checked = !!state.fastModel;
526	}
527	if (state.nsmode !== undefined) {
528	  var ns = document.getElementById('nsmode');
529	  if (ns) ns.checked = !!state.nsmode && !document.getElementById('goReg');
530	}
531	if (state.textQuality !== undefined) {
532	  var tq = document.getElementById('textQuality');
533	  if (tq) tq.checked = !!state.textQuality;
534	}
535	if (state.ruAudience !== undefined) {
536	    var ra = document.getElementById('ruAudience');
537	    if (ra) ra.checked = !!state.ruAudience;
538	}
539}
540
541/* ────────────────────────────────
542   Посадочные страницы изображений:
543   общие поля и пресеты без копирования JS в каждой статье
544──────────────────────────────── */
545function initImageLanding() {
546  var cfg = window.imageLandingConfig;
547  var wrap = document.querySelector('.textarea-wrap');
548  var promptEl = document.getElementById('prompt');
549  if (!cfg || !wrap || !promptEl || !Array.isArray(cfg.presets) || !cfg.presets.length) return;
550
551  var uploadLabel = document.querySelector('.upload-section .field-label');
552  if (uploadLabel && cfg.uploadLabel) uploadLabel.textContent = cfg.uploadLabel;
553
554  var panel = document.createElement('div');
555  panel.className = 'imageLandingPanel';
556  panel.style.cssText = 'margin-top:12px;';
557  var fields = {};
558
559  if (Array.isArray(cfg.fields) && cfg.fields.length) {
560    var fieldsBox = document.createElement('div');
561    fieldsBox.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-bottom:12px;padding:12px;border:1px solid #d9deea;border-radius:14px;background:#f8f9ff;';
562    cfg.fields.forEach(function(field) {
563      var label = document.createElement('label');
564      label.style.cssText = 'display:block;min-width:0;font-weight:700;color:#30364a;';
565      label.innerHTML = (field.icon || '') + (field.icon ? '&nbsp;' : '') + field.label + (field.required ? '&nbsp;*' : '');
566      var input;
567      if (field.type === 'select') {
568        input = document.createElement('select');
569        (field.options || []).forEach(function(option) {
570          var o = document.createElement('option');
571          if (typeof option === 'object') {
572            o.value = option.value;
573            o.textContent = option.label;
574          } else {
575            o.value = option;
576            o.textContent = option;
577          }
578          input.appendChild(o);
579        });
580      } else if (field.type === 'textarea') {
581        input = document.createElement('textarea');
582        input.rows = field.rows || 2;
583      } else {
584        input = document.createElement('input');
585        input.type = field.type || 'text';
586      }
587      input.id = field.id;
588      input.placeholder = field.placeholder || '';
589      input.value = field.value || '';
590      input.required = !!field.required;
591      input.style.cssText = 'box-sizing:border-box;width:100%;min-width:0;margin-top:7px;padding:10px 12px;border:1px solid #d9deea;border-radius:10px;background:#fff;color:#202637;font:inherit;';
592      label.appendChild(input);
593      fieldsBox.appendChild(label);
594      fields[field.id] = input;
595    });
596    panel.appendChild(fieldsBox);
597  }
598
599  if (cfg.presetsTitle) {
600    var presetTitle = document.createElement('div');
601    presetTitle.style.cssText = 'margin:2px 0 8px;font-weight:700;color:#30364a;';
602    presetTitle.innerHTML = cfg.presetsTitle;
603    panel.appendChild(presetTitle);
604  }
605
606  var presetsBox = document.createElement('div');
607  presetsBox.className = 'imageLandingPresets';
608  presetsBox.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;';
609  presetsBox.innerHTML = cfg.presets.map(function(item, index) {
610    return '<button type="button" class="ratio-pill" data-landing-preset="' + index + '">' + item[0] + '&nbsp;' + item[1] + '</button>';
611  }).join('');
612  panel.appendChild(presetsBox);
613
614  if (cfg.hint) {
615    var hint = document.createElement('div');
616    hint.style.cssText = 'margin-top:9px;font-size:13px;line-height:1.45;color:#667085;';
617    hint.textContent = cfg.hint;
618    panel.appendChild(hint);
619  }
620  wrap.appendChild(panel);
621
622  var currentPreset = 0;
623  function values() {
624    var result = {};
625    Object.keys(fields).forEach(function(id) { result[id] = fields[id].value.trim(); });
626    return result;
627  }
628  function makePrompt() {
629    var preset = cfg.presets[currentPreset];
630    var vals = values();
631    if (typeof cfg.promptBuilder === 'function') return cfg.promptBuilder(preset, vals);
632    var text = cfg.basePrompt ? cfg.basePrompt.replace('{preset}', preset[2]) : preset[2];
633    (cfg.fields || []).forEach(function(field) {
634      if (vals[field.id]) text += ' ' + (field.promptLabel || field.label) + ': ' + vals[field.id] + '.';
635    });
636    return text;
637  }
638  function updatePrompt() {
639    promptEl.value = makePrompt();
640    saveForm();
641    updateImprovePromptButton();
642  }
643  function setActive() {
644    presetsBox.querySelectorAll('[data-landing-preset]').forEach(function(button, index) {
645      button.classList.toggle('active', parseInt(button.dataset.landingPreset, 10) === currentPreset);
646    });
647  }
648
649  presetsBox.addEventListener('click', function(e) {
650    var button = e.target.closest('[data-landing-preset]');
651    if (!button) return;
652    currentPreset = parseInt(button.dataset.landingPreset, 10) || 0;
653    setActive();
654    updatePrompt();
655  });
656  Object.keys(fields).forEach(function(id) {
657    fields[id].addEventListener(fields[id].tagName === 'SELECT' ? 'change' : 'input', updatePrompt);
658  });
659
660  window.validateImageLanding = function() {
661    var fieldList = cfg.fields || [];
662    for (var i = 0; i < fieldList.length; i++) {
663      var field = fieldList[i];
664      if (field.required && !fields[field.id].value.trim()) {
665        setStatus('fail', '&#9888;&#65039;&nbsp;Заполните поле «' + field.label + '».');
666        fields[field.id].focus();
667        return false;
668      }
669    }
670    if (cfg.minImages) {
671      var readyCount = uploadedImages.filter(function(image) { return image.status === 'ok'; }).length;
672      if (readyCount < cfg.minImages) {
673        setStatus('fail', '&#9888;&#65039;&nbsp;' + (cfg.imageRequiredText || 'Добавьте исходное изображение.'));
674        document.getElementById('uploadZone').scrollIntoView({behavior:'smooth',block:'center'});
675        return false;
676      }
677    }
678    return true;
679  };
680
681  setActive();
682  if (!promptEl.value.trim()) updatePrompt();
683  if (cfg.ratio) {
684    var ratioButton = document.querySelector('#ratioPills .ratio-pill[data-value="' + cfg.ratio + '"]');
685    if (ratioButton) ratioButton.click();
686  }
687  updateUploadMaxHint();
688}
689
690/* ────────────────────────────────
691   Persist: активная задача
692──────────────────────────────── */
693function saveTask(taskId, format, startedAt) {
694  try { localStorage.setItem(TASK_KEY, JSON.stringify({ taskId: taskId, format: format, startedAt: startedAt || Date.now() })); } catch(e) {}
695}
696function loadTask() {
697  try { return JSON.parse(localStorage.getItem(TASK_KEY)); } catch(e) { return null; }
698}
699function clearTask() {
700  try { localStorage.removeItem(TASK_KEY); } catch(e) {}
701}
702
703function formatImageElapsed(seconds) {
704  seconds = Math.max(0, Math.floor(seconds || 0));
705  if (seconds < 60) return seconds + ' ' + pluralImageTime(seconds, 'секунда', 'секунды', 'секунд');
706  var minutes = Math.floor(seconds / 60);
707  var rest = seconds % 60;
708  return minutes + ' ' + pluralImageTime(minutes, 'минута', 'минуты', 'минут') + (rest ? ' ' + rest + ' сек.' : '');
709}
710
711function pluralImageTime(number, one, few, many) {
712  var n = Math.abs(number) % 100;
713  var n1 = n % 10;
714  if (n > 10 && n < 20) return many;
715  if (n1 > 1 && n1 < 5) return few;
716  if (n1 === 1) return one;
717  return many;
718}
719
720function estimatedImageProgress(seconds) {
721  var value;
722  if (seconds < 10) value = 4 + seconds * 2.1;
723  else if (seconds < 35) value = 25 + (seconds - 10) * 1.5;
724  else if (seconds < 75) value = 62 + (seconds - 35) * .55;
725  else if (seconds < 120) value = 84 + (seconds - 75) * .2;
726  else value = 93 + (seconds - 120) * .05;
727  if (imageProgressServerState === 'waiting' || imageProgressServerState === 'queuing') value = Math.min(value, 58);
728  return Math.min(97, Math.max(4, Math.round(value)));
729}
730
731function imageProgressStage(seconds) {
732  if (imageProgressServerState === 'waiting' || imageProgressServerState === 'queuing') return 'Ждём ответ нейросети…';
733  if (imageProgressServerState === 'retrying') return 'Переключаемся на быструю модель…';
734  if (seconds < 10) return 'Передаём описание нейросети…';
735  if (seconds < 35) return 'Подбираем композицию…';
736  if (seconds < 75) return 'Создаём изображение и мелкие детали…';
737  if (seconds < 100) return 'Улучшаем качество результата…';
738  return 'Почти закончили — сохраняем результат…';
739}
740
741function updateImageProgress() {
742  if (!imageProgressStartedAt) return;
743  var seconds = Math.max(0, Math.floor((Date.now() - imageProgressStartedAt) / 1000));
744  var percent = estimatedImageProgress(seconds);
745  var percentEl = document.getElementById('statusPercent');
746  var fill = document.getElementById('statusProgressFill');
747  var elapsed = document.getElementById('statusElapsed');
748  var stage = document.getElementById('statusStage');
749  if (percentEl) percentEl.textContent = percent + '%';
750  if (fill) fill.style.width = percent + '%';
751  if (elapsed) elapsed.textContent = 'Прошло ' + formatImageElapsed(seconds);
752  if (stage) stage.textContent = imageProgressStage(seconds);
753}
754
755function setGenerateButtonBusy(busy) {
756  var btn = document.getElementById('btn');
757  var label = document.getElementById('btnLabel');
758  if (!btn || !label) return;
759  btn.classList.toggle('is-generating', !!busy);
760  label.textContent = busy ? 'Создаём изображение…' : 'Сгенерировать';
761}
762
763function startImageProgress(startedAt) {
764  clearInterval(imageProgressTimer);
765  imageProgressStartedAt = Number(startedAt) || Date.now();
766  imageProgressServerState = 'starting';
767  var bar = document.getElementById('statusBar');
768  if (bar) {
769    bar.classList.remove('is-simple', 'is-ok', 'is-fail');
770    bar.classList.add('is-running');
771    bar.removeAttribute('data-progress-scrolled');
772  }
773  updateImageProgress();
774  imageProgressTimer = setInterval(updateImageProgress, 1000);
775  setGenerateButtonBusy(true);
776}
777
778function setImageProgressServerState(state) {
779  imageProgressServerState = state || 'generating';
780  updateImageProgress();
781}
782
783function finishImageProgress(success) {
784  clearInterval(imageProgressTimer);
785  imageProgressTimer = null;
786  if (!imageProgressStartedAt) return;
787  if (success) {
788    var percentEl = document.getElementById('statusPercent');
789    var fill = document.getElementById('statusProgressFill');
790    var stage = document.getElementById('statusStage');
791    if (percentEl) percentEl.textContent = '100%';
792    if (fill) fill.style.width = '100%';
793    if (stage) stage.textContent = 'Готово — изображение сохранено.';
794  }
795}
796
797/* ────────────────────────────────
798   Persist: результат
799──────────────────────────────── */
800function saveResult(imageUrl, format, ts, taskId) {
801  try { localStorage.setItem(IMG_KEY, JSON.stringify({ imageUrl: imageUrl, format: format, ts: ts || Date.now(), taskId: taskId || '' })); } catch(e) {}
802}
803function clearResult() {
804  try { localStorage.removeItem(IMG_KEY); } catch(e) {}
805  document.getElementById('result').innerHTML = '';
806}
807function restoreResult() {
808  var saved;
809  try { saved = JSON.parse(localStorage.getItem(IMG_KEY)); } catch(e) {}
810  if (!saved || !saved.imageUrl) return;
811  showResult(saved.imageUrl, saved.format || 'jpg', saved.ts, true, saved.taskId || '');
812}
813
814/* ────────────────────────────────
815   Ratio pills
816──────────────────────────────── */
817document.getElementById('ratioPills').addEventListener('click', function(e) {
818  var pill = e.target.closest('.ratio-pill');
819  if (!pill) return;
820  this.querySelectorAll('.ratio-pill').forEach(function(p) { p.classList.remove('active'); });
821  pill.classList.add('active');
822  document.getElementById('ratio').value = pill.dataset.value;
823  applyModelConstraints();
824  saveForm();
825});
826
827document.getElementById('resolution').addEventListener('change', function() {
828  var ns = document.getElementById('nsmode');
829  if (ns && ns.checked && this.value !== '1K') {
830    this.value = '1K';
831  }
832  applyModelConstraints();
833  saveForm();
834});
835document.getElementById('prompt').addEventListener('input', saveForm);
836document.getElementById('format').addEventListener('change', saveForm);
837document.getElementById('fastModel').addEventListener('change', saveForm);
838document.getElementById('nsmode').addEventListener('change', saveForm);
839document.getElementById('ruAudience').addEventListener('change', saveForm);
840
841document.getElementById('textQuality').addEventListener('change', function() {
842  if (this.checked) {
843    document.getElementById('fastModel').checked = false;
844    document.getElementById('nsmode').checked    = false;
845  }
846  updateUploadMaxHint();
847  updateNsUploadWarn();
848  applyModelConstraints();
849  saveForm();
850});
851
852function updateBtnCost() {
853  var res  = document.getElementById('resolution').value;
854  var cost = COSTS[res] || 8;
855  document.getElementById('btnCost').textContent = 'за ' + cost + ' PRO-баллов';
856}
857
858/* ────────────────────────────────
859   Ограничения моделей: блокируем недоступные
860   комбинации «соотношение + разрешение»
861──────────────────────────────── */
862var RES_ORDER = { '1K': 1, '2K': 2, '4K': 3 };
863/* GPT Image 2.5 Sunburst: 5:4 and 4:5 are unavailable in Kie.
864   Keep auto at 1K so the direct OpenAI fallback can honor explicit 2K/4K sizes. */
865var GPT_MAX_RES = { 'auto': '1K', '5:4': null, '4:5': null };
866function gptRatioSupportsResolution(ratio, resolution) {
867  var limit = Object.prototype.hasOwnProperty.call(GPT_MAX_RES, ratio) ? GPT_MAX_RES[ratio] : '4K';
868  return limit !== null && RES_ORDER[resolution] <= RES_ORDER[limit];
869}
870/* wan/2-7-image-pro: подмены соотношений на бэкенде */
871var WAN_RATIO_MAP = { 'auto': '1:1', '3:2': '4:3', '2:3': '3:4', '5:4': '4:3', '4:5': '3:4' };
872
873function applyModelConstraints() {
874  var tq    = document.getElementById('textQuality').checked;
875  var fm    = document.getElementById('fastModel').checked;
876  var ns    = document.getElementById('nsmode').checked;
877  var resEl = document.getElementById('resolution');
878  var ratioEl = document.getElementById('ratio');
879  var ratio = ratioEl.value;
880  var pills = document.querySelectorAll('#ratioPills .ratio-pill');
881  var hint  = document.getElementById('ratioHint');
882
883  /* сброс */
884  Array.from(resEl.options).forEach(function(o) { o.disabled = false; });
885  pills.forEach(function(p) { p.disabled = false; });
886  hint.style.display = 'none';
887  _ratioRequired = false;
888
889  if (!tq && !ratio) {
890    ratio = 'auto';
891    ratioEl.value = ratio;
892    pills.forEach(function(p) { p.classList.toggle('active', p.dataset.value === ratio); });
893  }
894
895  if (ns) {
896    /* qwen: только 1K (дублирует существующую логику nsmode) */
897    resEl.value = '1K';
898    Array.from(resEl.options).forEach(function(o) { o.disabled = o.value !== '1K'; });
899  } else if (tq) {
900    var res = resEl.value;
901
902    /* Высокое разрешение выбирается первым. Если текущее соотношение
903       несовместимо, снимаем выбор и просим выбрать сторону вручную. */
904    if (ratio && !gptRatioSupportsResolution(ratio, res)) {
905      ratioEl.value = '';
906      ratio = '';
907      pills.forEach(function(p) { p.classList.remove('active'); });
908    }
909
910    /* Unsupported ratios remain unavailable even at 1K. Keep the user's
911       resolution and ask for a new ratio instead of silently substituting it. */
912    pills.forEach(function(p) {
913      p.disabled = !gptRatioSupportsResolution(p.dataset.value, res);
914    });
915
916    _ratioRequired = !ratio;
917    if (_ratioRequired) {
918      hint.innerHTML = 'Для разрешения ' + res + ' выберите поддерживаемое соотношение сторон вручную.';
919      hint.style.display = 'block';
920    }
921  } else if (fm && WAN_RATIO_MAP[ratio]) {
922    hint.innerHTML = 'Быстрая модель не поддерживает соотношение ' +
923      (ratio === 'auto' ? '«Авто»' : ratio) +
924      ' &mdash; изображение будет создано в ' + WAN_RATIO_MAP[ratio] + '.';
925    hint.style.display = 'block';
926  } else if (!ratio) {
927    ratioEl.value = 'auto';
928    pills.forEach(function(p) { p.classList.toggle('active', p.dataset.value === 'auto'); });
929  }
930  updateBtnCost();
931  updateBtnState();
932}
933
934/* ────────────────────────────────
935   Polling
936──────────────────────────────── */
937var imageStatusRequests = {};
938var imageSeedreamDecisions = {};
939function requestImageStatus(taskId) {
940  // Active-task and history polling share one request and one confirmation dialog.
941  if (imageStatusRequests[taskId]) return imageStatusRequests[taskId];
942  var request = (async function() {
943    var url = SELF + '&action=status&taskId=' + encodeURIComponent(taskId);
944    var send = async function() {
945      var options;
946      if (imageSeedreamDecisions[taskId]) {
947        var body = new FormData();
948        body.append('seedream_2k', imageSeedreamDecisions[taskId]);
949        options = {method:'POST', body:body};
950      }
951      return (await fetch(url, options)).json();
952    };
953    var data = await send();
954    if (data.state === 'confirmation_required' && data.confirmation === 'seedream_2k') {
955      if (!imageSeedreamDecisions[taskId]) {
956        var pausedAt = Date.now();
957        imageSeedreamDecisions[taskId] = confirm(data.message) ? 'yes' : 'no';
958        var active = loadTask();
959        if (active && active.taskId === taskId) {
960          saveTask(taskId, active.format, active.startedAt + Date.now() - pausedAt);
961        }
962      }
963      data = await send();
964    }
965    return data;
966  })();
967  imageStatusRequests[taskId] = request;
968  var clear = function() { delete imageStatusRequests[taskId]; };
969  request.then(clear, clear);
970  return request;
971}
972
973function startPolling(taskId, format) {
974  var version=++imagePollVersion;
975  clearInterval(pollTimer);
976
977  pollTimer = setInterval(async function() {
978    if(version!==imagePollVersion) return;
979    var task = loadTask();
980    if (task && (Date.now() - task.startedAt) > TASK_TTL) {
981      clearInterval(pollTimer);
982      clearTask();
983      setStatus('fail', '&#10060;&nbsp;Превышено время ожидания. Попробуйте снова.');
984      enableBtn();
985      return;
986    }
987
988    try {
989      var data = await requestImageStatus(taskId);
990      if(version!==imagePollVersion) return;
991
992      if (data.error) {
993        clearInterval(pollTimer);
994        clearTask();
995        setStatus('fail', '&#10060;&nbsp;' + data.error);
996        enableBtn();
997        return;
998      }
999
1000      if (data.state === 'success' && data.imageUrl) {
1001        clearInterval(pollTimer);
1002        clearTask();
1003        var ts = Date.now();
1004        saveResult(data.imageUrl, format, ts, taskId);
1005        setStatus('ok', '&#10003;&nbsp;Изображение готово');
1006        showResult(data.imageUrl, format, ts, false, taskId);
1007        notifyImageReadyOnce();
1008        enableBtn();
1009
1010      } else if (data.state === 'retrying' && data.retryTaskId) {
1011        /* Первая модель нарушила политику — переключаемся на запасную */
1012        clearInterval(pollTimer);
1013        var activeTask = loadTask();
1014        saveTask(data.retryTaskId, format, activeTask && activeTask.startedAt ? activeTask.startedAt : imageProgressStartedAt);
1015        setImageProgressServerState('retrying');
1016        setStatus('running', '&#9881;&nbsp;Повторная генерация...');
1017        startPolling(data.retryTaskId, format);
1018
1019      } else if (data.state === 'failed') {
1020        clearInterval(pollTimer);
1021        clearTask();
1022        setStatus('fail', '&#10060;&nbsp;
1022' + (data.failMsg || 'Ошибка генерации'));
1023        enableBtn();
1024
1025      } else {
1026        var labels = { waiting: '&#9203;&nbsp;В очереди...', queuing: '&#9203;&nbsp;В очереди...', generating: '&#9881;&nbsp;Генерация...', retrying: '&#9881;&nbsp;Повторная генерация...' };
1027        setImageProgressServerState(data.state);
1028        setStatus('running', labels[data.state] || '&#9203;&nbsp;' + data.state);
1029      }
1030    } catch(e) {}
1031  }, POLL_MS);
1032}
1033
1034function tryResumeTask() {
1035  var task = loadTask();
1036  if (!task || !task.taskId) return false;
1037  if ((Date.now() - task.startedAt) > TASK_TTL) { clearTask(); return false; }
1038  var elapsed = Math.round((Date.now() - task.startedAt) / 1000);
1039  startImageProgress(task.startedAt);
1040  setImageProgressServerState('waiting');
1041  setStatus('running', '&#9203;&nbsp;Возобновляем ожидание... (уже ' + elapsed + ' с)');
1042  document.getElementById('btn').disabled = true;
1043  _btnEnabledAt = Math.max(_btnEnabledAt, task.startedAt + 20000);
1044  startPolling(task.taskId, task.format || 'jpg');
1045  enableBtn();
1046  return true;
1047}
1048
1049/* ────────────────────────────────
1050   Init
1051──────────────────────────────── */
1052restoreForm();
1053initImageFastPreference();
1054initImageLanding();
1055updateImprovePromptButton();
1056applyModelConstraints();
1057updateNsUploadWarn();
1058
1059(function() {
1060  var ns = document.getElementById('nsmode');
1061  if (ns && ns.checked) {
1062    var resEl = document.getElementById('resolution');
1063    resEl.value = '1K';
1064    Array.from(resEl.options).forEach(function(o) { o.disabled = o.value !== '1K'; });
1065    updateBtnCost();
1066  }
1067})();
1068
1069(function() {
1070  var tq = document.getElementById('textQuality');
1071  if (tq && tq.checked) {
1072    updateBtnCost();
1073  }
1074})();
1075
1076// Синхронизируем hint с восстановленным состоянием fastModel
1077(function() {
1078  updateUploadMaxHint();
1079})();
1080var puReady = fetch('/data.php?pu')
1081  .then(function(r){ return r.text(); })
1082  .then(function(t){ isPaidUser = (t.trim() === '1'); })
1083  .catch(function(){});
1084
1085puReady.then(function() {
1086  restoreUploads();
1087  if (!tryResumeTask()) restoreResult();
1088});
1089
1090/* ────────────────────────────────
1091   Done-звук (по аналогии с генератором треков)
1092──────────────────────────────── */
1093var doneAudioCtx      = null;
1094var doneAudioUnlocked = false;
1095
1096function initDoneAudioFromGesture() {
1097  try {
1098    var AC = window.AudioContext || window.webkitAudioContext;
1099    if (!AC) return;
1100    if (!doneAudioCtx) doneAudioCtx = new AC();
1101    if (doneAudioCtx.state === 'suspended') {
1102      doneAudioCtx.resume()
1103        .then(function() { doneAudioUnlocked = true; })
1104        .catch(function() {});
1105    } else {
1106      doneAudioUnlocked = true;
1107    }
1108  } catch(e) {}
1109}
1110
1111function playDoneSound() {
1112  try {
1113    if (!doneAudioCtx || !doneAudioUnlocked) return;
1114    var ctx = doneAudioCtx;
1115    var now = ctx.currentTime;
1116
1117    function beep(freq, start, dur) {
1118      var o = ctx.createOscillator();
1119      var g = ctx.createGain();
1120      o.type = 'triangle';
1121      o.frequency.setValueAtTime(freq, start);
1122      g.gain.setValueAtTime(0.0001, start);
1123      g.gain.exponentialRampToValueAtTime(0.8, start + 0.01);
1124      g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
1125      o.connect(g);
1126      g.connect(ctx.destination);
1127      o.start(start);
1128      o.stop(start + dur + 0.02);
1129    }
1130    beep(880,  now,        0.18);
1131    beep(1320, now + 0.20, 0.22);
1132    alert('Изображение готово');
1133  } catch(e) {}
1134}
1135
1136var _imageSoundPlayed = false;
1137
1138function notifyImageReadyOnce() {
1139  if (_imageSoundPlayed) return;
1140  _imageSoundPlayed = true;
1141  playDoneSound();
1142}
1143
1144/* ────────────────────────────────
1145   Генерация
1146──────────────────────────────── */
1147async function generate() {
1148  if (Date.now() < imageCooldownUntil()) { enableBtn(); return; }
1149  initDoneAudioFromGesture(); // ← добавить первой строкой
1150  _imageSoundPlayed = false;  // ← сбрасываем флаг для новой генерации
1151  if (_ratioRequired) {
1152    alert('Сначала выберите поддерживаемое соотношение сторон.');
1153    return;
1154  }
1155  if (window.validateImageLanding && !window.validateImageLanding()) return;
1156  var prompt = document.getElementById('prompt').value.trim();
1157  if (!prompt) { document.getElementById('prompt').focus(); return; }
1158
1159  if (hasPendingUploads()) {
1160    setStatus('running', '&#9201;&nbsp;Ожидаем завершения загрузки изображений...');
1161    return;
1162  }
1163
1164  var failedImage = uploadedImages.find(function(img) { return img.status === 'error'; });
1165  if (failedImage) {
1166    setStatus('fail', '&#10060;&nbsp;Не удалось подготовить изображение: ' + escHtml(failedImage.errMsg || 'неизвестная ошибка') + '. Удалите его кнопкой &#215; и попробуйте снова.');
1167    return;
1168  }
1169
1170  ++imagePollVersion; // Ignore late responses from the previous generation.
1171  clearInterval(pollTimer);
1172  clearTask();
1173  document.getElementById('result').innerHTML = '';
1174
1175  var format     = document.getElementById('format').value;
1176  var resolution = document.getElementById('resolution').value;
1177  var ratio      = document.getElementById('ratio').value;
1178  var imageUrls  = getReadyImageUrls();
1179
1180  var generationStartedAt = Date.now();
1181  startImageProgress(generationStartedAt);
1182  setStatus('running', '&#9201;&nbsp;Отправка задачи...');
1183  document.getElementById('btn').disabled = true;
1184  _btnEnabledAt = Date.now() + 20000;
1185
1186  var fd = new FormData();
1187  fd.append('prompt',     prompt);
1188  fd.append('format',     format);
1189  fd.append('resolution', resolution);
1190  fd.append('ratio',      ratio);
1191  var fastModel = document.getElementById('fastModel');
1192  if (fastModel && fastModel.checked) {
1193      fd.append('fast_model', '1');
1194  }
1195  var nsmode = document.getElementById('nsmode');
1196  if (nsmode && nsmode.checked) {
1197      fd.append('ns_mode', '1');
1198  }
1199  
1200  var ruAudience = document.getElementById('ruAudience');
1201	if (ruAudience && ruAudience.checked) {
1202	    fd.append('ru_audience', '1');
1203  }
1204  
1205  var textQuality = document.getElementById('textQuality');
1206  if (textQuality && textQuality.checked) {
1207    fd.append('text_quality', '1');
1208  }
1209  
1210  if (imageUrls.length > 0) {
1211    fd.append('image_urls', JSON.stringify(imageUrls));
1212  }
1213
1214  try {
1215    var res  = await fetch(SELF + '&action=create', { method: 'POST', body: fd });
1216    var json = await res.json();
1217
1218    if (json.state === 'confirmation_required' && json.confirmation === 'seedream_2k') {
1219      if (!confirm(json.message)) {
1220        setStatus('fail', 'Переход на 2K отменён. PRO-баллы не списаны.');
1221        enableBtn();
1222        return;
1223      }
1224      fd.append('seedream_2k', 'yes');
1225      generationStartedAt = Date.now();
1226      startImageProgress(generationStartedAt);
1227      res = await fetch(SELF + '&action=create', {method:'POST', body:fd});
1228      json = await res.json();
1229    }
1230
1231    if (json.retry_after) _btnEnabledAt = Math.max(_btnEnabledAt, Date.now() + Number(json.retry_after) * 1000);
1232    if (json.error) {
1233      setStatus('fail', '&nbsp;' + json.error);
1234      enableBtn();
1235      return;
1236    }
1237
1238    saveTask(json.taskId, format, generationStartedAt);
1239    setImageProgressServerState('waiting');
1240    setStatus('running', '&#9203;&nbsp;Задача принята, ожидаем результат...');
1241    startPolling(json.taskId, format);
1242    enableBtn(); // A new request is allowed after the server-wide 20-second interval.
1243
1244  } catch(e) {
1245    setStatus('fail', '&#10060;&nbsp;Ошибка сети');
1246    enableBtn();
1247  }
1248}
1249
1250/* ────────────────────────────────
1251   UI helpers
1252──────────────────────────────── */
1253function enableBtn() {
1254  clearTimeout(imageCooldownTimer);
1255  var delay = Math.max(0, imageCooldownUntil() - Date.now());
1256  var btn = document.getElementById('btn');
1257  function finishEnable() {
1258    if (Date.now() < imageCooldownUntil()) { enableBtn(); return; }
1259    btn.disabled = false;
1260    setGenerateButtonBusy(false);
1261    updateBtnState();
1262  }
1263  if (delay === 0) { finishEnable(); return; }
1264  btn.disabled = true;
1265  imageCooldownTimer = setTimeout(finishEnable, delay);
1266}
1267
1268function sugUrl(img){ return 'https://a.sinonim.org/sug.php?img=' + encodeURIComponent(img) + (isPaidUser ? '&paidUser' : ''); }
1269
1270function sugDow(img){ return 'https://a.sinonim.org/sug.php?dow&img=' + encodeURIComponent(img) + (isPaidUser ? '&paidUser' : ''); }
1271
1272function imageOutputPresetButton(img, className) {
1273  var preset = window.imageOutputPreset;
1274  if (!preset || !img) return '';
1275  var label = preset.buttonHtml || '&#128229;&nbsp;Скачать подготовленное изображение';
1276  var style = 'cursor:pointer;font-family:inherit' + (preset.enabled === false ? ';display:none' : '');
1277  return '<button type="button" class="' + className + ' image-output-preset-btn image-download-control" style="' + style +
1278    '" onclick="downloadImageOutputPreset(this)">' + label + '</button>';
1279}
1280
1281function loadImageFromResult(src) {
1282  return new Promise(function(resolve, reject) {
1283    var image = new Image();
1284    image.onload = function() { resolve(image); };
1285    image.onerror = function() { reject(new Error('Не удалось загрузить изображение')); };
1286    image.src = src;
1287  });
1288}
1289
1290function imageOutputPresetSource(button) {
1291  var container = button.closest('.hist-card') || button.closest('#result');
1292  var preview = container ? container.querySelector('img') : null;
1293  if (!preview) throw new Error('Не удалось найти изображение результата');
1294  if (preview.complete && preview.naturalWidth) return Promise.resolve(preview);
1295
1296  var src = preview.currentSrc || preview.src || '';
1297  if (!src) throw new Error('Не удалось найти адрес изображения результата');
1298  return loadImageFromResult(src);
1299}
1300
1301async function outputPresetBlob(canvas, preset) {
1302  var quality = Number(preset.quality) || 0.92;
1303  var mime = preset.mime || 'image/jpeg';
1304  var maxBytes = Number(preset.maxBytes) || 0;
1305  var blob = null;
1306
1307  for (var i = 0; i < 5; i++) {
1308    blob = await canvasToImageBlob(canvas, mime, quality);
1309    if (!blob || !blob.size) throw new Error('Не удалось подготовить файл');
1310    if (!maxBytes || blob.size <= maxBytes) return blob;
1311    quality = Math.max(0.55, quality - 0.10);
1312  }
1313  throw new Error('Не удалось уменьшить файл до допустимого размера');
1314}
1315
1316// Ten-second repeat-download cooldown. This is not file-transfer progress.
1317var imageDownloadStates = new WeakMap();
1318
1319function beginImageDownload(button) {
1320  var existing = imageDownloadStates.get(button);
1321  if (existing) {
1322    if (existing.doneAt && Date.now() >= Math.max(existing.unlockAt, existing.doneAt + 4000)) existing.reset();
1323    else return null;
1324  }
1325  var original = {
1326    html: button.innerHTML,
1327    disabled: button.disabled,
1328    ariaDisabled: button.getAttribute('aria-disabled'),
1329    ariaBusy: button.getAttribute('aria-busy')
1330  };
1331  var state = {unlockAt: Date.now() + 10000, doneAt: 0, timer: null};
1332  imageDownloadStates.set(button, state);
1333  button.classList.add('image-download-busy');
1334  button.setAttribute('aria-disabled', 'true');
1335  button.setAttribute('aria-busy', 'true');
1336  if ('disabled' in button) button.disabled = true;
1337  button.innerHTML = '<span class="image-download-fill" aria-hidden="true"></span>' +
1338    '<span class="image-download-label" role="status" aria-live="polite"></span>';
1339  var label = button.querySelector('.image-download-label');
1340
1341  state.reset = function() {
1342    clearInterval(state.timer);
1343    imageDownloadStates.delete(button);
1344    button.classList.remove('image-download-busy', 'image-download-started');
1345    button.innerHTML = original.html;
1346    if ('disabled' in button) button.disabled = original.disabled;
1347    ['aria-disabled', 'aria-busy'].forEach(function(name) {
1348      var value = name === 'aria-disabled' ? original.ariaDisabled : original.ariaBusy;
1349      if (value === null) button.removeAttribute(name); else button.setAttribute(name, value);
1350    });
1351  };
1352  function update() {
1353    if (!button.isConnected) { state.reset(); return; }
1354    var now = Date.now();
1355    if (state.doneAt && now >= Math.max(state.unlockAt, state.doneAt + 4000)) { state.reset(); return; }
1356    var started = state.doneAt && now < state.doneAt + 4000;
1357    button.classList.toggle('image-download-started', !!started);
1358    var text = started ? 'Скачивание началось' : state.doneAt
1359      ? 'Файл будет в загрузках'
1360      : 'Подготавливаем файл…';
1361    if (label.textContent !== text) label.textContent = text;
1362  }
1363  state.started = function() {
1364    state.doneAt = Date.now();
1365    button.setAttribute('aria-busy', 'false');
1366    update();
1367  };
1368  state.timer = setInterval(update, 100);
1369  update();
1370  return state;
1371}
1372
1373function imageDownloadClick(event, button) {
1374  var state = beginImageDownload(button);
1375  if (!state) { if (event) event.preventDefault(); return false; }
1376  try {
1377    // Preserve the existing download URL and local-proxy fallback.
1378    var result = sugProxyNavigate(event, button, function(url) {
1379      var frame = document.createElement('iframe');
1380      frame.hidden = true;
1381      frame.title = 'Скачивание изображения';
1382      frame.setAttribute('aria-hidden', 'true');
1383      frame.src = url;
1384      document.body.appendChild(frame);
1385      setTimeout(function() { frame.remove(); }, 600000);
1386    });
1387    state.started();
1388    return result;
1389  } catch (e) {
1390    state.reset();
1391    if (event) event.preventDefault();
1392    return false;
1393  }
1394}
1395
1396async function downloadImageOutputPreset(button) {
1397  var preset = window.imageOutputPreset;
1398  if (!preset || preset.enabled === false || !button || button.disabled) return;
1399
1400  var width = parseInt(preset.width, 10);
1401  var height = parseInt(preset.height, 10);
1402  if (!width || !height) {
1403    alert('Не заданы размеры подготовленного изображения.');
1404    return;
1405  }
1406
1407  var downloadState = beginImageDownload(button);
1408  if (!downloadState) return;
1409
1410  try {
1411    var image = await imageOutputPresetSource(button);
1412    var sourceWidth = image.naturalWidth || image.width;
1413    var sourceHeight = image.naturalHeight || image.height;
1414    var targetRatio = width / height;
1415    var sourceRatio = sourceWidth / sourceHeight;
1416    var sx = 0;
1417    var sy = 0;
1418    var sw = sourceWidth;
1419    var sh = sourceHeight;
1420
1421    if (sourceRatio > targetRatio) {
1422      sw = sourceHeight * targetRatio;
1423      sx = (sourceWidth - sw) / 2;
1424    } else if (sourceRatio < targetRatio) {
1425      sh = sourceWidth / targetRatio;
1426      sy = (sourceHeight - sh) / 2;
1427    }
1428
1429    var canvas = document.createElement('canvas');
1430    canvas.width = width;
1431    canvas.height = height;
1432    var ctx = canvas.getContext('2d');
1433    if (!ctx) throw new Error('Браузер не поддерживает подготовку изображения');
1434    ctx.imageSmoothingEnabled = true;
1435    ctx.imageSmoothingQuality = 'high';
1436    ctx.drawImage(image, sx, sy, sw, sh, 0, 0, width, height);
1437
1438    var blob = await outputPresetBlob(canvas, preset);
1439    canvas.width = canvas.height = 1;
1440    var objectUrl = URL.createObjectURL(blob);
1441    var link = document.createElement('a');
1442    link.href = objectUrl;
1443    link.download = preset.fileName || 'prepared-image.jpg';
1444    document.body.appendChild(link);
1445    link.click();
1446    downloadState.started();
1447    link.remove();
1448    setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 1000);
1449  } catch (e) {
1450    downloadState.reset();
1451    alert(e && e.message ? e.message : 'Не удалось подготовить изображение. Попробуйте ещё раз.');
1452  }
1453}
1454
1455
1456function setStatus(type, html) {
1457  var bar  = document.getElementById('statusBar');
1458  var dot  = document.getElementById('statusDot');
1459  var text = document.getElementById('statusText');
1460  var title = document.getElementById('statusTitle');
1461  var hadProgress = !!imageProgressStartedAt;
1462  bar.style.display = 'flex';
1463  dot.className     = 'status-dot ' + type;
1464  text.innerHTML    = html;
1465  bar.className = 'status-bar is-' + type + ((!hadProgress || type === 'fail') ? ' is-simple' : '');
1466
1467  if (type === 'ok') {
1468    finishImageProgress(true);
1469    if (title) title.textContent = 'Изображение готово';
1470  } else if (type === 'fail') {
1471    finishImageProgress(false);
1472    if (title) title.textContent = 'Не удалось создать изображение';
1473  } else if (title) {
1474    title.textContent = 'Создаём изображение';
1475  }
1476
1477  if (type === 'running' && hadProgress && window.innerWidth <= 700 && !bar.hasAttribute('data-progress-scrolled')) {
1478    bar.setAttribute('data-progress-scrolled', '1');
1479    setTimeout(function(){ bar.scrollIntoView({behavior:'smooth', block:'center'}); }, 80);
1480  }
1481
1482  if (type !== 'running') {
1483    imageProgressStartedAt = 0;
1484    imageProgressServerState = 'starting';
1485  }
1486}
1487
1488var imageThumbUpSvg = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
1489var imageThumbDownSvg = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/></svg>';
1490
1491function imageRatingHtml(taskId, rating) {
1492  if (!taskId) return '';
1493  return '<div class="unitRating imageRating" role="group" aria-label="Оценить изображение" data-taskid="' + escAttr(taskId) + '">'
1494    + '<span class="unitRatingVote unitRatingVoteUp' + (rating === 1 ? ' is-active' : '') + '">'
1495    + '<a href="#" onclick="rateImage(this,1);return false;" class="likeA unitRatingAction" title="Нравится" aria-label="Нравится">' + imageThumbUpSvg + '</a></span>'
1496    + '<span class="unitRatingDivider" aria-hidden="true"></span>'
1497    + '<span class="unitRatingVote unitRatingVoteDown' + (rating === -1 ? ' is-active' : '') + '">'
1498    + '<a href="#" onclick="rateImage(this,-1);return false;" class="likeA unitRatingAction" title="Не нравится" aria-label="Не нравится">' + imageThumbDownSvg + '</a></span></div>';
1499}
1500
1501function updateImageRating(group, rating) {
1502  var taskId = group.dataset.taskid;
1503  document.querySelectorAll('.imageRating').forEach(function(item){
1504    if (item.dataset.taskid !== taskId) return;
1505    item.querySelector('.unitRatingVoteUp').classList.toggle('is-active', rating === 1);
1506    item.querySelector('.unitRatingVoteDown').classList.toggle('is-active', rating === -1);
1507    item.dataset.rating = String(rating);
1508  });
1509}
1510
1511function loadImageRating(group) {
1512  var taskId = group && group.dataset.taskid;
1513  if (!taskId) return;
1514  fetch(SELF + '&action=rating&taskId=' + encodeURIComponent(taskId))
1515    .then(function(r){ return r.json(); })
1516    .then(function(json){ if (json.ok) updateImageRating(group, Number(json.rating) || 0); })
1517    .catch(function(){});
1518}
1519
1520function rateImage(link, vote) {
1521  var group = link.closest('.imageRating');
1522  if (!group || group.dataset.loading === '1') return;
1523  group.dataset.loading = '1';
1524  var fd = new FormData();
1525  fd.append('taskId', group.dataset.taskid || '');
1526  fd.append('vote', String(vote));
1527  fetch(SELF + '&action=rating', {method:'POST', body:fd})
1528    .then(function(r){ return r.json(); })
1529    .then(function(json){ if (json.ok) updateImageRating(group, Number(json.rating) || 0); })
1530    .finally(function(){ group.dataset.loading = '0'; });
1531}
1532
1533function showResult(url, fmt, ts, isRestored, taskId) {
1534  var dateStr = '';
1535  if (ts) {
1536    var d = new Date(ts);
1537    dateStr = d.toLocaleDateString('ru-RU') + ' в ' + d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
1538  }
1539  var savedHtml = (isRestored && dateStr) ? '<div class="result-meta">&#128336;&nbsp;Сохранено ' + dateStr + '</div>' : '';
1540  var metaHtml = (savedHtml || taskId) ? '<div class="result-meta-row">' + savedHtml + imageRatingHtml(taskId, 0) + '</div>' : '';
1541  var rawUrl = url;
1542  url = url.replace('storage.yandexcloud.net', 'hostname4');
1543  url = url.replace('tempfile.aiquickdraw.com', 'hostname');
1544  document.getElementById('result').innerHTML =
1545    '<div class="result-img-wrap">'
1546    +   '<a href="' + sugUrl(url) + '" target="_blank" onclick="return sugProxyNavigate(event,this)">'
1547	+     '<img src="' + sugUrl(url) + '" onerror="sugProxyImageFallback(this)" alt="Результат генерации">'
1548    +   '</a>'
1549    + '</div>'
1550    + metaHtml
1551    + '<div class="result-actions">'
1552    +   imageOutputPresetButton(rawUrl, 'btn-secondary')
1553    +   '<a class="btn-secondary image-download-control" href="' + sugDow(url) + '" download onclick="return imageDownloadClick(event,this)">&#128229;&nbsp;Скачать ' + fmt.toUpperCase() + '</a>'
1554    +   '<a class="btn-secondary" href="' + sugUrl(url) + '" target="_blank" onclick="return sugProxyNavigate(event,this)">&#128444;&#65039;&nbsp;Открыть</a>'
1555    +   '<a class="btn-secondary firstGen" href="/price" onclick="return handleWatermarkClick(event,this)">Убрать водяной знак</a>'
1556+   '<a class="btn-secondary" style="cursor:pointer" data-kieurl="' + escAttr(rawUrl) + '" onclick="refineImage(this.dataset.kieurl)">&#9998;&#65039;&nbsp;Доработать</a>'    
1557+   '<button class="btn-clear" onclick="clearResult()">&#10005;&nbsp;Удалить</button>'
1558    + '</div>';
1559  var currentRating = document.querySelector('#result .imageRating');
1560  if (currentRating) loadImageRating(currentRating);
1561}
1562
1563/* ────────────────────────────────
1564   История генераций
1565──────────────────────────────── */
1566var historyOffset  = 0;
1567var historyLoading = false;
1568var historyTotal   = 0;
1569var HISTORY_LIMIT  = 20;
1570
1571function loadHistory(append) {
1572  if (historyLoading) return;
1573  historyLoading = true;
1574
1575  if (!append) {
1576    historyOffset = 0;
1577    document.getElementById('historyGrid').innerHTML = '';
1578  }
1579
1580  fetch(SELF + '&action=history&offset=' + historyOffset)
1581    .then(function(r){ return r.json(); })
1582    .then(function(json){
1583      historyLoading = false;
1584      var items = json.items || [];
1585
1586      if (!append && items.length === 0) {
1587        document.getElementById('historySection').style.display = 'none';
1588        return;
1589      }
1590
1591      document.getElementById('historySection').style.display = 'block';
1592      historyOffset += items.length;
1593
1594	  var grid = document.getElementById('historyGrid');
1595      items.forEach(function(item){
1596        grid.insertAdjacentHTML('beforeend', renderHistCard(item));
1597      });
1598
1599      // Запускаем опрос задач, которые ещё в обработке
1600      pollStaleTasksFromHistory(items);
1601
1602      /* Кнопка «ещё»: показываем если вернулось ровно HISTORY_LIMIT */
1603      document.getElementById('historyMore').style.display =
1604        (items.length >= HISTORY_LIMIT) ? 'block' : 'none';
1605
1606      /* Счётчик */
1607      historyTotal += items.length;
1608      document.getElementById('historyCount').textContent =
1609        historyTotal + '\u00a0записей';
1610    })
1611    .catch(function(){ historyLoading = false; });
1612}
1613
1614function renderHistCard(item) {
1615  var rawResultUrl = item.result_url || '';
1616  item.result_url = rawResultUrl
1617	.replace('tempfile.aiquickdraw.com', 'hostname')
1618	.replace('storage.yandexcloud.net', 'hostname4');
1619  var proxy = sugUrl(item.result_url || '');
1620  var dow   = sugDow(item.result_url || '');
1621
1622  /* Миниатюра */
1623  var thumbHtml = item.result_url
1624    ? '<img class="hist-thumb" src="' + proxy + '" onerror="sugProxyImageFallback(this)" alt="" loading="lazy">'
1625    : '<div class="hist-thumb-placeholder">&#128444;&#65039;</div>';
1626
1627  /* Промпт */
1628  var promptHtml = item.prompt
1629    ? '<div class="hist-prompt" title="Нажмите, чтобы развернуть/свернуть" onclick="this.classList.toggle(\'expanded\')">' + escHtml(item.prompt) + '</div>'
1630    : '';
1631
1632  /* Бейджи метаданных */
1633  var badges = '';
1634  if (item.resolution) badges += '<span class="hist-badge">' + escHtml(item.resolution) + '</span>';
1635  if (item.format)     badges += '<span class="hist-badge">' + escHtml(item.format.toUpperCase()) + '</span>';
1636  if (item.ratio && item.ratio !== 'auto')
1637                       badges += '<span class="hist-badge">' + escHtml(item.ratio) + '</span>';
1638  if (item.is_free)    badges += '<span class="hist-badge free">Бесплатно</span>';
1639  else if (item.pro_cost > 0) badges += '<span class="hist-badge">' + item.pro_cost + '&#160;PRO</span>';
1640
1641  if (item.status === 'pending' || item.status === 'processing')
1642    badges += '<span class="hist-badge pending">&#9203;&#160;В обработке</span>';
1643  else if (item.status === 'failed')
1644    badges += '<span class="hist-badge failed">&#10060;&#160;Ошибка</span>';
1645
1646  /* Дата */
1647  var dateStr = '';
1648  if (item.created_at) {
1649    var d = new Date(item.created_at.replace(' ', 'T') + 'Z');
1650    dateStr = d.toLocaleDateString('ru-RU') + '&#160;' +
1651              d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
1652    badges += '<span class="hist-badge"' + (item.result_url ? '' : ' style="margin-left:auto;"') + '>
1652' + dateStr + '</span>';
1653  }
1654  if (item.result_url) badges += imageRatingHtml(item.taskId, Number(item.rating) || 0);
1655
1656  /* Кнопки действий */
1657  var actionsHtml = '<div class="hist-actions">';
1658if (item.result_url) {
1659    actionsHtml += '<a class="hist-btn" href="' + proxy + '" target="_blank" onclick="return sugProxyNavigate(event,this)">&#128444;&#65039;&#160;Открыть</a>';
1660    actionsHtml += '<a class="hist-btn image-download-control" href="' + dow + '" download onclick="return imageDownloadClick(event,this)">&#128229;&#160;Скачать</a>';
1661    actionsHtml += imageOutputPresetButton(rawResultUrl, 'hist-btn');
1662	if (!isPaidUser) {
1663	    actionsHtml += '<a class="hist-btn firstGen" href="/price" onclick="return handleWatermarkClick(event,this)">Убрать водяной знак</a>';
1664	}
1665	actionsHtml += '<button class="hist-btn" data-kieurl="' + escAttr(rawResultUrl) + '" onclick="refineImage(this.dataset.kieurl)">&#9998;&#65039;&#160;Доработать</button>';
1666}
1667  if (item.prompt) {
1668    actionsHtml += '<button class="hist-btn" data-prompt="' +
1669      escAttr(encodeURIComponent(item.prompt)) +
1670      '" onclick="copyHistPrompt(this)">&#128203;&#160;Копировать описание</button>';
1671  }
1672  actionsHtml += '<button class="hist-btn danger" onclick="hideHistItem(this,\'' +
1673    escAttr(item.taskId) + '\')">&#10005;&#160;Удалить</button>';
1674  actionsHtml += '</div>';
1675
1676  return '<div class="hist-card" id="hc_' + escAttr(item.taskId) + '">'
1677    + thumbHtml
1678    + '<div class="hist-body">'
1679    +   promptHtml
1680    +   '<div class="hist-meta">' + badges + '</div>'
1681    + '</div>'
1682    + actionsHtml
1683    + '</div>';
1684}
1685
1686function hideHistItem(btn, taskId) {
1687  btn.disabled = true;
1688  var fd = new FormData();
1689  fd.append('taskId', taskId);
1690  fetch(SELF + '&action=hide&from=sug.php', { method: 'POST', body: fd })
1691    .then(function(r){ return r.json(); })
1692    .then(function(json){
1693      if (json.ok) {
1694        var card = document.getElementById('hc_' + taskId);
1695        if (card) {
1696          card.style.transition = 'opacity .3s,transform .3s';
1697          card.style.opacity = '0';
1698          card.style.transform = 'scale(.95)';
1699          setTimeout(function(){ card.remove(); }, 300);
1700        }
1701      } else {
1702        btn.disabled = false;
1703      }
1704    })
1705    .catch(function(){ btn.disabled = false; });
1706}
1707
1708/* Копирование описания (промпта) из карточки истории в буфер обмена */
1709function copyHistPrompt(btn) {
1710  var text = '';
1711  try { text = decodeURIComponent(btn.getAttribute('data-prompt') || ''); }
1712  catch (e) { text = btn.getAttribute('data-prompt') || ''; }
1713  if (!text) return;
1714
1715  var done = function () {
1716    var orig = btn.innerHTML;
1717    if (btn._copyTimer) clearTimeout(btn._copyTimer);
1718    btn.innerHTML = '&#10004;&#160;Скопировано';
1719    btn._copyTimer = setTimeout(function () { btn.innerHTML = orig; }, 1500);
1720  };
1721
1722  var fallback = function () {
1723    var ta = document.createElement('textarea');
1724    ta.value = text;
1725    ta.setAttribute('readonly', '');
1726    ta.style.position = 'fixed';
1727    ta.style.top = '-1000px';
1728    document.body.appendChild(ta);
1729    ta.select();
1730    try { document.execCommand('copy'); done(); }
1731    catch (e) {}
1732    document.body.removeChild(ta);
1733  };
1734
1735  if (navigator.clipboard && navigator.clipboard.writeText) {
1736    navigator.clipboard.writeText(text).then(done, fallback);
1737  } else {
1738    fallback();
1739  }
1740}
1741
1742function escHtml(s) {
1743  return String(s)
1744    .replace(/&/g,'&amp;')
1745    .replace(/</g,'&lt;')
1746    .replace(/>/g,'&gt;')
1747    .replace(/"/g,'&quot;');
1748}
1749function escAttr(s) { return escHtml(s); }
1750
1751/* ────────────────────────────────
1752   Опрос зависших задач из истории
1753──────────────────────────────── */
1754var _stalePollTimers = {};   // taskId → intervalId
1755
1756function pollStaleTask(taskId, format) {
1757  if (_stalePollTimers[taskId]) return; // уже опрашиваем
1758
1759  _stalePollTimers[taskId] = setInterval(function() {
1760    requestImageStatus(taskId)
1761      .then(function(data) {
1762
1763        if (data.state === 'success' && data.imageUrl) {
1764          clearInterval(_stalePollTimers[taskId]);
1765          delete _stalePollTimers[taskId];
1766          // Перезагружаем историю целиком — проще и надёжнее
1767          setTimeout(function() { loadHistory(false); }, 600);
1768
1769        } else if (data.state === 'retrying' && data.retryTaskId) {
1770          // Переключаемся на новый taskId
1771          clearInterval(_stalePollTimers[taskId]);
1772          delete _stalePollTimers[taskId];
1773          pollStaleTask(data.retryTaskId, format);
1774
1775        } else if (data.state === 'failed'
1776               || data.state === 'fail'
1777               || data.state === 'error'
1778               || data.state === 'cancelled') {
1779          clearInterval(_stalePollTimers[taskId]);
1780          delete _stalePollTimers[taskId];
1781          // Обновляем только бейдж в карточке, не перегружая всю историю
1782          var card = document.getElementById('hc_' + taskId);
1783          if (card) {
1784            var badge = card.querySelector('.hist-badge.pending');
1785            if (badge) {
1786              badge.className = 'hist-badge failed';
1787              badge.innerHTML = '&#10060;&#160;Ошибка';
1788            }
1789          }
1790        }
1791        // иначе — waiting/queuing/generating — продолжаем опрос
1792      })
1793      .catch(function() { /* сеть — пробуем снова в следующем интервале */ });
1794  }, POLL_MS);
1795}
1796
1797function pollStaleTasksFromHistory(items) {
1798  var staleStatuses = { pending: 1, processing: 1, retrying: 1 };
1799  items.forEach(function(item) {
1800    if (staleStatuses[item.status]) {
1801      pollStaleTask(item.taskId, item.format || 'jpg');
1802    }
1803  });
1804}
1805
1806/* Загружаем историю при старте */
1807puReady.then(function(){ loadHistory(false); });
1808
1809/* Обновляем историю после успешной генерации */
1810var _origNotify = notifyImageReadyOnce;
1811notifyImageReadyOnce = function() {
1812  _origNotify();
1813  setTimeout(function(){ loadHistory(false); }, 1500);
1814};
1815document.getElementById('fastModel').addEventListener('change', function() {
1816  if (this.checked) {
1817    document.getElementById('nsmode').checked = false;
1818    document.getElementById('textQuality').checked = false;
1819    // Разблокируем разрешение если textQuality его блокировал
1820    Array.from(document.getElementById('resolution').options)
1821      .forEach(function(o) { o.disabled = false; });
1822    if (uploadedImages.length > 9) {
1823      uploadedImages = uploadedImages.slice(0, 9);
1824      renderPreviews();
1825    }
1826  }
1827  // Обновляем hint всегда — и при включении, и при выключении
1828  updateUploadMaxHint();
1829  updateNsUploadWarn();
1830  applyModelConstraints();
1831  saveForm();
1832});
1833document.getElementById('nsmode').addEventListener('change', function() {
1834  if (this.checked) {
1835    if (document.getElementById('goReg')) {
1836      this.checked = false;
1837      var hint = document.getElementById('nsModeHint');
1838      if (!hint) {
1839        hint = document.createElement('div');
1840        hint.id = 'nsModeHint';
1841        hint.style.cssText = 'margin-top:6px;font-size:13px;color:#c0392b;';
1842        hint.innerHTML = 'Для использования этой модели необходимо <a href="https://sinonim.org/reg?image">зарегистрироваться</a>. Введённые данные сохранятся.';
1843        this.closest('label').insertAdjacentElement('afterend', hint);
1844      }
1845      hint.style.display = 'block';
1846      return;
1847    }
1848    document.getElementById('fastModel').checked = false;
1849    document.getElementById('textQuality').checked = false;
1850    // Разблокируем разрешение если textQuality его блокировал
1851    Array.from(document.getElementById('resolution').options)
1852      .forEach(function(o) { o.disabled = false; });
1853  }
1854  var hint = document.getElementById('nsModeHint');
1855  if (hint) hint.style.display = 'none';
1856  // Блокируем разрешение выше 1K
1857  var resEl = document.getElementById('resolution');
1858  if (this.checked) {
1859    resEl.value = '1K';
1860    Array.from(resEl.options).forEach(function(o) {
1861      o.disabled = o.value !== '1K';
1862    });
1863    updateBtnCost();
1864  } else {
1865     Array.from(resEl.options).forEach(function(o) { o.disabled = false; });
1866  }
1867
1868  // Лимит загрузок и хинт
1869  var maxImg = getMaxImages();
1870  if (uploadedImages.length > maxImg) {
1871    uploadedImages = uploadedImages.slice(0, maxImg);
1872    renderPreviews();
1873    saveUploads();
1874  }
1875  updateUploadMaxHint();
1876
1877  updateNsUploadWarn();
1878
1879  applyModelConstraints();
1880  saveForm();
1881});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.