Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/media-viewer.mjs
Vito d6eba05d21 fix(inspect): show the prompts and settings the legacy parser finds (#251)
* fix(inspect): show the prompts and settings the legacy parser finds

The workspace inspector took its prompts from the new server resolver,
which refused workflows where several samplers share one prompt (base
pass, upscale pass, FaceDetailer), and showed "Multiple possible
prompts" for files the legacy gallery and admin pages read correctly.

The inspector now runs the same extractGenerationParams those pages
use and shows its positive, negative, seed, steps, CFG, sampler and
model whenever it finds them. Prompts recorded at execution or saved
in the Library are never replaced; when the legacy parser finds
nothing, the server result stays.

Read-only check over a 38,602-file output folder: prompts shown went
from 1,798 to 7,542 files, and every one of the 7,236 positives and
9,421 negatives the legacy parser finds is shown identically.

* fix(inspect): keep definite server prompts over the legacy parse

The legacy parser follows ConditioningZeroOut back to the positive text
node and shows the positive as the negative. Apply its result only to
roles and settings the server could not decide, so a definite answer
such as an explicitly empty negative is never replaced.

Node callers (the corpus facts tool and the parity test support) now
pass the parser explicitly instead of relying on a browser global.

On a 38,602-file output folder, prompts shown stay at 7,542 files. Where
the server and the legacy parser both answer, they differ only in
surrounding whitespace (793 roles), except 227 zeroed negatives the
legacy parser misread and one prompt that is a JSON block.

* fix(inspect): never fill a setting the file does not record

The legacy parser can take a FaceDetailer as the generation sampler and
report its CFG for a workflow whose guider records none. Fill a setting
from the legacy parse only when the server left it unresolved or
ambiguous; a known value or a definite "not recorded" stays.

The corpus and parity Node tools again measure extractGenerationFacts
on its own, the contract their fixture vectors describe.

* fix(viewer): destroy an inline Viewer only after its pending view settles

Viewer.js's inline destroy() only unbinds and leaves an in-flight image
view running; when it finishes it reads the destroyed item list and
throws "c is undefined". Closing the preview while an image was still
loading hit this in Firefox about once in 15 runs of the recorded-run
history test. A closed viewer is now destroyed once that view settles,
its image fails, or a 10 second bound passes.

The history test passed 60 of 60 runs in Firefox with the fix.

* fix(viewer): keep the image src until a closed Viewer is released

Viewer.js rebuilds its item list from images that still have a src. On
close, the src was removed before the viewer cleanup ran, so a pending
view found no items and threw "c is undefined" (WebKit and Firefox).
With Viewer.js, the src is now removed after the viewer is destroyed,
once its pending view settles.

The recorded-run history test passed 40 of 40 runs in WebKit and in
Firefox.
2026-10-19 20:05:55 -07:00

232 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {createScope, createButton} from '../shared/toolkit/core.js';
import {createViewerSession} from './viewer-state.mjs';
import {rootedFileAdapter} from './inspection-state.mjs';
import {mountFileInspector} from './file-inspector.mjs';
let viewerId = 0;
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
const PENDING_VIEW_DESTROY_MS = 10000;
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
let stored;
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
const {videoAutoplay,videoMute,videoLoop} = normalize(stored);
return {videoAutoplay,videoMute,videoLoop};
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
const scope = createScope();
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
const stopSlideshow = () => active?.stop();
if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose,stopSlideshow}; }
parentScope.signal.addEventListener('abort',dispose,{once:true});
scope.cleanup(() => parentScope.signal.removeEventListener('abort',dispose));
scope.cleanup(() => active?.close(false));
function open(options) {
if (scope.signal.aborted) return;
active?.close(false);
const {items,selectedKey,trigger = document.activeElement} = options;
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
let originalDownload = null, inspectionState = inspectionController.state;
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
if (!mounted) { initial = [state,selected]; return; }
update(state,selected);
}});
const local = createScope();
const dialog = element('dialog','image-preview');
dialog.setAttribute('aria-label','Image preview');
const header = element('header','image-preview-header');
const heading = element('div'), title = element('h2');
const position = element('p','image-preview-position muted');
position.setAttribute('role','status');
heading.append(title,position);
const close = createButton('×');close.setAttribute('aria-label','Close preview');close.autofocus = true;
header.append(heading,close);
const body = element('div','image-preview-body'), left = element('div','image-preview-left');
const stage = element('section','image-preview-stage');stage.tabIndex = 0;stage.setAttribute('aria-label','Media preview');
const media = element('div','image-preview-media'), failure = element('div','image-preview-failure');
const error = element('p',undefined,'Media could not be loaded. Retry this file.');
const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(media,failure);
const toolbar = element('div','image-preview-toolbar'), controls = element('div','image-preview-controls');
controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls');
const reason = element('p','image-preview-reason muted');reason.id = `media-viewer-reason-${++viewerId}`;
function button(label,text,action) {
const node = createButton(text);node.setAttribute('aria-label',label);
local.listen(node,'click',() => { if (node.getAttribute('aria-disabled') !== 'true') action(); });
controls.append(node);return node;
}
const previous = button('Previous media','← Previous',() => session.navigate(-1));
const transforms = [button('Zoom out','−',() => viewer.zoom(-0.2)),button('Zoom in','+',() => viewer.zoom(0.2)),
button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)),
button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))];
const slideshow = button('Play slideshow','Play',() => session.state.requested ? session.stop() : session.play());
const slideshowReason = element('p','image-preview-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
const download = element('a','image-preview-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
controls.append(download);
const next = button('Next media','Next →',() => session.navigate(1));
toolbar.append(controls,reason,slideshowReason);left.append(stage,toolbar);
const details = element('aside','image-preview-details');details.setAttribute('aria-label','Preview prompt details');
body.append(left,details);dialog.append(header,body);container.append(dialog);
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
function closeViewer(restore = true) {
if (closed) return;
closed = true;active = null;local.dispose();
if (restore && !scope.signal.aborted) {
if (trigger?.isConnected) trigger.focus({preventScroll:true});
else onRestoreFocus?.(entry);
}
if (!scope.signal.aborted) onClose?.();
}
active = {close:closeViewer,stop:() => session.stop()};
local.cleanup(() => {
session.dispose();mediaScope?.dispose();inspector.dispose();
if (dialog.open) dialog.close();dialog.remove();
});
local.listen(close,'click',() => closeViewer());local.listen(dialog,'close',() => closeViewer());
// Finish focus restoration before a later key event can select another control.
local.listen(dialog,'cancel',event => {event.preventDefault();closeViewer();});
local.listen(document,'visibilitychange',() => {if (document.hidden) session.stop();});
local.listen(window,'pagehide',() => closeViewer(false));
local.listen(retry,'click',() => {if (!inspectionState.refreshRequired) rebuild(entry);});
local.listen(dialog,'keydown',event => {
if (!['ArrowLeft','ArrowRight'].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
(!stage.contains(event.target) && !controls.contains(event.target)) ||
event.target.closest('video,input,textarea,select,pre,summary,[contenteditable]') ||
!window.getSelection()?.isCollapsed || [...document.querySelectorAll('dialog[open]')].at(-1) !== dialog) return;
event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1);
});
function rebuild(selected) {
mediaScope?.dispose();mediaScope = createScope();viewer = null;media.replaceChildren();
failure.hidden = true;
const mediaLocal = mediaScope, key = session.state.key;
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
let original;
try { original = adapter.originalDownload(selected); }
catch { session.failed(key);return; }
const url = new URL(original.url,window.location.href);
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
const node = element(selected.is_video ? 'video' : 'img');
mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);});
if (selected.is_video) {
node.controls = true;node.playsInline = true;node.preload = 'metadata';
const preferences = readViewerPreferences();
node.muted = preferences.videoMute;node.loop = preferences.videoLoop;
mediaLocal.listen(node,'loadedmetadata',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => {node.pause();node.removeAttribute('src');node.load();});
if (preferences.videoAutoplay) mediaLocal.listen(node,'loadedmetadata',() => {
if (current()) node.play().catch(() => {});
},{once:true});
} else {
node.alt = selected.filename;node.decoding = 'async';
node.hidden = !!window.Viewer;
// With Viewer.js the src is released after the viewer (see release below).
if (!window.Viewer) {
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
}
node.src = url.href;media.append(node);
if (!selected.is_video && window.Viewer) {
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
const release = () => {
if (released) return;
released = true;clearTimeout(fallback);instance.destroy();node.removeAttribute('src');
};
const scheduleFit = () => {
if (!current() || fitFrame) return;
fitFrame = requestAnimationFrame(() => {
fitFrame = 0;
if (!current() || !instance.viewed) return;
const width = media.offsetWidth, height = media.offsetHeight;
if (width === fitWidth && height === fitHeight) return;
fitWidth = width;fitHeight = height;instance.resize();
});
};
const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false,
keyboard:false,fullscreen:false,transition:false,viewed() {
viewSettled = true;
if (mediaLocal.signal.aborted) { release(); return; }
if (!current()) return;
session.ready(key);scheduleFit();
}});
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
viewer = instance;mediaLocal.cleanup(() => {
observer.disconnect();cancelAnimationFrame(fitFrame);
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
// rebuilds its list from images that still have a src; keep both until the view
// settles, fails or times out.
if (viewSettled) { release(); return; }
node.addEventListener('error',release,{once:true});
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
});
}
updateControls(session.state);
}
function updateControls(state) {
const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ?
'Image transforms are unavailable for video.' : !viewer ?
'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : '';
reason.textContent = unavailable;reason.hidden = !unavailable;
for (const node of transforms) {
node.setAttribute('aria-disabled',String(!!unavailable));
if (unavailable) node.setAttribute('aria-describedby',reason.id);else node.removeAttribute('aria-describedby');
}
for (const node of [previous,next]) {
node.setAttribute('aria-disabled',String(state.total === 1));
if (state.total === 1) {node.setAttribute('aria-describedby',position.id);}
}
const action = state.phase === 'paused-video' ? 'Stop' : state.requested ? 'Pause' : 'Play';
slideshow.textContent = action;slideshow.setAttribute('aria-label',`${action} slideshow`);
const cannotPlay = !state.requested && (inspectionState.refreshRequired || state.phase === 'error' || state.total < 2 || entry.is_video);
slideshow.setAttribute('aria-disabled',String(!!cannotPlay));
slideshowReason.textContent = !cannotPlay ? '' : inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ?
'Media could not be loaded. Retry this file.' : state.total < 2 ? 'Slideshow requires at least two loaded media entries.' : 'Start slideshow on an image.';
slideshowReason.hidden = !cannotPlay;
if (cannotPlay) slideshow.setAttribute('aria-describedby',slideshowReason.id);else slideshow.removeAttribute('aria-describedby');
const canDownload = originalDownload && !inspectionState.refreshRequired;
download.setAttribute('aria-disabled',String(!canDownload));
if (canDownload) {
download.href = originalDownload.url;download.download = originalDownload.filename;
download.removeAttribute('tabindex');download.removeAttribute('aria-describedby');
} else {
download.removeAttribute('href');download.tabIndex = 0;download.setAttribute('aria-describedby',reason.id);
}
failure.hidden = state.phase !== 'error' || inspectionState.refreshRequired;
}
function update(state,selected) {
if (closed || state.phase === 'disposed') return;
entry = selected;
const status = {'waiting-image':'Slideshow waiting for image.','playing-image':'Slideshow playing.',
'paused-video':'Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.',error:'Media could not be loaded.'}[state.phase];
const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${status ? ` — ${status}` : ''}`;
if (position.textContent !== label) position.textContent = label;
if (currentKey !== state.key) {
currentKey = state.key;title.textContent = entry.filename;
try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; }
onSelect?.(entry);
rebuild(entry);
}
updateControls(session.state);
}
position.id = `media-viewer-position-${viewerId}`;
dialog.showModal();mounted = true;update(...initial);
local.cleanup(inspectionController.subscribe(state => {
inspectionState = state;updateControls(session.state);
if (state.error) session.stop();
inspector.setState(state);
if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} · ${entry.filename}`;
}));
}
return {open,dispose,stopSlideshow};
}