diff --git a/html_generator.py b/html_generator.py index 2cbc147..ac7b7d2 100644 --- a/html_generator.py +++ b/html_generator.py @@ -22,6 +22,7 @@ def get_html_template(title, manifest_data, node_id, standalone=False): "logic_state.js", # Global variables "logic_utils.js", # API & Helper functions "logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard) + "logic_wfparams.js", # Workflow Sweep: wf_params helpers (pure; must load before logic_ui/logic_pipeline) "logic_ui.js", # DOM Creation, Modals, JSON Bars "logic_upscale_modal.js", # Upscale Modal (preset management, async upscale) "logic_virtual.js", # The Virtual Scroll Engine & Viewport diff --git a/resources/logic_events.js b/resources/logic_events.js index ea35b12..0a2f8bb 100644 --- a/resources/logic_events.js +++ b/resources/logic_events.js @@ -38,7 +38,7 @@ window.addEventListener('message', (event) => { if (etaText) { const phaseLabel = p.phase === 'upscaling' ? 'Upscaling' : (p.phase === 'sweep' ? 'Sweep job' : 'Job'); const finishPart = p.finish_time ? ` | ~${p.finish_time}` : ''; - etaText.textContent = `${phaseLabel} ${p.current_job}/${p.total_jobs} (${p.progress_pct}%) | ETA: ${p.eta_str}${finishPart} | ${p.avg_duration}s/job${p.errors ? ` | ${p.errors} error${p.errors === 1 ? '' : 's'}` : ''}`; + etaText.textContent = `${phaseLabel} ${p.current_job}/${p.total_jobs} (${p.progress_pct}%) | ETA: ${p.eta_str}${finishPart} | ${p.avg_duration}s/job${p.errors ? ` | ${p.errors} error${p.errors === 1 ? '' : 's'}` : ''}${(p.state === 'paused' || p.state === 'cancelled') ? ` | ${p.state}` : ''}`; } if (etaProgress) { etaProgress.style.width = `${p.progress_pct}%`; diff --git a/resources/logic_pipeline.js b/resources/logic_pipeline.js index 7967cc9..ec1c13d 100644 --- a/resources/logic_pipeline.js +++ b/resources/logic_pipeline.js @@ -25,6 +25,9 @@ function refreshIndices() { * Returns null if the field is missing or not parseable as a number. */ function _extractFieldValue(item, field) { + if (typeof field === 'string' && field.startsWith('wf:')) { + return (typeof wfParamNumber === 'function') ? wfParamNumber(item, field.slice(3)) : null; + } if (typeof field === 'string' && field.startsWith('metric:')) { const mv = item.metrics ? item.metrics[field.slice(7)] : undefined; return (typeof mv === 'number' && isFinite(mv)) ? mv : null; @@ -95,6 +98,9 @@ function matchesLogicFilters(item) { * - everything else: String exact match. */ function _quickFilterMatches(item, f) { + if (typeof f.type === 'string' && f.type.startsWith('wf:')) { + return (typeof wfParamMatches === 'function') && wfParamMatches(item, f.type.slice(3), f.value); + } if (f.type === 'lora') { const lora = String(item.lora || 'None'); if (lora === 'None') return f.value === 'None'; @@ -547,6 +553,7 @@ function executePipeline() { const startTime = performance.now(); refreshSupersededFps(); if (typeof populateMetricFilterUI === 'function') populateMetricFilterUI(); + if (typeof populateWfParamFilterUI === 'function') populateWfParamFilterUI(); const currentFilterKey = getFilterKey(); const filtersChanged = currentFilterKey !== lastFilterKey; diff --git a/resources/logic_state.js b/resources/logic_state.js index 3542052..6236e0c 100644 --- a/resources/logic_state.js +++ b/resources/logic_state.js @@ -142,7 +142,8 @@ var labelMode = labelMode || { hiresPromptBehavior: false, hiresPromptText: false, rating: false, // Pairwise-review rating badge - metrics: false + metrics: false, + wfParams: false // Workflow Sweep: varied wf_params as tags } }; diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 2d172f8..aa28dec 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -318,6 +318,17 @@ function buildLabelOverlay(d) { } } + if (labelMode.fields.wfParams && d.wf_params && typeof d.wf_params === 'object' && typeof wfParamKeys === 'function') { + const wfKeys = wfParamKeys(activeData); + const esc = (t) => String(t).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + for (const row of wfKeys) { + if (row.distinct <= 1) continue; // constant axes are noise + const v = wfParamValue(d, row.key); + if (v === null) continue; + tags.push(`${esc(wfShortLabel(row.key, wfKeys))}:${esc(wfDisplay(v))}`); + } + } + if (tags.length === 0) return ''; return `
${tags.join('')}
`; } @@ -343,7 +354,7 @@ function toggleLabelMode(enabled) { function updateLabelFields() { const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale', 'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling', - 'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics']; + 'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics', 'wfParams']; fields.forEach(f => { const cb = document.getElementById(`label-field-${f}`); if (cb) labelMode.fields[f] = cb.checked; @@ -411,7 +422,7 @@ function loadLabelPreferences() { const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale', 'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling', - 'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics']; + 'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics', 'wfParams']; fields.forEach(f => { const cb = document.getElementById(`label-field-${f}`); if (cb) cb.checked = labelMode.fields[f] || false; @@ -1368,7 +1379,8 @@ function createCard(d) { ? `` : `Image ${d.id}`; // Workflow Sweep items: the Revise modal builds a standard config and would lose the workflow template - const reviseBtn = (isVideo || d.model_type === 'workflow') ? '' : ``; + // (they get a JOB button instead: openM shows the read-only Workflow job panel) + const reviseBtn = isVideo ? '' : ``; const upscaleBtn = isVideo ? '' : ``; const videoBadge = isVideo ? '
\u25B6 VIDEO
' : ''; const previewBadge = d.preview @@ -1423,12 +1435,49 @@ function createCard(d) { return card; } +// Workflow Sweep items show a read-only "Workflow job" panel instead of the revise form. +function _setModalWorkflowMode(d) { + const isWf = d.model_type === 'workflow'; + const rev = document.getElementById('m-revise-controls'); + const pnl = document.getElementById('m-workflow-panel'); + if (!rev || !pnl) return; + rev.style.display = isWf ? 'none' : 'flex'; + pnl.style.display = isWf ? 'flex' : 'none'; + if (!isWf) return; + const esc = (t) => String(t).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const metaEl = document.getElementById('m-wf-meta'); + if (metaEl) { + const bits = [d.wf_source || 'workflow']; + if (d.wf_output && d.wf_output.node_id !== undefined) bits.push('output ' + d.wf_output.node_id + (d.wf_output.output_index ? ':' + d.wf_output.output_index : '')); + if (d.wf_sweep_id) bits.push('sweep ' + d.wf_sweep_id); + if (d.wf_job_index !== undefined && d.wf_job_index !== null) bits.push('job #' + (Number(d.wf_job_index) + 1)); + metaEl.textContent = bits.join(' \u00B7 '); + } + const tbl = document.getElementById('m-wf-params'); + if (tbl) { + const p = (d.wf_params && typeof d.wf_params === 'object') ? d.wf_params : {}; + const keys = Object.keys(p).sort(); + tbl.innerHTML = keys.length + ? keys.map(k => `${esc(k)}${esc(p[k])}`).join('') + : 'No varied parameters recorded.'; + } + const canFetch = !!d.wf_sweep_id && d.wf_job_index !== undefined && d.wf_job_index !== null; + ['m-wf-download', 'm-wf-copy'].forEach(bid => { + const b = document.getElementById(bid); + if (!b) return; + b.disabled = !canFetch; + b.title = canFetch ? '' : 'This item was not produced by a recorded sweep (no sweep id).'; + b.style.opacity = canFetch ? '' : '0.5'; + }); +} + // Open Revision Modal function openM(id) { window.currentModalId = id; const d = activeData.find(x => x.id === id); if (!d) return; document.getElementById('m-img').src = d.file; + _setModalWorkflowMode(d); const previewNote = document.getElementById('m-preview-note'); if (previewNote) { if (d.preview) { @@ -2003,6 +2052,37 @@ function addLogicFilter() { console.log(`[Logic Filter] Added: ${field} ${op} ${value}`); } +// --- Workflow params in the Logic Filter (numeric wf_params keys only; group is absent when no item has wf_params) --- +var _wfUiSig = null; + +function populateWfParamFilterUI() { + if (typeof wfParamKeys !== 'function') return; + const keys = wfParamKeys(activeData).filter(k => k.numeric); + const sig = keys.map(k => k.key).join(','); + if (sig === _wfUiSig) return; + _wfUiSig = sig; + const sel = document.getElementById('logic-filter-field'); + if (!sel) return; + const prev = sel.value; + const old = document.getElementById('logic-filter-wf-group'); + if (old) old.remove(); + if (keys.length) { + const group = document.createElement('optgroup'); + group.id = 'logic-filter-wf-group'; + group.label = 'Workflow params'; + const all = keys.map(k => k.key); + keys.forEach(k => { + const o = document.createElement('option'); + o.value = 'wf:' + k.key; + o.textContent = wfShortLabel(k.key, all); + o.title = k.key; + group.appendChild(o); + }); + sel.appendChild(group); + } + sel.value = prev; +} + // --- Metric fields in the Logic Filter (populated from the data; hidden when no item has metrics) --- var _metricUiSig = null; @@ -2129,7 +2209,9 @@ function renderLogicFilters() { tag.className = 'search-filter-tag'; const label = fieldLabels[filter.field] || - (typeof filter.field === 'string' && filter.field.startsWith('metric:') ? metricLabel(filter.field.slice(7)) : filter.field); + (typeof filter.field === 'string' && filter.field.startsWith('metric:') ? metricLabel(filter.field.slice(7)) : + (typeof filter.field === 'string' && filter.field.startsWith('wf:') && typeof wfShortLabel === 'function' + ? wfShortLabel(filter.field.slice(3), wfParamKeys(activeData)) : filter.field)); tag.innerHTML = ` ${label} ${filter.op} ${filter.value} @@ -2180,11 +2262,29 @@ const _qfAllTypes = ['lora', 'lora_strength', 'cfg', 'steps', 'sampler', 'schedu // Types shown in the expanded facet panel (omit prompt types for brevity) const _qfFacetableTypes = ['sampler', 'scheduler', 'lora', 'lora_strength', 'model', 'denoise', 'cfg', 'steps', 'size', 'seed', 'upscaleMethod', 'mediaType', 'passType']; +// Workflow Sweep: one extra chip type per VARIED wf_params key ("wf:"); the lists are dynamic. +function _qfTypes(base) { + if (typeof wfParamKeys !== 'function') return base; + return base.concat(wfParamKeys(typeof activeData !== 'undefined' ? activeData : []).filter(k => k.distinct > 1).map(k => 'wf:' + k.key)); +} + +function _qfLabel(type) { + if (_qfTypeLabels[type]) return _qfTypeLabels[type]; + if (typeof type === 'string' && type.startsWith('wf:') && typeof wfShortLabel === 'function') { + return 'WF ' + wfShortLabel(type.slice(3), wfParamKeys(typeof activeData !== 'undefined' ? activeData : [])); + } + return type; +} + /** * Extract a facet value (or array of values) from an item for a given type. * Returns null if the field is missing. */ function _qfExtractItemValue(item, type) { + if (typeof type === 'string' && type.startsWith('wf:')) { + const raw = wfParamValue(item, type.slice(3)); + return raw === null ? null : String(raw); + } if (type === 'lora') { const lora = String(item.lora || 'None'); if (lora === 'None') return ['None']; @@ -2251,12 +2351,13 @@ function _computeChipFacets(chip) { const data = (typeof activeData !== 'undefined' ? activeData : []); const subset = data.filter(item => _quickFilterMatches(item, chip)); const facets = {}; - for (const type of _qfFacetableTypes) { + const facetTypes = _qfTypes(_qfFacetableTypes); + for (const type of facetTypes) { if (type === chip.type) continue; facets[type] = new Set(); } for (const item of subset) { - for (const type of _qfFacetableTypes) { + for (const type of facetTypes) { if (type === chip.type) continue; const val = _qfExtractItemValue(item, type); if (val === null || val === undefined) continue; @@ -2328,8 +2429,8 @@ function _openQuickFilterTypeMenu(btnEl) { return; } - for (const type of _qfAllTypes) { - const label = _qfTypeLabels[type] || type; + for (const type of _qfTypes(_qfAllTypes)) { + const label = _qfLabel(type); const div = document.createElement('div'); div.style.cssText = 'padding:7px 14px; cursor:pointer; font-size:12px; color:#ccc; white-space:nowrap;'; div.textContent = label; @@ -2357,7 +2458,7 @@ function _openQuickFilterValueMenu(type) { vm.innerHTML = ''; const values = _qfUniqueValuesForType(type); - const label = _qfTypeLabels[type] || type; + const label = _qfLabel(type); if (values.length === 0) { vm.innerHTML = `
No values for ${label}
`; @@ -2451,7 +2552,7 @@ function renderQuickFilters() { } quickFilters.forEach((chip, idx) => { - const label = _qfTypeLabels[chip.type] || chip.type; + const label = _qfLabel(chip.type); const chipEl = document.createElement('div'); chipEl.style.cssText = 'border:1px solid #1e4a6e; border-radius:4px; margin-bottom:6px; overflow:hidden; background:#0d1f2d;'; @@ -2501,7 +2602,7 @@ function renderQuickFilters() { body.appendChild(countEl); let anyFacets = false; - for (const type of _qfFacetableTypes) { + for (const type of _qfTypes(_qfFacetableTypes)) { if (type === chip.type) continue; const vals = facets[type]; if (!vals || vals.size === 0) continue; @@ -2514,7 +2615,7 @@ function renderQuickFilters() { const rowLabel = document.createElement('span'); rowLabel.style.cssText = 'font-size:9px; color:#555; text-transform:uppercase; letter-spacing:0.05em; margin-right:6px;'; - rowLabel.textContent = (_qfTypeLabels[type] || type) + ':'; + rowLabel.textContent = _qfLabel(type) + ':'; row.appendChild(rowLabel); for (const val of sortedVals) { diff --git a/resources/logic_utils.js b/resources/logic_utils.js index 857349a..04ffefb 100644 --- a/resources/logic_utils.js +++ b/resources/logic_utils.js @@ -1071,3 +1071,65 @@ async function runFullPassForSurvivors() { if (res && res.ok) { _uscgToast(res.message || 'Full pass queued.', true); return; } _uscgToast((res && res.message ? res.message + ' ' : '') + manual, false); } + + +// --- Workflow Sweep: download / copy the API prompt of one job (GET /config_tester/sweep/job_prompt) --- +async function _fetchWorkflowJobPrompt() { + const d = (typeof activeData !== 'undefined' ? activeData : []).find(x => x.id === window.currentModalId); + if (!d || d.wf_sweep_id === undefined || d.wf_sweep_id === null || d.wf_job_index === undefined || d.wf_job_index === null) { + _uscgToast('This item has no recorded sweep job.', false); + return null; + } + const session = document.getElementById('session-input')?.value || (typeof fullManifest !== 'undefined' && fullManifest && fullManifest.session_name) || ''; + const q = 'session_name=' + encodeURIComponent(session) + '&sweep_id=' + encodeURIComponent(d.wf_sweep_id) + '&job_index=' + encodeURIComponent(d.wf_job_index); + try { + const r = await fetch('/config_tester/sweep/job_prompt?' + q); + const data = await r.json(); + if (!r.ok) { _uscgToast(data.error || ('Could not load the job prompt (' + r.status + ')'), false); return null; } + return { session: session, job: d.wf_job_index, prompt: data.prompt }; + } catch (e) { + _uscgToast('Could not load the job prompt: ' + e, false); + return null; + } +} + +async function downloadWorkflowJobPrompt() { + const res = await _fetchWorkflowJobPrompt(); + if (!res) return; + const text = JSON.stringify(res.prompt, null, 2); + try { + const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); + const a = document.createElement('a'); + a.href = url; + a.download = (res.session || 'sweep') + '_job' + (Number(res.job) + 1) + '_api.json'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 4000); + _uscgToast('Job prompt downloaded (if nothing happened, use "Copy job prompt").', true); + } catch (e) { + _uscgToast('Download blocked: ' + e + ' (use "Copy job prompt")', false); + } +} + +async function copyWorkflowJobPrompt() { + const res = await _fetchWorkflowJobPrompt(); + if (!res) return; + const text = JSON.stringify(res.prompt, null, 2); + let ok = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); ok = true; } + } catch (e) { ok = false; } + if (!ok) { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px;top:0;'; + document.body.appendChild(ta); + ta.select(); + ok = document.execCommand('copy'); + ta.remove(); + } catch (e) { ok = false; } + } + _uscgToast(ok ? 'Job prompt copied to the clipboard.' : 'Copy failed: clipboard access is blocked here.', ok); +} diff --git a/resources/logic_wfparams.js b/resources/logic_wfparams.js new file mode 100644 index 0000000..9982efe --- /dev/null +++ b/resources/logic_wfparams.js @@ -0,0 +1,81 @@ +/** + * WORKFLOW SWEEP PARAMS - PURE LOGIC (no DOM access) + * Helpers for item.wf_params ({"KSampler.cfg": 4.0, ...}; values are numbers OR strings), used by the + * Quick Filter, the Logic Filter ("wf:" fields) and the label overlay. + * The dashboard inlines every logic_*.js into ONE