Add progress overlay and fix txt2img for local GPU

- New progress_overlay.js: animated fullscreen overlay with shimmer bar,
  fake progress creep, Esc-to-cancel, used by all slow AI operations
- text_to_image.js: allow local_gpu provider (was incorrectly blocked);
  show provider/model/VRAM info in dialog; show estimated generation time;
  use progress overlay during generation
- upscale.js: replace alertify.message with progress overlay (90s estimate
  for AI upscale, 10s for Lanczos)
- frame_fit.js: progress overlay for extend mode (AI outpaint ~45s)
- print_prepare.js: progress overlay for full upscale+frame chain (~2 min)
- selection_actions.js: progress overlay for all AI region edits

https://claude.ai/code/session_01WVDg7amsy1TTtxvpku7bcM
This commit is contained in:
Claude
2026-06-13 16:37:04 +00:00
parent 5940e10542
commit ee94282753
6 changed files with 244 additions and 46 deletions
+142
View File
@@ -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;
}
@@ -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
? `<span style="color:#44cc44">● ${caps.remote.provider}</span>`
: `<span style="color:#44cc44">● local GPU · ${caps.local.gpu_tier || ''} · ${_shortGpu(caps.local.gpu_device)}</span>`;
// 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: <span style="color:#ddd">${m.model_id.split('/').pop()}</span>`;
if (m.memory_opt && m.memory_opt !== 'none') modelNote += ` · <span style="color:#aaa">${m.memory_opt}</span>`;
}
}
// 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: `<div style="font-size:11px;margin:0 0 8px">
Provider: ${providerHtml}${modelNote ? ' · ' + modelNote : ''}<br>
<span style="color:#777">Generation typically takes ${estSec < 30 ? 'a few seconds' : estSec < 90 ? '3090 seconds on local GPU' : '13 minutes on local GPU'}.</span>
</div>`,
},
{
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;
+8 -7
View File
@@ -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;
}
@@ -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 13 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;
}
+10 -4
View File
@@ -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 30120 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;
}
+13 -4
View File
@@ -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);
}
}