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);
}
}