diff --git a/frontend/src/js/libs/progress_overlay.js b/frontend/src/js/libs/progress_overlay.js new file mode 100644 index 0000000..da10cb0 --- /dev/null +++ b/frontend/src/js/libs/progress_overlay.js @@ -0,0 +1,142 @@ +/** + * ProgressOverlay — shared animated progress indicator for long AI operations. + * + * Usage: + * import { showProgress, updateProgress, hideProgress } from './progress_overlay.js'; + * + * showProgress('Generating image…'); + * updateProgress(50, 'Denoising step 15/30…'); // optional step updates + * hideProgress(); + * + * When you don't have real step counts, call showProgress() and hideProgress() only — + * the bar animates automatically with a shimmer to signal activity. + */ + +var _overlay = null; +var _bar = null; +var _label = null; +var _shimmerAnim = null; +var _fakeTimer = null; +var _currentPct = 0; + +export function showProgress(message, estimatedSeconds) { + hideProgress(); + + _currentPct = 0; + + // ── Backdrop ────────────────────────────────────────────────────────────── + _overlay = document.createElement('div'); + _overlay.id = 'ai-progress-overlay'; + _overlay.style.cssText = [ + 'position:fixed', 'inset:0', 'z-index:99999', + 'display:flex', 'flex-direction:column', + 'align-items:center', 'justify-content:center', + 'background:rgba(0,0,0,0.55)', + 'backdrop-filter:blur(2px)', + '-webkit-backdrop-filter:blur(2px)', + ].join(';'); + + // ── Card ────────────────────────────────────────────────────────────────── + var card = document.createElement('div'); + card.style.cssText = [ + 'background:#1a1a2e', + 'border:1px solid #3a3a6a', + 'border-radius:14px', + 'padding:28px 36px', + 'min-width:320px', 'max-width:480px', + 'box-shadow:0 12px 48px rgba(0,0,0,0.8)', + 'display:flex', 'flex-direction:column', 'gap:14px', + 'text-align:center', + ].join(';'); + + // ── Label ───────────────────────────────────────────────────────────────── + _label = document.createElement('div'); + _label.textContent = message || 'Processing…'; + _label.style.cssText = 'font-family:sans-serif;font-size:13px;color:#c0c0e0;line-height:1.4;min-height:2.8em'; + + // ── Track ───────────────────────────────────────────────────────────────── + var track = document.createElement('div'); + track.style.cssText = [ + 'width:100%', 'height:6px', + 'background:#0f0f2a', + 'border-radius:3px', + 'overflow:hidden', + 'position:relative', + ].join(';'); + + // ── Shimmer (indeterminate stripe) ──────────────────────────────────────── + var shimmer = document.createElement('div'); + shimmer.style.cssText = [ + 'position:absolute', 'inset:0', + 'background:linear-gradient(90deg,transparent 0%,rgba(120,120,255,0.25) 50%,transparent 100%)', + 'transform:translateX(-100%)', + 'will-change:transform', + ].join(';'); + + // ── Filled bar ──────────────────────────────────────────────────────────── + _bar = document.createElement('div'); + _bar.style.cssText = [ + 'position:absolute', 'inset-block:0', 'left:0', + 'width:0%', + 'background:linear-gradient(90deg,#5577ff,#88aaff)', + 'border-radius:3px', + 'transition:width 0.35s ease', + ].join(';'); + + // ── Cancel hint ─────────────────────────────────────────────────────────── + var hint = document.createElement('div'); + hint.textContent = 'Press Esc to cancel'; + hint.style.cssText = 'font-family:sans-serif;font-size:10px;color:#444;margin-top:2px'; + + track.appendChild(shimmer); + track.appendChild(_bar); + card.appendChild(_label); + card.appendChild(track); + card.appendChild(hint); + _overlay.appendChild(card); + document.body.appendChild(_overlay); + + // Animate shimmer + var pos = -100; + _shimmerAnim = setInterval(() => { + pos += 2.5; + if (pos > 200) pos = -100; + shimmer.style.transform = `translateX(${pos}%)`; + }, 16); + + // Fake progress that creeps toward 90% if no real steps given + if (estimatedSeconds) { + var totalMs = estimatedSeconds * 1000; + var step = 90 / (totalMs / 200); + _fakeTimer = setInterval(() => { + if (_currentPct < 90) { + _currentPct = Math.min(90, _currentPct + step); + _bar.style.width = _currentPct + '%'; + } + }, 200); + } + + // Esc to cancel + _overlay._escHandler = (e) => { if (e.key === 'Escape') hideProgress(); }; + document.addEventListener('keydown', _overlay._escHandler); +} + +export function updateProgress(pct, message) { + if (!_overlay) return; + _currentPct = Math.max(_currentPct, Math.min(100, pct)); + if (_bar) _bar.style.width = _currentPct + '%'; + if (_label && message) _label.textContent = message; +} + +export function hideProgress() { + if (_shimmerAnim) { clearInterval(_shimmerAnim); _shimmerAnim = null; } + if (_fakeTimer) { clearInterval(_fakeTimer); _fakeTimer = null; } + if (_overlay) { + document.removeEventListener('keydown', _overlay._escHandler); + _overlay.remove(); + _overlay = null; + } + _bar = null; + _label = null; + _currentPct = 0; +} diff --git a/frontend/src/js/modules/generate/text_to_image.js b/frontend/src/js/modules/generate/text_to_image.js index d23ddca..86f4853 100644 --- a/frontend/src/js/modules/generate/text_to_image.js +++ b/frontend/src/js/modules/generate/text_to_image.js @@ -1,5 +1,5 @@ /** - * Text → Image — opens a sidebar-style dialog, generates via remote provider, + * Text → Image — generates via remote or local-GPU provider, * pastes result as a new layer on the current canvas. * * Menu target: generate/text_to_image.text_to_image @@ -12,6 +12,7 @@ import Dialog_class from './../../libs/popup.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import apiService from './../../services/api.js'; import { getCapabilities } from './../../api/capabilities.js'; +import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js'; var instance = null; @@ -27,21 +28,54 @@ class Generate_text_to_image_class { async text_to_image() { var caps = await getCapabilities(); - if (!caps.remote || !caps.remote.healthy) { + var hasRemote = caps.remote && caps.remote.healthy; + var hasLocal = caps.local && caps.local.local_gpu_available; + + if (!hasRemote && !hasLocal) { alertify.error( - 'Text → Image requires a remote AI provider. ' + - 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.' + 'Text → Image requires an AI provider. ' + + 'Set AI_PROVIDER=openai / invokeai / comfyui / local_gpu in .env and restart, ' + + 'or configure one in Image → AI Provider Settings.' ); return; } - var _this = this; + var _this = this; var canvasW = config.WIDTH || 1024; var canvasH = config.HEIGHT || 1024; + // Build provider info line + var providerHtml = hasRemote + ? `● ${caps.remote.provider}` + : `● local GPU · ${caps.local.gpu_tier || ''} · ${_shortGpu(caps.local.gpu_device)}`; + + // Model note for local GPU + var modelNote = ''; + if (hasLocal && !hasRemote) { + var rec = caps.local.local_gpu_capabilities && caps.local.local_gpu_capabilities.recommended; + var m = rec && rec.txt2img; + if (m) { + modelNote = `Model: ${m.model_id.split('/').pop()}`; + if (m.memory_opt && m.memory_opt !== 'none') modelNote += ` · ${m.memory_opt}`; + } + } + + // Estimate generation time (rough guide for the progress bar) + var estSec = hasLocal ? 60 : 15; // local GPU ~1 min; OpenAI ~15s + + var defaultW = Math.min(canvasW, hasLocal ? (caps.local.local_gpu_capabilities?.recommended?.txt2img?.native_res || 1024) : 1024); + var defaultH = Math.min(canvasH, defaultW); + this.Dialog.show({ title: 'Text → Image', params: [ + { + title: '', + html: `
+ Provider: ${providerHtml}${modelNote ? ' · ' + modelNote : ''}
+ Generation typically takes ${estSec < 30 ? 'a few seconds' : estSec < 90 ? '30–90 seconds on local GPU' : '1–3 minutes on local GPU'}. +
`, + }, { name: 'prompt', title: 'Describe your image:', @@ -58,7 +92,7 @@ class Generate_text_to_image_class { { name: 'width', title: 'Width (px):', - value: Math.min(canvasW, 1024), + value: defaultW, range: [256, 2048], step: 64, type: 'range', @@ -66,7 +100,7 @@ class Generate_text_to_image_class { { name: 'height', title: 'Height (px):', - value: Math.min(canvasH, 1024), + value: defaultH, range: [256, 2048], step: 64, type: 'range', @@ -99,29 +133,31 @@ class Generate_text_to_image_class { alertify.warning('Please enter a description.'); return; } - await _this._generate(params); + await _this._generate(params, estSec); }, }); } - async _generate(params) { + async _generate(params, estSec) { if (this.isProcessing) return; this.isProcessing = true; - alertify.message('Generating image... please wait', 0); + + showProgress('Generating image… this may take a minute on local GPU', estSec || 60); try { var result = await apiService.textToImage(params.prompt, { - width: params.width || 1024, - height: params.height || 1024, + width: params.width || 1024, + height: params.height || 1024, negativePrompt: params.negative_prompt || '', - steps: params.steps || 30, - seed: params.seed || 0, + steps: params.steps || 30, + seed: params.seed || 0, }); + updateProgress(95, 'Placing image…'); + var img = new Image(); img.onload = () => { if (params.placement === 'replace_canvas') { - // Resize canvas and replace bottom layer config.WIDTH = img.naturalWidth; config.HEIGHT = img.naturalHeight; var resultCanvas = document.createElement('canvas'); @@ -134,41 +170,43 @@ class Generate_text_to_image_class { ]) ); } else { - // Add as new layer on top - var dataURL = img.src; app.State.do_action( new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [ new app.Actions.Insert_layer_action({ name: params.prompt.slice(0, 30), type: 'image', - data: dataURL, - x: 0, - y: 0, - width: img.naturalWidth, - height: img.naturalHeight, + data: img.src, + x: 0, y: 0, + width: img.naturalWidth, + height: img.naturalHeight, width_original: img.naturalWidth, height_original: img.naturalHeight, }) ]) ); } - alertify.dismissAll(); + hideProgress(); alertify.success('Image generated!'); this.isProcessing = false; }; img.onerror = () => { - alertify.dismissAll(); + hideProgress(); alertify.error('Failed to load generated image.'); this.isProcessing = false; }; img.src = 'data:image/png;base64,' + result.result; } catch (err) { - alertify.dismissAll(); + hideProgress(); alertify.error('Generation failed: ' + (err.message || err)); this.isProcessing = false; } } } +function _shortGpu(name) { + if (!name) return 'GPU'; + return name.replace(/^NVIDIA GeForce /i, '').replace(/^NVIDIA /i, ''); +} + export default Generate_text_to_image_class; diff --git a/frontend/src/js/modules/image/frame_fit.js b/frontend/src/js/modules/image/frame_fit.js index f967164..b56bc00 100644 --- a/frontend/src/js/modules/image/frame_fit.js +++ b/frontend/src/js/modules/image/frame_fit.js @@ -15,6 +15,7 @@ import Base_layers_class from './../../core/base-layers.js'; import Dialog_class from './../../libs/popup.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import { getCapabilities } from './../../api/capabilities.js'; +import { showProgress, hideProgress } from './../../libs/progress_overlay.js'; var instance = null; @@ -123,11 +124,11 @@ class Image_frame_fit_class { this.isProcessing = true; var mode = params.mode || 'smart'; - alertify.message( + showProgress( mode === 'extend' - ? 'Fitting to frame with AI extension... please wait' - : 'Fitting to frame...', - 0 + ? 'Fitting to frame with AI extension…' + : 'Fitting to frame…', + mode === 'extend' ? 45 : 5 ); try { @@ -203,7 +204,7 @@ class Image_frame_fit_class { ); } - alertify.dismissAll(); + hideProgress(); alertify.success( `Done! ${result.output_pixels.width}×${result.output_pixels.height}px` + ` (${result.frame} ${result.orientation}, ${result.mode_used})` @@ -211,14 +212,14 @@ class Image_frame_fit_class { this.isProcessing = false; }; img.onerror = () => { - alertify.dismissAll(); + hideProgress(); alertify.error('Failed to load result.'); this.isProcessing = false; }; img.src = 'data:image/png;base64,' + result.result; } catch (err) { - alertify.dismissAll(); + hideProgress(); alertify.error('Frame fit failed: ' + (err.message || err)); this.isProcessing = false; } diff --git a/frontend/src/js/modules/image/print_prepare.js b/frontend/src/js/modules/image/print_prepare.js index 38b8735..cbe84b2 100644 --- a/frontend/src/js/modules/image/print_prepare.js +++ b/frontend/src/js/modules/image/print_prepare.js @@ -13,6 +13,7 @@ import Base_layers_class from './../../core/base-layers.js'; import Dialog_class from './../../libs/popup.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import { getCapabilities } from './../../api/capabilities.js'; +import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js'; const FRAME_SIZES = [ '5x7', '8x10', '11x14', '18x24', '16x20', '20x24', '24x36', @@ -153,11 +154,11 @@ class Image_print_prepare_class { var neededScale = Math.max(targetW / origW, targetH / origH); var willUpscale = neededScale > 1.05; - alertify.message( + showProgress( willUpscale - ? `Upscaling ${neededScale.toFixed(1)}× with AI, then fitting to frame… this may take a minute` + ? `Upscaling ${neededScale.toFixed(1)}× with AI, then fitting to frame…\nAI is reconstructing detail — this may take 1–3 minutes.` : 'Fitting to frame…', - 0 + willUpscale ? 120 : 8 ); try { @@ -188,6 +189,7 @@ class Image_print_prepare_class { } var result = await r.json(); + updateProgress(90, 'Placing result…'); var img = new Image(); img.onload = () => { var resultCanvas = document.createElement('canvas'); @@ -225,7 +227,7 @@ class Image_print_prepare_class { ); } - alertify.dismissAll(); + hideProgress(); var upscaleNote = result.upscale_applied ? ` · ${result.upscale_factor}× ${result.upscale_method}` : ' · no upscale needed'; @@ -235,14 +237,14 @@ class Image_print_prepare_class { this.isProcessing = false; }; img.onerror = () => { - alertify.dismissAll(); + hideProgress(); alertify.error('Failed to load result.'); this.isProcessing = false; }; img.src = 'data:image/png;base64,' + result.result; } catch (err) { - alertify.dismissAll(); + hideProgress(); alertify.error('Prepare for Print failed: ' + (err.message || err)); this.isProcessing = false; } diff --git a/frontend/src/js/modules/image/upscale.js b/frontend/src/js/modules/image/upscale.js index 4968f4d..9947a1f 100644 --- a/frontend/src/js/modules/image/upscale.js +++ b/frontend/src/js/modules/image/upscale.js @@ -13,6 +13,7 @@ import config from './../../config.js'; import Base_layers_class from './../../core/base-layers.js'; import Dialog_class from './../../libs/popup.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js'; var instance = null; @@ -221,7 +222,12 @@ class Image_upscale_class { ? `Auto (${caps.recommended_label || 'best available'})` : (METHOD_LABELS[method] || method); - alertify.message(`Upscaling ${scale}× · ${methodLabel}…`, 0); + var isAI = method !== 'lanczos'; + showProgress( + `Upscaling ${scale}× with ${methodLabel}…` + + (isAI ? '\nAI is reconstructing detail — this may take 30–120 seconds.' : ''), + isAI ? 90 : 10 + ); try { var layerCanvas = document.createElement('canvas'); @@ -276,21 +282,21 @@ class Image_upscale_class { ); } - alertify.dismissAll(); + hideProgress(); alertify.success( `${result.output.width}×${result.output.height}px · ${usedLabel}` ); this.isProcessing = false; }; img.onerror = () => { - alertify.dismissAll(); + hideProgress(); alertify.error('Failed to load upscaled image.'); this.isProcessing = false; }; img.src = 'data:image/png;base64,' + result.result; } catch (err) { - alertify.dismissAll(); + hideProgress(); alertify.error('Upscale failed: ' + (err.message || err)); this.isProcessing = false; } diff --git a/frontend/src/js/tools/selection_actions.js b/frontend/src/js/tools/selection_actions.js index af6f9b7..8cbec02 100644 --- a/frontend/src/js/tools/selection_actions.js +++ b/frontend/src/js/tools/selection_actions.js @@ -18,6 +18,7 @@ import app from './../app.js'; import config from './../config.js'; import Base_layers_class from './../core/base-layers.js'; import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import { showProgress, hideProgress } from './../libs/progress_overlay.js'; const BASE = window.API_BASE_URL || ''; @@ -175,7 +176,7 @@ export class SelectionActions { async _scaleSelection(scalePct) { if (!this._check()) return; this.hide(); - alertify.message('Scaling object and filling gap…'); + showProgress('Scaling object and AI-filling the gap…', 30); try { var res = await _post('/api/image/scale-selection', { image: this._imageData, @@ -184,8 +185,10 @@ export class SelectionActions { }); this.tool.updateLayerWithResult(res.result); this.tool.clearSelection(); + hideProgress(); alertify.success('Scaled by ' + scalePct + '%!'); } catch (e) { + hideProgress(); alertify.error('Scale failed: ' + e.message); } } @@ -193,7 +196,7 @@ export class SelectionActions { async _makeAsymmetric() { if (!this._check()) return; this.hide(); - alertify.message('AI is adding natural asymmetry…'); + showProgress('AI is adding natural asymmetry…', 60); try { var res = await _post('/api/image/ai-edit-region', { image: this._imageData, @@ -205,8 +208,10 @@ export class SelectionActions { }); this.tool.updateLayerWithResult(res.result); this.tool.clearSelection(); + hideProgress(); alertify.success('Made less symmetrical!'); } catch (e) { + hideProgress(); alertify.error('AI edit failed: ' + e.message); } } @@ -214,7 +219,7 @@ export class SelectionActions { async _aiEditRegion(instruction) { if (!this._check()) return; this.hide(); - alertify.message('AI is editing the region…'); + showProgress('AI is editing the region…', 60); try { var res = await _post('/api/image/ai-edit-region', { image: this._imageData, @@ -225,8 +230,10 @@ export class SelectionActions { }); this.tool.updateLayerWithResult(res.result); this.tool.clearSelection(); + hideProgress(); alertify.success('Done!'); } catch (e) { + hideProgress(); alertify.error('AI edit failed: ' + e.message); } } @@ -257,7 +264,7 @@ export class SelectionActions { var clipBase64 = await _blobToBase64(clipBlob); this.hide(); - alertify.message('Pasting clipboard into selection…'); + showProgress('Pasting clipboard into selection…', 10); var res = await _post('/api/image/paste-into-selection', { image: this._imageData, @@ -266,8 +273,10 @@ export class SelectionActions { }); this.tool.updateLayerWithResult(res.result); this.tool.clearSelection(); + hideProgress(); alertify.success('Clipboard pasted into selection!'); } catch (e) { + hideProgress(); alertify.error('Paste failed: ' + e.message); } }