Files
JasonHoku-ComfyUI-Ultimate-…/resources/logic_upscale_modal.js
T
Jason HokuandClaude Opus 4.6 9e7911aa23 feat: SeedVR2 upscale mode in Builder UI and Dashboard
Adds SeedVR2 as a new upscale mode alongside HiRes Fix, Model Upscale,
and Model+HiRes. Requires ComfyUI-SeedVR2_VideoUpscaler to be installed
as a dependency — the plugin calls its nodes programmatically.

Data Model:
- New seedvr2 options object on each upscale step with all SeedVR2
  parameters: dit_model, resolution, max_resolution, seed,
  color_correction, batch_size, noise scales, blocks_to_swap,
  attention_mode, offload_device, cache_model, VAE tiling options
- _defaultSeedVR2Options() / createDefaultSeedVR2Options() helpers
- Migration: ensureStepFields adds seedvr2 defaults to existing configs

Builder UI (conf-builder-config-management.js):
- "SeedVR2 Upscale" added to mode dropdown
- When selected, shows full SeedVR2 settings: DiT model selection
  (10 variants: 3B/7B x fp16/fp8/GGUF/sharp), resolution, seed,
  color correction, noise scales, batch size, VRAM optimization
  (blocks_to_swap, attention_mode, offload), VAE tiling with
  conditional encode/decode tile size and overlap fields

Dashboard (logic_upscale_modal.js):
- Same SeedVR2 mode and fields in renderPipelineEditor via _renderStep
- All fields use existing _makeSelect/_makeNumber/_makeCheckbox helpers

Python Execution (image_generation.py):
- New seedvr2_upscale(pil_image, seedvr2_config) function
- Imports SeedVR2 nodes from NODE_CLASS_MAPPINGS at runtime
- Calls SeedVR2LoadDiTModel → SeedVR2LoadVAEModel → SeedVR2VideoUpscaler
- Converts PIL→tensor→SeedVR2→tensor→PIL
- Clear error if SeedVR2 not installed

Pipeline Integration (generation_orchestrator.py):
- Inline upscale path: mode=="seedvr2" branch decodes current latent
  to PIL, calls seedvr2_upscale, saves result, updates manifest
- Supports chained steps: non-final SeedVR2 steps re-encode to latent
  for the next step in the pipeline

Preset compatible: seedvr2 config serializes naturally as JSON, saved
and loaded with upscale presets without any special handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:29:00 -07:00

1058 lines
48 KiB
JavaScript

// ============================================================================
// UPSCALE MODAL — Dashboard image upscaling with inline config + presets
// ============================================================================
// State for the upscale modal
var upscaleModalState = {
open: false,
imageId: null,
imageData: null,
presets: [],
currentConfig: null,
jobId: null,
pollInterval: null,
availableModels: [], // Upscale model list from server
};
// Default SeedVR2 options — matches SeedVR2 node defaults
function _defaultSeedVR2Options() {
return {
dit_model: "seedvr2_ema_3b_fp8_e4m3fn.safetensors",
resolution: 1080,
max_resolution: 0,
seed: 42,
color_correction: "lab",
batch_size: 1,
input_noise_scale: 0.0,
latent_noise_scale: 0.0,
blocks_to_swap: 0,
attention_mode: "sdpa",
offload_device: "cpu",
cache_model: false,
encode_tiled: false,
encode_tile_size: 1024,
encode_tile_overlap: 128,
decode_tiled: false,
decode_tile_size: 1024,
decode_tile_overlap: 128,
vae_offload_device: "none",
vae_cache_model: false
};
}
// Default upscale step config — ALL fields matching the Builder UI's createDefaultStep()
function _defaultUpscaleStep() {
return {
active: true,
mode: "hires_only",
repeat: 1,
upscale_models: [],
upscale_ratios: "1.5",
upscale_size: "2.0",
hires_denoise: "0.3",
hires_steps: 0,
tiled_vae: false,
tile_size: 512,
tile_overlap: 64,
temporal_size: 512,
temporal_overlap: 64,
resize_method: "bilinear",
hires_tiled_sampling: false,
hires_tile_width: 512,
hires_tile_height: 512,
hires_mask_blur: 8,
hires_tile_padding: 32,
hires_force_uniform_tiles: false,
seedvr2: _defaultSeedVR2Options()
};
}
// Ensure a step has all required fields (migration/defaults) — mirrors Builder's ensureStepFields()
function _ensureUpscaleStepFields(step) {
if (step.active === undefined) step.active = true;
if (!step.repeat || step.repeat < 1) step.repeat = 1;
if (!step.upscale_models) step.upscale_models = [];
if (!step.upscale_ratios) step.upscale_ratios = "1.5";
if (!step.upscale_size) step.upscale_size = "2.0";
if (!step.hires_denoise) step.hires_denoise = "0.3";
if (step.hires_steps === undefined) step.hires_steps = 0;
if (step.tiled_vae === undefined) step.tiled_vae = false;
if (!step.tile_size) step.tile_size = 512;
if (!step.tile_overlap) step.tile_overlap = 64;
if (!step.temporal_size) step.temporal_size = 512;
if (!step.temporal_overlap) step.temporal_overlap = 64;
if (!step.resize_method) step.resize_method = "bilinear";
if (step.hires_tiled_sampling === undefined) step.hires_tiled_sampling = false;
if (!step.hires_tile_width) step.hires_tile_width = 512;
if (!step.hires_tile_height) step.hires_tile_height = 512;
if (step.hires_mask_blur === undefined) step.hires_mask_blur = 8;
if (!step.hires_tile_padding) step.hires_tile_padding = 32;
if (step.hires_force_uniform_tiles === undefined) step.hires_force_uniform_tiles = false;
if (!step.seedvr2) step.seedvr2 = _defaultSeedVR2Options();
}
// Default config
function _defaultUpscaleConfig() {
return {
pipelines: [{ active: true, name: "Pipeline 1", steps: [_defaultUpscaleStep()] }],
hires_prompt_adjust: false, hires_prompt_behavior: "append_end", hires_prompt_text: "",
};
}
/**
* Open the upscale modal for a specific image
*/
function openUpscaleModal(imageId) {
var item = activeData ? activeData.find(function(d) { return d.id === imageId; }) : null;
if (!item) return;
upscaleModalState.open = true;
upscaleModalState.imageId = imageId;
upscaleModalState.imageData = item;
upscaleModalState.jobId = null;
// Initialize with default config if none set
if (!upscaleModalState.currentConfig) {
upscaleModalState.currentConfig = _defaultUpscaleConfig();
}
// Fetch presets and model list in parallel, then render
Promise.all([
fetchUpscalePresets(),
fetchUpscaleModels()
]).then(function() { renderUpscaleModal(); });
}
/**
* Close the upscale modal
*/
function closeUpscaleModal() {
upscaleModalState.open = false;
if (upscaleModalState.pollInterval) {
clearInterval(upscaleModalState.pollInterval);
upscaleModalState.pollInterval = null;
}
var modal = document.getElementById('upscale-modal-overlay');
if (modal) modal.style.display = 'none';
}
/**
* Fetch upscale presets from server
*/
async function fetchUpscalePresets() {
try {
var resp = await fetch('/configbuilder/upscale_presets');
if (resp.ok) {
var data = await resp.json();
upscaleModalState.presets = data.presets || [];
}
} catch (e) { console.error('Failed to fetch upscale presets:', e); }
}
/**
* Fetch available upscale models from server
*/
async function fetchUpscaleModels() {
try {
var resp = await fetch('/configbuilder/model_lists');
if (resp.ok) {
var data = await resp.json();
upscaleModalState.availableModels = data.upscale_models || [];
}
} catch (e) { console.error('Failed to fetch upscale models:', e); }
}
/**
* Save current config as a new preset
*/
async function saveUpscalePreset(name) {
if (!name || !upscaleModalState.currentConfig) return;
// Check for duplicate name and replace if exists
var existingIdx = -1;
for (var i = 0; i < upscaleModalState.presets.length; i++) {
if (upscaleModalState.presets[i].name === name) { existingIdx = i; break; }
}
var preset = {
name: name,
pipelines: JSON.parse(JSON.stringify(upscaleModalState.currentConfig.pipelines)),
hires_prompt_adjust: upscaleModalState.currentConfig.hires_prompt_adjust || false,
hires_prompt_behavior: upscaleModalState.currentConfig.hires_prompt_behavior || "append_end",
hires_prompt_text: upscaleModalState.currentConfig.hires_prompt_text || "",
};
if (existingIdx >= 0) {
upscaleModalState.presets[existingIdx] = preset;
} else {
upscaleModalState.presets.push(preset);
}
try {
await fetch('/configbuilder/upscale_presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ presets: upscaleModalState.presets })
});
} catch (e) { console.error('Failed to save preset:', e); }
renderUpscaleModal();
}
/**
* Delete a preset by index
*/
async function deleteUpscalePreset(index) {
upscaleModalState.presets.splice(index, 1);
try {
await fetch('/configbuilder/upscale_presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ presets: upscaleModalState.presets })
});
} catch (e) { console.error('Failed to delete preset:', e); }
renderUpscaleModal();
}
/**
* Load a preset into the current config
*/
function loadUpscalePreset(index) {
var preset = upscaleModalState.presets[index];
if (!preset) return;
upscaleModalState.currentConfig = {
pipelines: JSON.parse(JSON.stringify(preset.pipelines)),
hires_prompt_adjust: preset.hires_prompt_adjust || false,
hires_prompt_behavior: preset.hires_prompt_behavior || "append_end",
hires_prompt_text: preset.hires_prompt_text || "",
};
// Ensure all loaded steps have required fields
upscaleModalState.currentConfig.pipelines.forEach(function(p) {
if (p.active === undefined) p.active = true;
if (!p.name) p.name = "Pipeline";
if (!p.steps || p.steps.length === 0) p.steps = [_defaultUpscaleStep()];
p.steps.forEach(_ensureUpscaleStepFields);
});
renderUpscaleModal();
}
/**
* Helper: create a labeled select
*/
function _makeSelect(label, options, currentVal, onChange) {
var row = document.createElement('div');
row.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 6px;';
var lbl = document.createElement('span');
lbl.textContent = label;
lbl.style.cssText = 'font-size: 11px; color: #999; min-width: 70px;';
var sel = document.createElement('select');
sel.style.cssText = 'flex: 1; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 3px 6px; font-size: 11px;';
options.forEach(function(o) {
var opt = document.createElement('option');
opt.value = typeof o === 'object' ? o.value : o;
opt.textContent = typeof o === 'object' ? o.label : o;
if (opt.value === String(currentVal)) opt.selected = true;
sel.appendChild(opt);
});
sel.onchange = function() { onChange(sel.value); };
row.appendChild(lbl);
row.appendChild(sel);
return row;
}
/**
* Helper: create a labeled text input
*/
function _makeInput(label, currentVal, placeholder, onChange) {
var row = document.createElement('div');
row.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 6px;';
var lbl = document.createElement('span');
lbl.textContent = label;
lbl.style.cssText = 'font-size: 11px; color: #999; min-width: 70px;';
var inp = document.createElement('input');
inp.type = 'text';
inp.value = currentVal || '';
inp.placeholder = placeholder || '';
inp.style.cssText = 'flex: 1; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 3px 6px; font-size: 11px;';
inp.onchange = function() { onChange(inp.value); };
row.appendChild(lbl);
row.appendChild(inp);
return row;
}
/**
* Helper: create a labeled number input
*/
function _makeNumber(label, currentVal, min, max, step, onChange) {
var row = document.createElement('div');
row.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 6px;';
var lbl = document.createElement('span');
lbl.textContent = label;
lbl.style.cssText = 'font-size: 11px; color: #999; min-width: 70px;';
var inp = document.createElement('input');
inp.type = 'number';
inp.value = currentVal;
inp.min = min;
inp.max = max;
inp.step = step;
inp.style.cssText = 'flex: 1; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 3px 6px; font-size: 11px; max-width: 80px;';
inp.onchange = function() { var v = parseInt(inp.value); onChange(isNaN(v) ? min : Math.max(min, Math.min(max, v))); };
row.appendChild(lbl);
row.appendChild(inp);
return row;
}
/**
* Helper: create a labeled checkbox
*/
function _makeCheckbox(label, checked, onChange) {
var row = document.createElement('div');
row.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 6px;';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = checked;
cb.onchange = function() { onChange(cb.checked); };
var lbl = document.createElement('span');
lbl.textContent = label;
lbl.style.cssText = 'font-size: 11px; color: #ccc; cursor: pointer;';
lbl.onclick = function() { cb.checked = !cb.checked; onChange(cb.checked); };
row.appendChild(cb);
row.appendChild(lbl);
return row;
}
// ============================================================================
// PIPELINE EDITOR — Full pipeline/step UI, reusable component
// Mirrors the Builder UI's renderPipelines() in conf-builder-config-management.js
// ============================================================================
/**
* Render the full pipeline editor into a container.
* @param {HTMLElement} container — DOM element to render into (will be cleared)
* @param {Object} config — { pipelines: [...], hires_prompt_adjust, hires_prompt_behavior, hires_prompt_text }
* @param {Array} modelList — array of upscale model filenames
* @param {Object} callbacks — { onUpdate(), onRender() }
* onUpdate — called when any config value changes (caller can persist)
* onRender — called when DOM needs full re-render (add/remove pipeline/step, mode change)
*/
function renderPipelineEditor(container, config, modelList, callbacks) {
container.textContent = '';
// Ensure all pipelines/steps have required fields
if (!config.pipelines || config.pipelines.length === 0) {
config.pipelines = [{ active: true, name: "Pipeline 1", steps: [_defaultUpscaleStep()] }];
}
config.pipelines.forEach(function(p) {
if (p.active === undefined) p.active = true;
if (!p.name) p.name = "Pipeline";
if (!p.steps || p.steps.length === 0) p.steps = [_defaultUpscaleStep()];
p.steps.forEach(_ensureUpscaleStepFields);
});
// --- HiRes Prompt Adjustment Section ---
var hiresDiv = document.createElement('div');
hiresDiv.style.cssText = 'margin-bottom: 8px; padding: 6px 8px; background: #1e1e1e; border-radius: 4px;';
var hiresPromptRow = _makeCheckbox('Adjust Prompt During HiRes Fix', config.hires_prompt_adjust || false, function(v) {
config.hires_prompt_adjust = v;
hiresOpts.style.display = v ? 'block' : 'none';
callbacks.onUpdate();
});
hiresDiv.appendChild(hiresPromptRow);
var hiresOpts = document.createElement('div');
hiresOpts.style.cssText = 'margin-top: 4px; display: ' + (config.hires_prompt_adjust ? 'block' : 'none') + ';';
hiresOpts.appendChild(_makeSelect('Behavior:', [
{ value: 'prepend', label: 'Append To Front' },
{ value: 'append_end', label: 'Append To End' },
{ value: 'replace', label: 'Replace Prompt' },
], config.hires_prompt_behavior || 'append_end', function(v) {
config.hires_prompt_behavior = v;
callbacks.onUpdate();
}));
// Prompt text textarea
var promptRow = document.createElement('div');
promptRow.style.cssText = 'margin-bottom: 6px;';
var promptLbl = document.createElement('span');
promptLbl.textContent = 'HiRes Prompt:';
promptLbl.style.cssText = 'font-size: 11px; color: #999; display: block; margin-bottom: 2px;';
var promptTa = document.createElement('textarea');
promptTa.value = config.hires_prompt_text || '';
promptTa.placeholder = 'Enter prompt adjustment text...';
promptTa.style.cssText = 'width: 100%; min-height: 40px; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 4px 6px; font-size: 11px; resize: vertical; box-sizing: border-box;';
promptTa.onchange = function() { config.hires_prompt_text = promptTa.value; callbacks.onUpdate(); };
promptRow.appendChild(promptLbl);
promptRow.appendChild(promptTa);
hiresOpts.appendChild(promptRow);
hiresDiv.appendChild(hiresOpts);
container.appendChild(hiresDiv);
// --- Pipelines ---
var pipelinesContainer = document.createElement('div');
function reRender() {
renderPipelineEditor(container, config, modelList, callbacks);
}
config.pipelines.forEach(function(pipeline, pipeIdx) {
var pipeCard = document.createElement('div');
pipeCard.style.cssText = 'border: 2px solid #665599; border-radius: 8px; margin-bottom: 8px; background: #1a1a2e;' + (pipeline.active === false ? ' opacity: 0.5;' : '');
// Pipeline header (collapsible)
var pipeHeader = document.createElement('div');
pipeHeader.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; cursor: pointer; background: #252540; border-radius: 6px 6px 0 0;';
var pipeLeft = document.createElement('div');
pipeLeft.style.cssText = 'display: flex; align-items: center; gap: 6px;';
var pipeActiveCb = document.createElement('input');
pipeActiveCb.type = 'checkbox';
pipeActiveCb.checked = pipeline.active !== false;
pipeActiveCb.title = pipeline.active !== false ? 'Pipeline is ON' : 'Pipeline is OFF';
pipeActiveCb.onclick = function(e) { e.stopPropagation(); };
pipeActiveCb.onchange = function() {
pipeline.active = pipeActiveCb.checked;
pipeCard.style.opacity = pipeline.active !== false ? '1' : '0.5';
callbacks.onUpdate();
};
pipeLeft.appendChild(pipeActiveCb);
var pipeNameInput = document.createElement('input');
pipeNameInput.type = 'text';
pipeNameInput.value = pipeline.name || ('Pipeline ' + (pipeIdx + 1));
pipeNameInput.style.cssText = 'font-weight: bold; color: #cc99ff; font-size: 12px; background: transparent; border: 1px solid transparent; padding: 2px 4px; width: 130px;';
pipeNameInput.onclick = function(e) { e.stopPropagation(); };
pipeNameInput.onfocus = function() { pipeNameInput.style.borderColor = '#665599'; };
pipeNameInput.onblur = function() { pipeNameInput.style.borderColor = 'transparent'; };
pipeNameInput.onchange = function() { pipeline.name = pipeNameInput.value; callbacks.onUpdate(); };
pipeLeft.appendChild(pipeNameInput);
var pipeStepCount = document.createElement('span');
pipeStepCount.style.cssText = 'font-size: 10px; color: #888;';
pipeStepCount.textContent = '(' + pipeline.steps.length + ' step' + (pipeline.steps.length !== 1 ? 's' : '') + ')';
pipeLeft.appendChild(pipeStepCount);
pipeHeader.appendChild(pipeLeft);
var pipeRight = document.createElement('div');
pipeRight.style.cssText = 'display: flex; align-items: center; gap: 6px;';
if (config.pipelines.length > 1) {
var pipeDelBtn = document.createElement('button');
pipeDelBtn.textContent = '\u2715';
pipeDelBtn.style.cssText = 'background: #cc3333; color: #fff; border: none; border-radius: 3px; padding: 2px 8px; font-size: 12px; cursor: pointer;';
pipeDelBtn.onclick = function(e) {
e.stopPropagation();
config.pipelines.splice(pipeIdx, 1);
callbacks.onUpdate();
reRender();
};
pipeRight.appendChild(pipeDelBtn);
}
var collapseIcon = document.createElement('span');
collapseIcon.style.cssText = 'color: #888; font-size: 12px; user-select: none;';
collapseIcon.textContent = '\u25BC';
pipeRight.appendChild(collapseIcon);
pipeHeader.appendChild(pipeRight);
pipeCard.appendChild(pipeHeader);
// Pipeline body (collapsible)
var pipeBody = document.createElement('div');
pipeBody.style.cssText = 'padding: 6px 10px;';
pipeHeader.onclick = function() {
var isCollapsed = pipeBody.style.display === 'none';
pipeBody.style.display = isCollapsed ? 'block' : 'none';
collapseIcon.textContent = isCollapsed ? '\u25BC' : '\u25B6';
};
// Render steps
pipeline.steps.forEach(function(ucfg, stepIdx) {
_renderStep(pipeBody, config, pipeline, ucfg, stepIdx, modelList, callbacks, reRender);
});
// Add Step button
var addStepBtn = document.createElement('button');
addStepBtn.textContent = '+ Add Step';
addStepBtn.style.cssText = 'background: #333; color: #cc99ff; border: 1px solid #665599; border-radius: 4px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-top: 4px;';
addStepBtn.onclick = function() {
pipeline.steps.push(_defaultUpscaleStep());
callbacks.onUpdate();
reRender();
};
pipeBody.appendChild(addStepBtn);
pipeCard.appendChild(pipeBody);
pipelinesContainer.appendChild(pipeCard);
});
// Add Pipeline button
var addPipeBtn = document.createElement('button');
addPipeBtn.textContent = '+ Add Pipeline';
addPipeBtn.style.cssText = 'background: #333; color: #cc99ff; border: 1px solid #cc99ff; border-radius: 4px; padding: 4px 12px; font-size: 11px; cursor: pointer; margin-top: 4px;';
addPipeBtn.onclick = function() {
config.pipelines.push({
active: true,
name: 'Pipeline ' + (config.pipelines.length + 1),
steps: [_defaultUpscaleStep()]
});
callbacks.onUpdate();
reRender();
};
pipelinesContainer.appendChild(addPipeBtn);
container.appendChild(pipelinesContainer);
}
/**
* Render a single step card inside a pipeline body.
* Internal helper for renderPipelineEditor.
*/
function _renderStep(pipeBody, config, pipeline, ucfg, stepIdx, modelList, callbacks, reRender) {
var card = document.createElement('div');
card.style.cssText = 'border: 1px solid #444; border-radius: 6px; margin-bottom: 6px; padding: 8px;' + (ucfg.active === false ? ' opacity: 0.5;' : '');
// Card header
var cardHeader = document.createElement('div');
cardHeader.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;';
var cardLeft = document.createElement('div');
cardLeft.style.cssText = 'display: flex; align-items: center; gap: 6px;';
var activeCb = document.createElement('input');
activeCb.type = 'checkbox';
activeCb.checked = ucfg.active !== false;
activeCb.onchange = function() {
ucfg.active = activeCb.checked;
card.style.opacity = ucfg.active !== false ? '1' : '0.5';
callbacks.onUpdate();
};
cardLeft.appendChild(activeCb);
var cardTitle = document.createElement('span');
cardTitle.style.cssText = 'font-weight: bold; color: #cc99ff; font-size: 12px;';
cardTitle.textContent = 'Step ' + (stepIdx + 1);
cardLeft.appendChild(cardTitle);
// Repeat field inline
var repeatLbl = document.createElement('span');
repeatLbl.style.cssText = 'font-size: 10px; color: #888; margin-left: 6px;';
repeatLbl.textContent = 'Repeat:';
cardLeft.appendChild(repeatLbl);
var repeatInput = document.createElement('input');
repeatInput.type = 'number';
repeatInput.value = ucfg.repeat || 1;
repeatInput.min = 1; repeatInput.max = 20; repeatInput.step = 1;
repeatInput.style.cssText = 'width: 44px; padding: 1px 3px; font-size: 10px; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 3px;';
repeatInput.onchange = function() { ucfg.repeat = Math.max(1, parseInt(repeatInput.value) || 1); callbacks.onUpdate(); reRender(); };
cardLeft.appendChild(repeatInput);
if (ucfg.repeat > 1) {
var repeatNote = document.createElement('span');
repeatNote.style.cssText = 'font-size: 9px; color: #cc99ff;';
repeatNote.textContent = '(\u00D7' + ucfg.repeat + ' feedback loop)';
cardLeft.appendChild(repeatNote);
}
cardHeader.appendChild(cardLeft);
if (pipeline.steps.length > 1) {
var delBtn = document.createElement('button');
delBtn.textContent = '\u2715';
delBtn.style.cssText = 'background: #cc3333; color: #fff; border: none; border-radius: 3px; padding: 2px 8px; font-size: 11px; cursor: pointer;';
delBtn.onclick = function() {
pipeline.steps.splice(stepIdx, 1);
callbacks.onUpdate();
reRender();
};
cardHeader.appendChild(delBtn);
}
card.appendChild(cardHeader);
// Grid of settings
var grid = document.createElement('div');
grid.style.cssText = 'display: flex; flex-wrap: wrap; gap: 4px;';
// Mode select
grid.appendChild(_makeSelect('Mode:', [
{ value: 'hires_only', label: 'HiRes Fix Only' },
{ value: 'model_only', label: 'Model Upscale Only' },
{ value: 'model_then_hires', label: 'Model + HiRes Fix' },
{ value: 'seedvr2', label: 'SeedVR2 Upscale' },
], ucfg.mode, function(v) {
ucfg.mode = v;
callbacks.onUpdate();
reRender();
}));
// Resize method
grid.appendChild(_makeSelect('Resize:', [
'nearest-exact', 'bilinear', 'area', 'bicubic', 'lanczos'
], ucfg.resize_method || 'bilinear', function(v) {
ucfg.resize_method = v;
callbacks.onUpdate();
}));
var showHires = ucfg.mode === 'hires_only' || ucfg.mode === 'model_then_hires';
var showModel = ucfg.mode === 'model_only' || ucfg.mode === 'model_then_hires';
var showSeedVR2 = ucfg.mode === 'seedvr2';
// --- HiRes fields ---
if (showHires) {
grid.appendChild(_makeInput('Ratios:', ucfg.upscale_ratios || '1.5', '1.2, 1.5, 2.0', function(v) {
ucfg.upscale_ratios = v; callbacks.onUpdate();
}));
grid.appendChild(_makeInput('Denoise:', ucfg.hires_denoise || '0.3', '0.2, 0.3, 0.5', function(v) {
ucfg.hires_denoise = v; callbacks.onUpdate();
}));
grid.appendChild(_makeNumber('HiRes Steps:', ucfg.hires_steps || 0, 0, 150, 1, function(v) {
ucfg.hires_steps = v; callbacks.onUpdate();
}));
// Tiled sampling
grid.appendChild(_makeCheckbox('HiRes Tiled Sampling', ucfg.hires_tiled_sampling || false, function(v) {
ucfg.hires_tiled_sampling = v; callbacks.onUpdate(); reRender();
}));
if (ucfg.hires_tiled_sampling) {
grid.appendChild(_makeNumber('Tile Width:', ucfg.hires_tile_width || 512, 128, 2048, 64, function(v) {
ucfg.hires_tile_width = v; callbacks.onUpdate();
}));
grid.appendChild(_makeNumber('Tile Height:', ucfg.hires_tile_height || 512, 128, 2048, 64, function(v) {
ucfg.hires_tile_height = v; callbacks.onUpdate();
}));
grid.appendChild(_makeNumber('Mask Blur:', ucfg.hires_mask_blur || 8, 0, 64, 1, function(v) {
ucfg.hires_mask_blur = v; callbacks.onUpdate();
}));
grid.appendChild(_makeNumber('Tile Padding:', ucfg.hires_tile_padding || 32, 0, 256, 8, function(v) {
ucfg.hires_tile_padding = v; callbacks.onUpdate();
}));
grid.appendChild(_makeCheckbox('Force Uniform Tiles', ucfg.hires_force_uniform_tiles !== false, function(v) {
ucfg.hires_force_uniform_tiles = v; callbacks.onUpdate();
}));
}
// Tiled VAE
grid.appendChild(_makeCheckbox('Tiled VAE Decode', ucfg.tiled_vae || false, function(v) {
ucfg.tiled_vae = v; callbacks.onUpdate(); reRender();
}));
if (ucfg.tiled_vae) {
grid.appendChild(_makeNumber('Tile Size:', ucfg.tile_size || 512, 128, 1024, 64, function(v) {
ucfg.tile_size = v; callbacks.onUpdate();
}));
grid.appendChild(_makeNumber('Tile Overlap:', ucfg.tile_overlap || 64, 0, 512, 8, function(v) {
ucfg.tile_overlap = v; callbacks.onUpdate();
}));
grid.appendChild(_makeNumber('Temporal Size:', ucfg.temporal_size || 512, 128, 1024, 64, function(v) {
ucfg.temporal_size = v; callbacks.onUpdate();
}));
grid.appendChild(_makeNumber('Temporal Overlap:', ucfg.temporal_overlap || 64, 0, 512, 8, function(v) {
ucfg.temporal_overlap = v; callbacks.onUpdate();
}));
}
}
// --- Model fields ---
if (showModel) {
// Output size multiplier — only for model_only mode
if (ucfg.mode === 'model_only') {
grid.appendChild(_makeInput('Size Mult:', ucfg.upscale_size || '2.0', '2.0', function(v) {
ucfg.upscale_size = v; callbacks.onUpdate();
}));
}
// Model multi-select: chips + searchable dropdown
var modelsWrap = document.createElement('div');
modelsWrap.style.cssText = 'width: 100%; margin-bottom: 6px;';
var noModels = !ucfg.upscale_models || ucfg.upscale_models.length === 0;
if (noModels) {
modelsWrap.style.border = '2px solid #ff3333';
modelsWrap.style.borderRadius = '4px';
modelsWrap.style.padding = '4px';
}
var modelsLbl = document.createElement('div');
modelsLbl.style.cssText = 'font-size: 11px; color: ' + (noModels ? '#ff3333' : '#999') + '; margin-bottom: 3px;';
modelsLbl.textContent = 'Upscale Models (' + (ucfg.upscale_models || []).length + ')' + (noModels ? ' \u2014 Select a model!' : '');
modelsWrap.appendChild(modelsLbl);
// Model chips
(ucfg.upscale_models || []).forEach(function(modelName, mIdx) {
var chip = document.createElement('div');
chip.style.cssText = 'display: inline-flex; align-items: center; gap: 3px; background: #333; border-radius: 4px; padding: 2px 6px; margin: 2px; font-size: 10px; color: #ccc;';
chip.textContent = modelName.replace(/\\/g, '/').split('/').pop();
var removeBtn = document.createElement('span');
removeBtn.textContent = '\u2715';
removeBtn.style.cssText = 'cursor: pointer; color: #cc3333; margin-left: 3px; font-size: 11px;';
removeBtn.onclick = function() {
ucfg.upscale_models.splice(mIdx, 1);
callbacks.onUpdate();
reRender();
};
chip.appendChild(removeBtn);
modelsWrap.appendChild(chip);
});
// Searchable model dropdown
var modelSearchWrap = document.createElement('div');
modelSearchWrap.style.cssText = 'position: relative; margin-top: 3px;';
var modelSearchInput = document.createElement('input');
modelSearchInput.type = 'text';
modelSearchInput.placeholder = 'Search upscale models...';
modelSearchInput.style.cssText = 'width: 100%; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 3px 6px; font-size: 10px; box-sizing: border-box;';
var modelDropdown = document.createElement('div');
modelDropdown.style.cssText = 'display: none; position: absolute; top: 100%; left: 0; right: 0; max-height: 150px; overflow-y: auto; background: #222; border: 1px solid #555; border-radius: 4px; z-index: 100;';
function populateModelDropdown(query) {
modelDropdown.textContent = '';
var q = (query || '').toLowerCase();
var existing = ucfg.upscale_models || [];
var filtered = (modelList || []).filter(function(m) {
if (existing.indexOf(m) >= 0) return false;
return !q || m.toLowerCase().indexOf(q) >= 0;
});
if (filtered.length === 0) {
modelDropdown.style.display = 'none';
return;
}
modelDropdown.style.display = 'block';
filtered.forEach(function(m) {
var opt = document.createElement('div');
opt.style.cssText = 'padding: 3px 8px; font-size: 10px; color: #ccc; cursor: pointer;';
opt.textContent = m.replace(/\\/g, '/').split('/').pop();
opt.title = m;
opt.onmouseenter = function() { opt.style.background = '#444'; };
opt.onmouseleave = function() { opt.style.background = 'transparent'; };
opt.onclick = function() {
if (!ucfg.upscale_models) ucfg.upscale_models = [];
if (ucfg.upscale_models.indexOf(m) < 0) {
ucfg.upscale_models.push(m);
callbacks.onUpdate();
reRender();
}
};
modelDropdown.appendChild(opt);
});
}
modelSearchInput.onfocus = function() { populateModelDropdown(modelSearchInput.value); };
modelSearchInput.oninput = function() { populateModelDropdown(modelSearchInput.value); };
modelSearchInput.onblur = function() {
// Delay hide so click on dropdown item registers
setTimeout(function() { modelDropdown.style.display = 'none'; }, 200);
};
modelSearchWrap.appendChild(modelSearchInput);
modelSearchWrap.appendChild(modelDropdown);
modelsWrap.appendChild(modelSearchWrap);
grid.appendChild(modelsWrap);
}
// --- SeedVR2 fields ---
if (showSeedVR2) {
if (!ucfg.seedvr2) ucfg.seedvr2 = _defaultSeedVR2Options();
var sv = ucfg.seedvr2;
var ditModels = [
'seedvr2_ema_3b_fp8_e4m3fn.safetensors', 'seedvr2_ema_3b_fp16.safetensors',
'seedvr2_ema_3b-Q4_K_M.gguf', 'seedvr2_ema_3b-Q8_0.gguf',
'seedvr2_ema_7b_fp8_e4m3fn_mixed_block35_fp16.safetensors', 'seedvr2_ema_7b_fp16.safetensors',
'seedvr2_ema_7b-Q4_K_M.gguf', 'seedvr2_ema_7b_sharp_fp8_e4m3fn_mixed_block35_fp16.safetensors',
'seedvr2_ema_7b_sharp_fp16.safetensors', 'seedvr2_ema_7b_sharp-Q4_K_M.gguf'
];
grid.appendChild(_makeSelect('DiT Model:', ditModels.map(function(m) {
return { value: m, label: m.replace('.safetensors', '').replace('.gguf', ' (GGUF)') };
}), sv.dit_model, function(v) { sv.dit_model = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Resolution:', sv.resolution || 1080, 16, 16384, 2, function(v) { sv.resolution = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Max Res (0=none):', sv.max_resolution || 0, 0, 16384, 2, function(v) { sv.max_resolution = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Seed:', sv.seed || 42, 0, 4294967295, 1, function(v) { sv.seed = v; callbacks.onUpdate(); }));
grid.appendChild(_makeSelect('Color Correct:', ['lab', 'wavelet', 'wavelet_adaptive', 'hsv', 'adain', 'none'],
sv.color_correction || 'lab', function(v) { sv.color_correction = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Input Noise:', sv.input_noise_scale || 0, 0, 1, 0.001, function(v) { sv.input_noise_scale = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Latent Noise:', sv.latent_noise_scale || 0, 0, 1, 0.001, function(v) { sv.latent_noise_scale = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Blocks to Swap:', sv.blocks_to_swap || 0, 0, 36, 1, function(v) { sv.blocks_to_swap = v; callbacks.onUpdate(); }));
grid.appendChild(_makeSelect('Attention:', ['sdpa', 'flash_attn_2', 'flash_attn_3', 'sageattn_2', 'sageattn_3'],
sv.attention_mode || 'sdpa', function(v) { sv.attention_mode = v; callbacks.onUpdate(); }));
grid.appendChild(_makeSelect('DiT Offload:', ['none', 'cpu'], sv.offload_device || 'cpu', function(v) { sv.offload_device = v; callbacks.onUpdate(); }));
grid.appendChild(_makeCheckbox('Encode Tiled', sv.encode_tiled || false, function(v) { sv.encode_tiled = v; callbacks.onUpdate(); reRender(); }));
if (sv.encode_tiled) {
grid.appendChild(_makeNumber('Enc Tile Size:', sv.encode_tile_size || 1024, 64, 4096, 32, function(v) { sv.encode_tile_size = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Enc Overlap:', sv.encode_tile_overlap || 128, 0, 1024, 32, function(v) { sv.encode_tile_overlap = v; callbacks.onUpdate(); }));
}
grid.appendChild(_makeCheckbox('Decode Tiled', sv.decode_tiled || false, function(v) { sv.decode_tiled = v; callbacks.onUpdate(); reRender(); }));
if (sv.decode_tiled) {
grid.appendChild(_makeNumber('Dec Tile Size:', sv.decode_tile_size || 1024, 64, 4096, 32, function(v) { sv.decode_tile_size = v; callbacks.onUpdate(); }));
grid.appendChild(_makeNumber('Dec Overlap:', sv.decode_tile_overlap || 128, 0, 1024, 32, function(v) { sv.decode_tile_overlap = v; callbacks.onUpdate(); }));
}
}
card.appendChild(grid);
// Iteration count display
var countDisplay = document.createElement('div');
countDisplay.style.cssText = 'color: #00cc00; font-family: monospace; font-size: 10px; margin-top: 4px;';
var ratios = (ucfg.upscale_ratios || '1.5').split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }).length;
var denoises = (ucfg.hires_denoise || '0.3').split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }).length;
var models = Math.max(1, (ucfg.upscale_models || []).length);
var combos = 1;
if (showSeedVR2) combos = 1;
else if (showHires) combos *= ratios * denoises;
if (showModel) combos *= models;
var repeatCount = ucfg.repeat || 1;
countDisplay.textContent = combos + ' upscale combo(s) per image' + (repeatCount > 1 ? ' \u00D7 ' + repeatCount + ' repeats' : '');
card.appendChild(countDisplay);
pipeBody.appendChild(card);
}
// ============================================================================
// MODAL RENDERING — Uses renderPipelineEditor for full pipeline UI
// ============================================================================
/**
* Render the upscale modal
*/
function renderUpscaleModal() {
var overlay = document.getElementById('upscale-modal-overlay');
if (!overlay) return;
overlay.style.display = 'flex';
var item = upscaleModalState.imageData;
var favCount = activeData ? activeData.filter(function(d) { return d.favorited; }).length : 0;
var cfg = upscaleModalState.currentConfig;
var modal = document.getElementById('upscale-modal-content');
if (!modal) return;
modal.textContent = '';
// Header
var header = document.createElement('div');
header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;';
var title = document.createElement('h3');
title.style.cssText = 'margin: 0; color: #fff; font-size: 16px;';
title.textContent = 'Upscale Image';
var closeBtn = document.createElement('button');
closeBtn.textContent = '\u2715';
closeBtn.style.cssText = 'background: none; border: none; color: #999; font-size: 18px; cursor: pointer;';
closeBtn.onclick = closeUpscaleModal;
header.appendChild(title);
header.appendChild(closeBtn);
modal.appendChild(header);
// Scope toggle
var scopeDiv = document.createElement('div');
scopeDiv.style.cssText = 'margin-bottom: 10px; padding: 8px; background: #252525; border-radius: 4px;';
var radioThis = document.createElement('input');
radioThis.type = 'radio'; radioThis.name = 'upscale-scope'; radioThis.value = 'single';
radioThis.checked = true; radioThis.id = 'upscale-scope-single';
var labelThis = document.createElement('label');
labelThis.htmlFor = 'upscale-scope-single';
labelThis.style.cssText = 'color: #ccc; font-size: 12px; cursor: pointer; margin-right: 16px;';
labelThis.textContent = ' Upscale This Image';
var radioAll = document.createElement('input');
radioAll.type = 'radio'; radioAll.name = 'upscale-scope'; radioAll.value = 'favorites';
radioAll.id = 'upscale-scope-favorites';
var labelAll = document.createElement('label');
labelAll.htmlFor = 'upscale-scope-favorites';
labelAll.style.cssText = 'color: #ccc; font-size: 12px; cursor: pointer;';
labelAll.textContent = ' Upscale All Favorited (' + favCount + ' images)';
scopeDiv.appendChild(radioThis); scopeDiv.appendChild(labelThis);
scopeDiv.appendChild(radioAll); scopeDiv.appendChild(labelAll);
modal.appendChild(scopeDiv);
// === FULL PIPELINE EDITOR ===
var configDiv = document.createElement('div');
configDiv.style.cssText = 'margin-bottom: 10px; padding: 8px; background: #252525; border-radius: 4px; max-height: 55vh; overflow-y: auto;';
var configTitle = document.createElement('div');
configTitle.style.cssText = 'font-size: 12px; color: #fff; font-weight: bold; margin-bottom: 8px;';
configTitle.textContent = 'Upscale Pipeline Configuration';
configDiv.appendChild(configTitle);
// Ensure config exists
if (!cfg) { cfg = _defaultUpscaleConfig(); upscaleModalState.currentConfig = cfg; }
// Pipeline editor container (separate from configDiv title so reRender only clears this)
var editorContainer = document.createElement('div');
configDiv.appendChild(editorContainer);
renderPipelineEditor(editorContainer, cfg, upscaleModalState.availableModels, {
onUpdate: function() { /* config is mutated in-place, nothing to persist */ },
onRender: function() { /* no-op — renderPipelineEditor self-re-renders via reRender() */ }
});
modal.appendChild(configDiv);
// === PRESET SELECTOR ===
var presetDiv = document.createElement('div');
presetDiv.style.cssText = 'margin-bottom: 10px; padding: 8px; background: #1e1e1e; border-radius: 4px; border: 1px solid #333;';
var presetRow = document.createElement('div');
presetRow.style.cssText = 'display: flex; gap: 4px; align-items: center;';
var presetLbl = document.createElement('span');
presetLbl.textContent = 'Presets:';
presetLbl.style.cssText = 'font-size: 10px; color: #666; white-space: nowrap;';
var presetSelect = document.createElement('select');
presetSelect.style.cssText = 'flex: 1; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 3px 6px; font-size: 10px;';
var defaultOpt = document.createElement('option');
defaultOpt.value = ''; defaultOpt.textContent = '-- Presets --';
presetSelect.appendChild(defaultOpt);
upscaleModalState.presets.forEach(function(p, i) {
var opt = document.createElement('option');
opt.value = i; opt.textContent = p.name;
presetSelect.appendChild(opt);
});
var pLoadBtn = document.createElement('button');
pLoadBtn.textContent = 'Load';
pLoadBtn.style.cssText = 'background: var(--accent); color: #000; border: none; border-radius: 3px; padding: 2px 6px; font-size: 10px; cursor: pointer; font-weight: bold;';
pLoadBtn.onclick = function() { var idx = parseInt(presetSelect.value); if (!isNaN(idx)) loadUpscalePreset(idx); };
var pSaveBtn = document.createElement('button');
pSaveBtn.textContent = 'Save';
pSaveBtn.style.cssText = 'background: #555; color: #fff; border: none; border-radius: 3px; padding: 2px 6px; font-size: 10px; cursor: pointer;';
pSaveBtn.onclick = function() { var n = prompt('Preset name:'); if (n) saveUpscalePreset(n); };
var pDelBtn = document.createElement('button');
pDelBtn.textContent = 'Del';
pDelBtn.style.cssText = 'background: var(--danger); color: #fff; border: none; border-radius: 3px; padding: 2px 6px; font-size: 10px; cursor: pointer;';
pDelBtn.onclick = function() {
var idx = parseInt(presetSelect.value);
if (!isNaN(idx) && idx >= 0 && idx < upscaleModalState.presets.length) {
if (confirm('Delete "' + upscaleModalState.presets[idx].name + '"?')) deleteUpscalePreset(idx);
}
};
presetRow.appendChild(presetLbl);
presetRow.appendChild(presetSelect);
presetRow.appendChild(pLoadBtn);
presetRow.appendChild(pSaveBtn);
presetRow.appendChild(pDelBtn);
presetDiv.appendChild(presetRow);
modal.appendChild(presetDiv);
// Progress area (hidden until upscale starts)
var progressDiv = document.createElement('div');
progressDiv.id = 'upscale-modal-progress';
progressDiv.style.cssText = 'margin-bottom: 10px; display: none;';
var progressBar = document.createElement('div');
progressBar.style.cssText = 'height: 6px; background: #333; border-radius: 3px; overflow: hidden;';
var progressFill = document.createElement('div');
progressFill.id = 'upscale-modal-progress-fill';
progressFill.style.cssText = 'height: 100%; background: var(--accent); width: 0%; transition: width 0.3s;';
progressBar.appendChild(progressFill);
progressDiv.appendChild(progressBar);
var progressText = document.createElement('div');
progressText.id = 'upscale-modal-progress-text';
progressText.style.cssText = 'font-size: 11px; color: #999; margin-top: 4px;';
progressDiv.appendChild(progressText);
modal.appendChild(progressDiv);
// Action buttons
var actionsDiv = document.createElement('div');
actionsDiv.style.cssText = 'display: flex; gap: 8px; justify-content: flex-end;';
var cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'background: #444; color: #fff; border: none; border-radius: 4px; padding: 6px 16px; font-size: 12px; cursor: pointer;';
cancelBtn.onclick = function() {
if (upscaleModalState.jobId && upscaleModalState.pollInterval) {
// Active upscale — send cancel and let the poll detect "cancelled" status
fetch('/config_tester/cancel_upscale', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: upscaleModalState.jobId })
});
cancelBtn.disabled = true;
cancelBtn.textContent = 'Cancelling...';
cancelBtn.style.opacity = '0.5';
} else {
// No active upscale — just close
closeUpscaleModal();
}
};
var startBtn = document.createElement('button');
startBtn.textContent = 'Start Upscale';
startBtn.id = 'upscale-start-btn';
startBtn.style.cssText = 'background: var(--accent); color: #000; border: none; border-radius: 4px; padding: 6px 16px; font-size: 12px; cursor: pointer; font-weight: bold;';
startBtn.onclick = startUpscaleFromModal;
actionsDiv.appendChild(cancelBtn);
actionsDiv.appendChild(startBtn);
modal.appendChild(actionsDiv);
}
/**
* Start the upscale job from the modal
*/
async function startUpscaleFromModal() {
var cfg = upscaleModalState.currentConfig;
if (!cfg) return;
var scopeEl = document.querySelector('input[name="upscale-scope"]:checked');
var scope = scopeEl ? scopeEl.value : 'single';
var allFavorited = scope === 'favorites';
var imageIds = allFavorited ? [] : [upscaleModalState.imageId];
var startBtn = document.getElementById('upscale-start-btn');
if (startBtn) { startBtn.disabled = true; startBtn.style.opacity = '0.5'; startBtn.textContent = 'Starting...'; }
var progressDiv = document.getElementById('upscale-modal-progress');
if (progressDiv) progressDiv.style.display = 'block';
try {
var resp = await fetch('/config_tester/upscale_images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_name: document.getElementById('session-input')?.value || "default",
image_ids: imageIds,
upscale_config: cfg,
all_favorited: allFavorited,
})
});
var result = await resp.json();
if (result.error) {
alert('Upscale failed: ' + result.error);
if (startBtn) { startBtn.disabled = false; startBtn.style.opacity = '1'; startBtn.textContent = 'Start Upscale'; }
return;
}
upscaleModalState.jobId = result.job_id;
if (startBtn) startBtn.textContent = 'Upscaling...';
// Poll for progress
upscaleModalState.pollInterval = setInterval(async function() {
try {
var statusResp = await fetch('/config_tester/upscale_status?job_id=' + upscaleModalState.jobId);
var status = await statusResp.json();
var fill = document.getElementById('upscale-modal-progress-fill');
var text = document.getElementById('upscale-modal-progress-text');
if (fill && status.total > 0) fill.style.width = Math.round((status.completed / status.total) * 100) + '%';
if (text) text.textContent = 'Upscaling ' + status.completed + '/' + status.total + '...';
if (status.status === 'complete' || status.status === 'error' || status.status === 'cancelled') {
clearInterval(upscaleModalState.pollInterval);
upscaleModalState.pollInterval = null;
upscaleModalState.jobId = null;
if (text) {
if (status.status === 'complete') text.textContent = 'Complete! ' + status.completed + ' images upscaled.';
else if (status.status === 'cancelled') text.textContent = 'Cancelled. ' + status.completed + '/' + status.total + ' completed.';
else text.textContent = 'Error: ' + (status.error || 'Unknown error');
}
if (startBtn) { startBtn.disabled = false; startBtn.style.opacity = '1'; startBtn.textContent = 'Start Upscale'; }
// Reset cancel button to Close
var cBtn = document.querySelector('#upscale-modal-content button');
if (cBtn && (cBtn.textContent === 'Cancelling...' || cBtn.textContent === 'Cancel')) {
cBtn.disabled = false; cBtn.style.opacity = '1'; cBtn.textContent = 'Close';
}
}
} catch (e) { /* ignore poll errors */ }
}, 2000);
} catch (e) {
alert('Failed to start upscale: ' + e);
if (startBtn) { startBtn.disabled = false; startBtn.style.opacity = '1'; startBtn.textContent = 'Start Upscale'; }
}
}