From 148922ee2fd60eca6213cc8a235039da3cf376fd Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Wed, 4 Nov 2026 00:09:26 -0800 Subject: [PATCH] feat(dashboard): preview badge, Pass filter, superseded previews, full-pass button PREVIEW badge on cards and a modal note, a Pass filter row (hidden until previews exist), previews superseded by a full image (config_fp/base_fp match) hidden by default, a preview bar with 'Full pass for survivors (N)'. The button asks web/dashboard.js (postMessage) to switch Preview pass off in the matching Config Builder and call app.queuePrompt; a marked stub covers a future run API; fallback is an instructions toast. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 + resources/logic_pipeline.js | 88 +++++++++++++++++++++++++++++-- resources/logic_state.js | 7 ++- resources/logic_ui.js | 31 +++++++++-- resources/logic_utils.js | 70 +++++++++++++++++++++++- resources/report.css | 20 +++++++ resources/template.html | 15 +++++- tests/superseded_previews.test.js | 43 +++++++++++++++ web/dashboard.js | 39 ++++++++++++++ 9 files changed, 304 insertions(+), 11 deletions(-) create mode 100644 tests/superseded_previews.test.js diff --git a/README.md b/README.md index 40b9cd0..51e5738 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ If you'd rather build the graph yourself: * **Unified Settings Panel:** Gear icon (⚙️) opens a consolidated settings panel with Session management, Grid Display controls, Export Favorites, and Scan External Directory — replacing the old separate SESSION button. * **Scan External Directory:** Load image sessions from any folder (including other ComfyUI instances) into the dashboard for viewing and filtering. Reads PNG metadata for full config display. * **Export Favorites:** Copy all favorited images to a dedicated export folder with optional CivitAI metadata packing for sharing. +* **Preview Pass support:** preview images carry an amber `PREVIEW` badge, a **Pass** filter row (Preview / Full, shown once previews exist) separates them, and a preview whose cell now has a full image is hidden by default (isolate `Preview` in the Pass row to see them). A bar under the header counts previews and offers **Full pass for survivors (N)**, which switches Preview Pass off in the matching Config Builder and queues the workflow (falls back to instructions if the ComfyUI frontend does not allow it). ### ⚡ The "Revise & Generate" Workflow * **One-Click Revision:** Click "REVISE" on any image to open a detail view. @@ -840,6 +841,7 @@ Both live in the **Run Settings** section of the Config Builder and default to t * **Bottom Right:** Generation time in seconds. * **Top Left (on hover):** Red ✕ button to reject/hide image. * **Top Right (on hover):** Gold ⭐ button to favorite image, and green "REVISE" button to open studio view. +* **Bottom Center:** amber `PREVIEW · N st` badge on preview-pass images (hover for the full-quality size/steps it stands in for). ### JSON Bars (Bottom — Horizontal Layout) * **Green Bar (Left — Accepted):** Contains optimized JSON of all currently visible images. Click to select all, then copy-paste back into the `configs_json` widget to refine your batch. diff --git a/resources/logic_pipeline.js b/resources/logic_pipeline.js index f413d08..40f0c5a 100644 --- a/resources/logic_pipeline.js +++ b/resources/logic_pipeline.js @@ -157,6 +157,7 @@ function getFilterKey() { [...filters.cfg].sort().join(','), [...filters.upscaleMethod].sort().join(','), [...filters.mediaType].sort().join(','), + [...(filters.passType || [])].sort().join(',') + '#' + supersededFps.size, searchFilters.map(f => `${f.type}:${f.term}`).join('|'), (typeof logicFilters !== 'undefined' ? logicFilters.map(f => `${f.field}${f.op}${f.value}`).join('|') : ''), (typeof quickFilters !== 'undefined' ? quickFilters.map(f => `${f.type}:${f.value}`).join('|') : '') @@ -335,6 +336,63 @@ function runMultiSort(list) { // --- 4. PIPELINE & FILTER LOGIC --- +// Preview pass support ------------------------------------------------------- +// A preview is "superseded" once a full (non-preview) item exists for the same cell fingerprint. +// Superseded previews are hidden by default; isolating the Pass filter on "Preview" (without "Full") shows them. +function isSupersededPreview(d) { + if (!d.preview || !d.preview_of || !supersededFps.has(d.preview_of)) return false; + const pf = filters.passType; + if (pf && pf.has('Preview') && !pf.has('Full')) return false; + return true; +} + +// newItems omitted: rebuild the whole set from activeData. With newItems: add only their fingerprints and +// return true when that superseded a preview that is currently displayed (caller then re-runs the pipeline). +function refreshSupersededFps(newItems) { + if (!newItems) { + const s = new Set(); + for (let i = 0; i < activeData.length; i++) { + const d = activeData[i]; + if (d.preview) continue; + if (d.config_fp) s.add(d.config_fp); + if (d.base_fp) s.add(d.base_fp); + } + supersededFps = s; + return false; + } + let grew = false; + for (let i = 0; i < newItems.length; i++) { + const d = newItems[i]; + if (d.preview) continue; + if (d.config_fp && !supersededFps.has(d.config_fp)) { supersededFps.add(d.config_fp); grew = true; } + if (d.base_fp && !supersededFps.has(d.base_fp)) { supersededFps.add(d.base_fp); grew = true; } + } + if (!grew) return false; + return processedData.some(p => p.preview && supersededFps.has(p.preview_of)); +} + +// Shows "N previews | M awaiting a full pass" + the full-pass button when the session holds previews. +function updatePreviewBar() { + const bar = document.getElementById('preview-bar'); + if (!bar) return; + let previews = 0, survivors = 0; + for (let i = 0; i < activeData.length; i++) { + const d = activeData[i]; + if (!d.preview) continue; + previews++; + if (!d.rejected && !supersededFps.has(d.preview_of)) survivors++; + } + if (previews === 0) { bar.style.display = 'none'; return; } + bar.style.display = 'flex'; + const txt = document.getElementById('preview-bar-text'); + if (txt) txt.textContent = `${previews} preview image${previews === 1 ? '' : 's'} | ${survivors} not rejected and awaiting a full pass`; + const btn = document.getElementById('full-pass-btn'); + if (btn) { + btn.textContent = `\u25B6 Full pass for survivors (${survivors})`; + btn.disabled = survivors === 0; + } +} + function incrementalFilter(newItems) { const hasButtonFilters = filters.sampler.size > 0 || filters.scheduler.size > 0 || @@ -348,13 +406,15 @@ function incrementalFilter(newItems) { filters.steps.size > 0 || filters.cfg.size > 0 || filters.upscaleMethod.size > 0 || - filters.mediaType.size > 0; + filters.mediaType.size > 0 || + (filters.passType && filters.passType.size > 0); return newItems.filter(d => { // Apply top-level filters (Favorites/Non-Favorited/Rejected visibility) if (d.favorited && !topFilters.showFavorites) return false; if (!d.favorited && !d.rejected && !topFilters.showNonFavorited) return false; if (d.rejected && !topFilters.showRejected) return false; + if (d.preview && isSupersededPreview(d)) return false; // Apply button filters if (hasButtonFilters) { @@ -395,6 +455,9 @@ function incrementalFilter(newItems) { const mt = d.media_type || 'image'; if (!filters.mediaType.has(mt)) return false; } + if (filters.passType && filters.passType.size > 0) { + if (!filters.passType.has(d.preview ? 'Preview' : 'Full')) return false; + } } // Apply search filters @@ -427,6 +490,7 @@ let pipelineAbortId = 0; function executePipeline() { const startTime = performance.now(); + refreshSupersededFps(); const currentFilterKey = getFilterKey(); const filtersChanged = currentFilterKey !== lastFilterKey; @@ -449,7 +513,8 @@ function executePipeline() { filters.steps.size > 0 || filters.cfg.size > 0 || filters.upscaleMethod.size > 0 || - filters.mediaType.size > 0; + filters.mediaType.size > 0 || + (filters.passType && filters.passType.size > 0); const hasSearchFilters = typeof matchesSearchFilters === 'function' && searchFilters.length > 0; @@ -458,6 +523,7 @@ function executePipeline() { if (d.favorited && !topFilters.showFavorites) return false; if (!d.favorited && !d.rejected && !topFilters.showNonFavorited) return false; if (d.rejected && !topFilters.showRejected) return false; + if (d.preview && isSupersededPreview(d)) return false; // Apply button filters (skip entirely if none are active) if (hasButtonFilters) { @@ -495,6 +561,9 @@ function executePipeline() { const mt = d.media_type || 'image'; if (!filters.mediaType.has(mt)) return false; } + if (filters.passType && filters.passType.size > 0) { + if (!filters.passType.has(d.preview ? 'Preview' : 'Full')) return false; + } } if (hasSearchFilters && !matchesSearchFilters(d)) return false; @@ -523,6 +592,7 @@ function executePipeline() { const elapsed = (performance.now() - startTime).toFixed(1); console.log(`[Pipeline] Processed ${processedData.length} items in ${elapsed}ms`); updateJSONs(processedData); + updatePreviewBar(); if (filtersChanged) { if(typeof renderDOM === 'function') renderDOM(); @@ -542,6 +612,15 @@ function processNewData(newItems) { // (activeData already contains the new items via fullManifest.items reference) refreshIndices(); + // A full image landing for a cell supersedes its preview: if any preview on screen just became + // superseded, re-run the whole pipeline (it rebuilds the superseded set and hides them). + if (refreshSupersededFps(newItems)) { + updateFiltersForNewData(newItems); + updateDataPipeline(); + return; + } + updatePreviewBar(); + // Recompute label global values when data changes (new items may change what's unique) // Then refresh ALL existing card labels since what's "unique" may have changed if (typeof computeLabelGlobalValues === 'function' && labelMode && labelMode.enabled) { @@ -647,7 +726,7 @@ function loadSortPreference() { function updateFiltersForNewData(newItems) { // Track which filter categories actually got new unique values let changed = false; - const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed']; + const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed', 'passType']; // Build a set of new values for each key in a single pass over newItems for (let i = 0; i < newItems.length; i++) { @@ -659,9 +738,10 @@ function updateFiltersForNewData(newItems) { else if (key === 'positive') val = d.positive || meta.positive || ""; else if (key === 'negative') val = d.negative || meta.negative || ""; else if (key === 'size') val = `${d.width}x${d.height}`; + else if (key === 'passType') val = d.preview ? 'Preview' : 'Full'; else val = d[key]; - if (val !== undefined && val !== null && !filters[key].has(val)) { + if (val !== undefined && val !== null && !(filters[key] && filters[key].has(val))) { changed = true; // Don't break - we need to continue checking all items/keys // so that initFilters gets a complete picture diff --git a/resources/logic_state.js b/resources/logic_state.js index 45ef690..0df8c6a 100644 --- a/resources/logic_state.js +++ b/resources/logic_state.js @@ -32,9 +32,14 @@ var filters = filters || { steps: new Set(), cfg: new Set(), upscaleMethod: new Set(), - mediaType: new Set() + mediaType: new Set(), + passType: new Set() // 'Preview' | 'Full' }; +// config_fp / base_fp of every non-preview item: a preview whose preview_of is in here has been superseded +// by a full render and is hidden by default (see isSupersededPreview in logic_pipeline.js). +var supersededFps = supersededFps || new Set(); + // Top-level filter toggles (true = show, false = hide) var topFilters = topFilters || { showFavorites: true, // Default ON diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 31f922c..315bed7 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -558,7 +558,7 @@ function initFilters() { if (!activeData || activeData.length === 0) return; // Ensure all filter Sets exist (add new ones if missing) - const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed', 'steps', 'cfg', 'upscaleMethod', 'mediaType']; + const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed', 'steps', 'cfg', 'upscaleMethod', 'mediaType', 'passType']; filterKeys.forEach(key => { if (!filters.hasOwnProperty(key) || !(filters[key] instanceof Set)) { filters[key] = new Set(); @@ -587,12 +587,19 @@ function initFilters() { return shortModel ? `${mode} + ${shortModel}` : mode || 'Upscaled'; } if (key === 'mediaType') return d.media_type || 'image'; + if (key === 'passType') return d.preview ? 'Preview' : 'Full'; return d[key]; }))].sort(); const container = document.getElementById('filter-' + key); if (!container) return; + // The Pass row only matters once the session holds preview images + if (key === 'passType') { + const passRow = container.closest('.filter-row'); + if (passRow) passRow.style.display = unique.length > 1 ? '' : 'none'; + } + const cacheKey = unique.join(','); if (filterButtonCache[key] === cacheKey) { return; @@ -699,7 +706,7 @@ function initFilters() { function resetAllFilters() { if (!activeData || activeData.length === 0) return; - const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed', 'steps', 'cfg', 'upscaleMethod', 'mediaType']; + const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed', 'steps', 'cfg', 'upscaleMethod', 'mediaType', 'passType']; // Re-populate every filter Set with all unique values from activeData filterKeys.forEach(key => { @@ -722,6 +729,7 @@ function resetAllFilters() { val = shortModel ? `${mode} + ${shortModel}` : mode || 'Upscaled'; } } else if (key === 'mediaType') val = d.media_type || 'image'; + else if (key === 'passType') val = d.preview ? 'Preview' : 'Full'; else val = d[key]; if (val !== undefined && val !== null) filters[key].add(val); }); @@ -1332,6 +1340,9 @@ function createCard(d) { const reviseBtn = isVideo ? '' : ``; const upscaleBtn = isVideo ? '' : ``; const videoBadge = isVideo ? '
\u25B6 VIDEO
' : ''; + const previewBadge = d.preview + ? `
PREVIEW \u00B7 ${d.steps} st
` + : ''; // FIXED LAYOUT: Star top-right, Revise below it, time bottom-right, index bottom-left card.innerHTML = ` @@ -1342,6 +1353,7 @@ function createCard(d) { ${reviseBtn} ${upscaleBtn} ${videoBadge} + ${previewBadge}
${d.duration}s
#${totalIndex}
${buildLabelOverlay(d)} @@ -1386,6 +1398,15 @@ function openM(id) { const d = activeData.find(x => x.id === id); if (!d) return; document.getElementById('m-img').src = d.file; + const previewNote = document.getElementById('m-preview-note'); + if (previewNote) { + if (d.preview) { + previewNote.textContent = `Preview of ${d.full_width}x${d.full_height} @ ${d.full_steps} steps (this image: ${d.width}x${d.height} @ ${d.steps} steps)`; + previewNote.style.display = 'block'; + } else { + previewNote.style.display = 'none'; + } + } console.log(d) // Populate read-only info fields const modelEl = document.getElementById('f-model'); @@ -1999,15 +2020,16 @@ const _qfTypeLabels = { cfg: 'CFG', upscaleMethod: 'Upscale', mediaType: 'Media', + passType: 'Pass', positive: 'Positive', negative: 'Negative' }; // All types that Quick Filter supports (type-menu order) -const _qfAllTypes = ['lora', 'lora_strength', 'cfg', 'steps', 'sampler', 'scheduler', 'denoise', 'size', 'model', 'seed', 'upscaleMethod', 'mediaType', 'positive', 'negative']; +const _qfAllTypes = ['lora', 'lora_strength', 'cfg', 'steps', 'sampler', 'scheduler', 'denoise', 'size', 'model', 'seed', 'upscaleMethod', 'mediaType', 'passType', 'positive', 'negative']; // 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']; +const _qfFacetableTypes = ['sampler', 'scheduler', 'lora', 'lora_strength', 'model', 'denoise', 'cfg', 'steps', 'size', 'seed', 'upscaleMethod', 'mediaType', 'passType']; /** * Extract a facet value (or array of values) from an item for a given type. @@ -2046,6 +2068,7 @@ function _qfExtractItemValue(item, type) { return shortModel ? `${mode} + ${shortModel}` : mode || 'Upscaled'; } if (type === 'mediaType') return item.media_type || 'image'; + if (type === 'passType') return item.preview ? 'Preview' : 'Full'; const v = item[type]; if (v === undefined || v === null) return null; return String(v); diff --git a/resources/logic_utils.js b/resources/logic_utils.js index ef736ed..d130ebf 100644 --- a/resources/logic_utils.js +++ b/resources/logic_utils.js @@ -976,4 +976,72 @@ async function scanDirectory() { } } -setupKeyReloadFullscreen() \ No newline at end of file +setupKeyReloadFullscreen() + +// --------------------------------------------------------------------------- +// FULL PASS FOR SURVIVORS (preview-pass workflow) +// Strategy (a): ask the parent ComfyUI page (web/dashboard.js) to flip the matching Config Builder to +// "Preview pass off", then queue the prompt. Needs a parent that understands 'uscg_run_full_pass'. +// Strategy (b): STUB for a future run API (see _uscgTryRunApi). Fallback: instructions toast. +// --------------------------------------------------------------------------- +function _uscgToast(msg, ok) { + var el = document.createElement('div'); + el.textContent = msg; + el.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);max-width:80%;z-index:99999;' + + 'padding:10px 16px;border-radius:6px;font-size:12px;color:#fff;box-shadow:0 4px 16px rgba(0,0,0,0.5);' + + 'background:' + (ok ? '#1f5a2e' : '#5a3a14') + ';border:1px solid ' + (ok ? '#3c9a55' : '#a8741f') + ';'; + document.body.appendChild(el); + setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 9000); +} + +// Strategy (b) stub: wire the run API here once it exists. Detection: window.USCG_RUN_API (set by the host +// page) or a '/config_tester/run' route. Resolves to {ok, message} when it handled the request, else null. +async function _uscgTryRunApi(session) { + try { + if (window.USCG_RUN_API && typeof window.USCG_RUN_API.run === 'function') { + // TODO(merge with the run-API task): confirm the real signature; overrides below are the contract. + var res = await window.USCG_RUN_API.run({ + session_name: session, + settings_overrides: { preview_pass: { enabled: false }, skip_rejected_previews: true } + }); + return { ok: true, message: (res && res.message) || 'Full pass queued.' }; + } + var probe = await fetch('/config_tester/run', { method: 'GET' }).catch(function () { return null; }); + if (probe && probe.status !== 404) { + // TODO(merge with the run-API task): POST /config_tester/run with the same overrides, then return + // {ok, message}. Intentionally not implemented here (this task does not depend on it). + } + } catch (e) { /* fall through to the instructions toast */ } + return null; +} + +function _uscgAskParentFullPass(session) { + return new Promise(function (resolve) { + if (!window.parent || window.parent === window) { resolve(null); return; } + var done = false; + var onMsg = function (ev) { + if (!ev.data || ev.data.type !== 'uscg_run_full_pass_result') return; + done = true; + window.removeEventListener('message', onMsg); + resolve({ ok: !!ev.data.ok, message: ev.data.message || '' }); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: 'uscg_run_full_pass', session_name: session }, '*'); + setTimeout(function () { + if (done) return; + window.removeEventListener('message', onMsg); + resolve(null); // parent too old to answer + }, 4000); + }); +} + +async function runFullPassForSurvivors() { + var session = (document.getElementById('session-input') && document.getElementById('session-input').value) || + (fullManifest && fullManifest.session_name) || ''; + var manual = 'In the Config Builder open Run Settings, untick "Preview Pass" and queue the workflow again. ' + + 'Only cells whose preview was not rejected will render at full quality.'; + var res = await _uscgAskParentFullPass(session); + if (!res) res = await _uscgTryRunApi(session); + if (res && res.ok) { _uscgToast(res.message || 'Full pass queued.', true); return; } + _uscgToast((res && res.message ? res.message + ' ' : '') + manual, false); +} diff --git a/resources/report.css b/resources/report.css index f6c9720..3aa99da 100644 --- a/resources/report.css +++ b/resources/report.css @@ -593,6 +593,26 @@ body { text-shadow: 0 0 4px rgba(0,0,0,0.8); } +.preview-badge { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + background: rgba(160, 110, 20, 0.92); + color: #fff; + padding: 3px 7px; + border-radius: 4px; + font-weight: bold; + font-size: 10px; + white-space: nowrap; + pointer-events: none; + z-index: 5; + border: 1px solid rgba(255, 255, 255, 0.2); + text-shadow: 0 0 4px rgba(0,0,0,0.8); +} + +#full-pass-btn:disabled { opacity: 0.45; cursor: not-allowed; } + .card video { width: 100%; height: 100%; diff --git a/resources/template.html b/resources/template.html index d87af90..2ee88a9 100644 --- a/resources/template.html +++ b/resources/template.html @@ -449,6 +449,15 @@ + + + @@ -696,7 +709,7 @@ ← → Space navigate  |  1-9 favorite