Spaces:
Running
Running
| /* ===== Cloud Image Browser Page ===== */ | |
| window.NAICloud = { | |
| dates: [], | |
| images: [], | |
| displayedCount: 0, | |
| BATCH_SIZE: 30, | |
| selectedPath: null, | |
| selectedInfo: null, | |
| currentDate: '', | |
| syncing: false, | |
| batch: null, | |
| searchQuery: '', | |
| searchTimeout: null, | |
| _scrollHandler: null, | |
| async loadDates() { | |
| try { | |
| const resp = await fetch('/api/cloud/dates'); | |
| const data = await resp.json(); | |
| this.dates = data.dates || []; | |
| } catch (e) { | |
| this.dates = []; | |
| } | |
| this.renderDateList(); | |
| }, | |
| async loadImages(date) { | |
| this.currentDate = date || ''; | |
| try { | |
| let url; | |
| if (this.searchQuery.trim()) { | |
| url = `/api/cloud/search?q=${encodeURIComponent(this.searchQuery)}&date=${encodeURIComponent(date)}`; | |
| } else { | |
| url = `/api/cloud/images?date=${encodeURIComponent(date)}`; | |
| } | |
| const resp = await fetch(url); | |
| const data = await resp.json(); | |
| this.images = data.images || []; | |
| } catch (e) { | |
| this.images = []; | |
| } | |
| this.displayedCount = 0; | |
| this.renderGrid(); | |
| }, | |
| onSearchInput(value) { | |
| this.searchQuery = value; | |
| clearTimeout(this.searchTimeout); | |
| this.searchTimeout = this.currentDate ? setTimeout(() => this.loadImages(this.currentDate), 300) : null; | |
| }, | |
| async selectImage(path) { | |
| this.selectedPath = path; | |
| $$('.cloud-card').forEach(c => { | |
| c.classList.toggle('selected', c.dataset.path === path); | |
| }); | |
| const detail = $('#cloud-detail'); | |
| detail.style.display = 'flex'; | |
| $('#cloud-empty-detail').style.display = 'none'; | |
| // Load full image | |
| const imgEl = $('#cloud-detail-img'); | |
| imgEl.src = ''; | |
| imgEl.alt = 'Loading...'; | |
| try { | |
| const resp = await fetch(`/api/cloud/image?path=${encodeURIComponent(path)}`); | |
| const data = await resp.json(); | |
| if (data.image) { | |
| const dataUrl = 'data:image/png;base64,' + data.image; | |
| imgEl.src = dataUrl; | |
| imgEl.addEventListener('click', () => { | |
| if (document.fullscreenElement) { | |
| document.exitFullscreen(); | |
| } else { | |
| imgEl.requestFullscreen(); | |
| } | |
| }); | |
| // Parse PNG info | |
| const binary = atob(data.image); | |
| const bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | |
| const textChunks = await NAIPngInfo.readPngTextChunks(bytes.buffer); | |
| const img = await NAIUtils.loadImageFromDataUrl(dataUrl); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = img.naturalWidth; | |
| canvas.height = img.naturalHeight; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| ctx.drawImage(img, 0, 0); | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| const stealthInfo = await NAIPngInfo.readStealthInfo(imageData, canvas.width, canvas.height); | |
| const parsed = NAIPngInfo.parseInfo(textChunks, stealthInfo, img.naturalWidth, img.naturalHeight); | |
| this.selectedInfo = parsed; | |
| this._selectedB64 = data.image; | |
| this.renderParams(parsed); | |
| } | |
| } catch (e) { | |
| console.error('Failed to load cloud image:', e); | |
| } | |
| // Meta | |
| const item = this.images.find(i => i.path === path); | |
| const metaEl = $('#cloud-detail-meta'); | |
| if (item) { | |
| metaEl.innerHTML = ` | |
| <div class="history-meta-row"><span class="history-meta-key">文件:</span><span>${item.name}</span></div> | |
| <div class="history-meta-row"><span class="history-meta-key">日期:</span><span>${item.date}</span></div> | |
| `; | |
| } | |
| }, | |
| renderDateList() { | |
| const list = $('#cloud-date-list'); | |
| list.innerHTML = ''; | |
| // "All" option | |
| const allItem = document.createElement('div'); | |
| allItem.className = 'cloud-date-item' + (!this.currentDate ? ' active' : ''); | |
| allItem.textContent = `全部`; | |
| allItem.addEventListener('click', () => { | |
| this.currentDate = ''; | |
| this.loadImages(''); | |
| $$('.cloud-date-item').forEach(d => d.classList.remove('active')); | |
| allItem.classList.add('active'); | |
| }); | |
| list.appendChild(allItem); | |
| for (const d of this.dates) { | |
| const item = document.createElement('div'); | |
| item.className = 'cloud-date-item' + (d.date === this.currentDate ? ' active' : ''); | |
| item.innerHTML = `<span>${d.date}</span><span class="cloud-date-count">${d.count}</span>`; | |
| item.addEventListener('click', () => { | |
| this.currentDate = d.date; | |
| this.loadImages(d.date); | |
| $$('.cloud-date-item').forEach(di => di.classList.remove('active')); | |
| item.classList.add('active'); | |
| }); | |
| list.appendChild(item); | |
| } | |
| }, | |
| _renderCards(items) { | |
| const grid = $('#cloud-grid'); | |
| for (const item of items) { | |
| const card = document.createElement('div'); | |
| card.className = 'cloud-card history-card' + (item.path === this.selectedPath ? ' selected' : ''); | |
| card.dataset.path = item.path; | |
| // Use thumbnail endpoint (lazy: load full only on select) | |
| const img = document.createElement('img'); | |
| img.loading = 'lazy'; | |
| img.src = `/api/cloud/image?path=${encodeURIComponent(item.path)}`; | |
| img.alt = item.name; | |
| img.style.background = 'var(--bg-input)'; | |
| // Fetch thumbnail via API | |
| fetch(`/api/cloud/image?path=${encodeURIComponent(item.path)}`) | |
| .then(r => r.json()) | |
| .then(data => { | |
| if (data.image) img.src = 'data:image/png;base64,' + data.image; | |
| }) | |
| .catch(() => {}); | |
| card.appendChild(img); | |
| const badge = document.createElement('div'); | |
| badge.className = 'history-card-badge'; | |
| badge.textContent = item.name.split('-').pop()?.replace('.png', '') || ''; | |
| card.appendChild(badge); | |
| card.dataset.batchId = item.path; | |
| if (this.batch?.batchMode) { | |
| const cbWrap = document.createElement('div'); | |
| cbWrap.className = 'batch-checkbox-wrap'; | |
| const cb = document.createElement('input'); | |
| cb.type = 'checkbox'; | |
| cb.className = 'batch-checkbox'; | |
| cb.checked = this.batch.selected.has(item.path); | |
| cbWrap.appendChild(cb); | |
| card.appendChild(cbWrap); | |
| if (this.batch.selected.has(item.path)) { | |
| card.classList.add('batch-selected'); | |
| } | |
| card.addEventListener('click', (e) => { | |
| if (e.shiftKey) { | |
| this.batch.rangeSelect(item.path); | |
| } else { | |
| this.batch.toggleItem(item.path); | |
| } | |
| }); | |
| } else { | |
| card.addEventListener('click', () => this.selectImage(item.path)); | |
| } | |
| grid.appendChild(card); | |
| } | |
| }, | |
| loadMoreImages() { | |
| if (this.displayedCount >= this.images.length) return; | |
| const nextBatch = this.images.slice(this.displayedCount, this.displayedCount + this.BATCH_SIZE); | |
| this._renderCards(nextBatch); | |
| this.displayedCount += nextBatch.length; | |
| // Update load-more indicator | |
| this._updateLoadMoreIndicator(); | |
| }, | |
| _updateLoadMoreIndicator() { | |
| const grid = $('#cloud-grid'); | |
| const existing = grid.parentElement.querySelector('.cloud-load-more-indicator'); | |
| if (existing) existing.remove(); | |
| if (this.displayedCount < this.images.length) { | |
| const indicator = document.createElement('div'); | |
| indicator.className = 'cloud-load-more-indicator'; | |
| indicator.style.cssText = 'text-align:center;padding:12px;color:var(--text-secondary,#888);font-size:13px;'; | |
| indicator.textContent = `已加载 ${this.displayedCount} / ${this.images.length} 张,下滑加载更多...`; | |
| grid.parentElement.appendChild(indicator); | |
| } | |
| }, | |
| _onScrollContainer() { | |
| const container = $('.cloud-grid-body'); | |
| if (!container) return; | |
| const scrollTop = container.scrollTop; | |
| const scrollHeight = container.scrollHeight; | |
| const clientHeight = container.clientHeight; | |
| // When scrolled to within 200px of the bottom, load more | |
| if (scrollTop + clientHeight >= scrollHeight - 200) { | |
| this.loadMoreImages(); | |
| } | |
| }, | |
| _setupScrollListener() { | |
| const container = $('.cloud-grid-body'); | |
| if (!container) return; | |
| // Remove old listener if exists | |
| if (this._scrollHandler) { | |
| container.removeEventListener('scroll', this._scrollHandler); | |
| } | |
| this._scrollHandler = () => this._onScrollContainer(); | |
| container.addEventListener('scroll', this._scrollHandler); | |
| }, | |
| renderGrid() { | |
| const grid = $('#cloud-grid'); | |
| const countEl = $('#cloud-count'); | |
| grid.innerHTML = ''; | |
| // Remove any existing load-more indicator | |
| const existingIndicator = grid.parentElement?.querySelector('.cloud-load-more-indicator'); | |
| if (existingIndicator) existingIndicator.remove(); | |
| if (countEl) countEl.textContent = `${this.images.length} 张`; | |
| if (this.images.length === 0) { | |
| grid.innerHTML = '<div class="history-empty">暂无图片</div>'; | |
| return; | |
| } | |
| // Reset displayed count and load first batch | |
| this.displayedCount = 0; | |
| this.loadMoreImages(); | |
| // Setup scroll listener for infinite scroll | |
| this._setupScrollListener(); | |
| }, | |
| renderParams(parsed) { | |
| const paramsEl = $('#cloud-detail-params'); | |
| paramsEl.innerHTML = ''; | |
| if (parsed.params && Object.keys(parsed.params).length > 0) { | |
| const order = ['prompt', 'uc', 'negative prompt', 'model', 'steps', 'sampler', 'scale', 'seed', 'width', 'height']; | |
| const shown = new Set(); | |
| for (const key of order) { | |
| if (parsed.params[key] !== undefined) { | |
| paramsEl.appendChild(this._row(key, parsed.params[key])); | |
| shown.add(key); | |
| } | |
| } | |
| for (const [k, v] of Object.entries(parsed.params)) { | |
| if (shown.has(k)) continue; | |
| paramsEl.appendChild(this._row(k, typeof v === 'object' ? JSON.stringify(v) : v)); | |
| } | |
| $('#btn-cloud-send-params').style.display = ''; | |
| } else { | |
| paramsEl.innerHTML = '<div class="history-no-params">无可解析参数</div>'; | |
| $('#btn-cloud-send-params').style.display = 'none'; | |
| } | |
| }, | |
| _row(key, value) { | |
| const row = document.createElement('div'); | |
| row.className = 'pi-param-row'; | |
| const vs = String(value); | |
| row.innerHTML = `<div class="pi-param-key">${key}</div><div class="pi-param-value" ${vs.length > 100 ? 'title="' + vs.replace(/"/g, '"') + '"' : ''}>${vs}</div>`; | |
| return row; | |
| }, | |
| // ── Actions ── | |
| sendParamsToGenerate() { | |
| if (!this.selectedInfo) return; | |
| const backup = NAIPngInfo.currentInfo; | |
| NAIPngInfo.currentInfo = this.selectedInfo; | |
| NAIPngInfo.sendToGenerate(); | |
| NAIPngInfo.currentInfo = backup; | |
| }, | |
| _getSelectedDataUrl() { | |
| if (!this._selectedB64) return null; | |
| return 'data:image/png;base64,' + this._selectedB64; | |
| }, | |
| sendToExtraInput(mode) { | |
| const du = this._getSelectedDataUrl(); | |
| if (!du) return; | |
| NAIExtraInput.clear(); | |
| const r = document.querySelector(`input[name="extra-mode"][value="${mode}"]`); | |
| if (r) { r.checked = true; NAIExtraInput.setMode(mode); } | |
| NAIExtraInput.handleUploadFromDataUrl(du); | |
| const c = $('#extra-input-content'); | |
| if (c && !c.classList.contains('open')) { c.classList.add('open'); $('#extra-input-arrow').textContent = '▼'; } | |
| document.querySelector('.nav-item[data-page="generate"]')?.click(); | |
| }, | |
| sendToVibe() { | |
| const du = this._getSelectedDataUrl(); | |
| if (!du) return; | |
| fetch(du).then(r => r.blob()).then(blob => { | |
| NAIVibe.handleRefUpload([new File([blob], 'cloud.png', { type: 'image/png' })]); | |
| const c = $('#vibe-content'); | |
| if (c && !c.classList.contains('open')) { c.classList.add('open'); $('#vibe-arrow').textContent = '▼'; } | |
| document.querySelector('.nav-item[data-page="generate"]')?.click(); | |
| }); | |
| }, | |
| sendToDirectorTools() { | |
| const du = this._getSelectedDataUrl(); | |
| if (!du) return; | |
| NAIDirectorTools._setInputFromDataUrl(du); | |
| document.querySelector('.nav-item[data-page="inpaint"]')?.click(); | |
| }, | |
| downloadSelected() { | |
| if (!this._selectedB64 || !this.selectedPath) return; | |
| const a = document.createElement('a'); | |
| a.href = 'data:image/png;base64,' + this._selectedB64; | |
| a.download = this.selectedPath.split('/').pop() || 'image.png'; | |
| document.body.appendChild(a); a.click(); document.body.removeChild(a); | |
| }, | |
| async deleteSelected() { | |
| if (!this.selectedPath) return; | |
| if (!confirm(`确定删除 ${this.selectedPath}?将同时从云端删除。`)) return; | |
| try { | |
| await fetch('/api/cloud/delete', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ path: this.selectedPath }), | |
| }); | |
| this.selectedPath = null; | |
| this.selectedInfo = null; | |
| this._selectedB64 = null; | |
| $('#cloud-detail').style.display = 'none'; | |
| $('#cloud-empty-detail').style.display = 'flex'; | |
| if (this.currentDate) await this.loadImages(this.currentDate); | |
| await this.loadDates(); | |
| } catch (e) { console.error(e); } | |
| }, | |
| async syncFromCloud() { | |
| if (this.syncing) return; | |
| this.syncing = true; | |
| const btn = $('#btn-cloud-sync'); | |
| btn.disabled = true; | |
| btn.textContent = '同步中...'; | |
| try { | |
| await fetch('/api/cloud/sync', { method: 'POST' }); | |
| await this.loadDates(); | |
| if (this.currentDate) await this.loadImages(this.currentDate); | |
| } catch (e) { console.error(e); } | |
| this.syncing = false; | |
| btn.disabled = false; | |
| btn.textContent = '🔄 同步'; | |
| }, | |
| init() { | |
| $('#btn-cloud-sync').addEventListener('click', () => this.syncFromCloud()); | |
| $('#btn-cloud-send-params').addEventListener('click', () => this.sendParamsToGenerate()); | |
| $('#btn-cloud-send-i2i').addEventListener('click', () => this.sendToExtraInput('img2img')); | |
| $('#btn-cloud-send-inp').addEventListener('click', () => this.sendToExtraInput('inpaint')); | |
| $('#btn-cloud-send-vib').addEventListener('click', () => this.sendToVibe()); | |
| $('#btn-cloud-send-dt').addEventListener('click', () => this.sendToDirectorTools()); | |
| $('#btn-cloud-download').addEventListener('click', () => this.downloadSelected()); | |
| $('#btn-cloud-delete').addEventListener('click', () => this.deleteSelected()); | |
| // Search | |
| $('#cloud-search-input').addEventListener('input', (e) => this.onSearchInput(e.target.value)); | |
| $('#btn-cloud-search-clear').addEventListener('click', () => { | |
| $('#cloud-search-input').value = ''; | |
| this.onSearchInput(''); | |
| }); | |
| // Batch mode | |
| this.batch = NAIBatch.create({ | |
| gridSel: '#cloud-grid', | |
| toolbarSel: '#cloud-batch-toolbar', | |
| getItems: () => this.images, | |
| onDelete: async (paths) => { | |
| for (const path of paths) { | |
| await fetch('/api/cloud/delete', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ path }), | |
| }); | |
| } | |
| this.selectedPath = null; | |
| this.selectedInfo = null; | |
| this._selectedB64 = null; | |
| $('#cloud-detail').style.display = 'none'; | |
| $('#cloud-empty-detail').style.display = 'flex'; | |
| if (this.currentDate) await this.loadImages(this.currentDate); | |
| await this.loadDates(); | |
| }, | |
| getImageB64: async (path) => { | |
| const resp = await fetch(`/api/cloud/image?path=${encodeURIComponent(path)}`); | |
| const data = await resp.json(); | |
| if (!data.image) return null; | |
| return { b64: data.image, filename: path.split('/').pop() || 'image.png' }; | |
| }, | |
| onRefreshGrid: () => this.renderGrid(), | |
| }); | |
| $('#btn-cloud-batch-toggle').addEventListener('click', () => { | |
| this.batch.toggle(); | |
| $('#btn-cloud-batch-toggle').classList.toggle('active', this.batch.batchMode); | |
| }); | |
| const cbSelectAll = document.querySelector('#cloud-batch-toolbar .batch-select-all'); | |
| if (cbSelectAll) { | |
| cbSelectAll.addEventListener('change', () => { | |
| cbSelectAll.checked ? this.batch.selectAll() : this.batch.selectNone(); | |
| }); | |
| } | |
| $('#btn-cloud-batch-download')?.addEventListener('click', () => this.batch.batchDownload()); | |
| $('#btn-cloud-batch-delete')?.addEventListener('click', () => this.batch.batchDelete()); | |
| document.querySelector('.nav-item[data-page="cloud"]')?.addEventListener('click', () => { | |
| this.loadDates(); | |
| if (this.currentDate) this.loadImages(this.currentDate); | |
| }); | |
| }, | |
| }; |