Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/media-viewer.mjs
T
Vito 785b744554 feat(system): Appearance section and browser display defaults (#307)
* feat(system): share browser display defaults

The workspace reads and writes the legacy gallery's gallerySettings object
through preferences.mjs: workspaceLayout and workspaceDensity join the
shared keys, a write re-reads the stored object and writes only the changed
keys, denied storage or a full quota keeps the change for this tab and says
so, and storage events report changes from other pages. The legacy gallery
Save keeps keys it does not edit, and the viewer reads its video defaults
through the shared module.

* feat(system): Appearance defaults used by Images and the viewer

System > Appearance is the concept's Browsing & display surface on real
values: the browser rows edit this browser's display defaults through the
shared gallerySettings object, and Open from ComfyUI and the node search
auto-hide save to the server settings with their revision. A new Images
visit starts from the default layout and size (the current view and saved
views keep theirs); metadata off waits for Load metadata; cache off re-reads
the shown file; paths off hides the Path row; the viewer reads the video
defaults. D97 adds card details, collapsed viewer details and Reset display
defaults; storage refused says Saved for this tab only.

* fix(system): flag changes made while System was closed and keep embedded galleries fixed

Final review fixes for System S3 (Appearance):

- I1: the on-demand metadata deviation is D124 (TESTING.md, inspector
  markers); TESTING.md names the spec that pins it, since no concept
  comparison renders that state.
- I2: System checks the display defaults for conflicts on mount, not only
  on storage events, and a browser write refuses a draft another page
  overtook, so a draft is never saved over another tab's value without
  the conflict status first. New two-tab spec.
- I3: the layout and size defaults start the Images page only; embedded
  galleries (a prompt history's Shared gallery and dialogs) keep
  Grid/Comfortable.
- M1: host rows whose /settings read failed say "Server settings could
  not be read."
- M2: a rejected timeout shows the "Some changes were not saved" status.
- M3: the browser loads on mount and on storage events never reject
  unhandled.
- M4: the next accepted write stores the tab-only changes and clears the
  overlay.
- M5: cache off never re-reads a source-changed selection, and refresh()
  never loads a deferred one.
- M6: the System fixture refuses a config.json outside its temp root and
  restores it with os.replace.
- M7: the inspection read counts wait on a request barrier, not frames.
- M8 recorded as an open saved-views question in TESTING.md.

* fix(system): answer a failed System read only after its other reads finish

CI (py3.10 on ubuntu) crashed with a segfault inside SQLite in
test_statistics_failure_is_an_error_envelope: the statistics and overview
routes used asyncio.gather without return_exceptions, so the 500 answered
on the first failure while get_statistics still ran in the executor, and
the test tore the database down under it. Both routes now await every
read before raising the first failure (failing before, passing after:
TestFailedReadsSettleFirst).

The frontend coverage merge failed on workspace/activity.mjs branches
(86.3 < 87): no test asserted Activity's empty and failed-read states, so
their branch was only reached in passing, depending on timing. A spec now
asserts both.
2026-10-23 07:49:39 -07:00

275 lines
19 KiB
JavaScript
Raw 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';
import {displayPreferences, readPreferences} from './preferences.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;
/** The video defaults, read through the shared display defaults (preferences.mjs, #249 System S3). */
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values;
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({api,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();
// Closes an open viewer without restoring focus (page leave); the viewer stays usable.
const close = () => active?.close(false);
if (parentScope.signal.aborted) { scope.dispose(); return {open() {},close,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, withoutViewer = false, 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();
// The concept's viewer shell (index.html h84–103, app.js a889–926): `dialog#viewer-dialog` named by its title,
// `.dialog-header`, then `.viewer-layout` > `.viewer-left` (`#viewer-stage`, `#viewer-toolbar`) and
// `aside#viewer-details`. #249 D34: production keeps a focusable stage, its controls' accessible names, the
// Download link and, after Next →, the position status and the two unavailability reasons.
const dialog = element('dialog');dialog.id = 'viewer-dialog';dialog.setAttribute('aria-labelledby','viewer-title');
const header = element('div','dialog-header');
const heading = element('div'), title = element('h2');title.id = 'viewer-title';
heading.append(title,element('p','muted small','One viewer · image, prompts, generation data, workflow'));
const close = createButton('×');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true;
header.append(heading,close);
const body = element('div','viewer-layout'), left = element('div','viewer-left');
const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0;
stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview');
const failure = element('div','media-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(failure);
const controls = element('div','viewer-toolbar');controls.id = 'viewer-toolbar';
controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls');
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 download = element('a','media-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));
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle.
const collapsed = displayPreferences()?.sidebarCollapsedByDefault === true;
let detailsToggle = null;
if (collapsed) {
detailsToggle = createButton('Show details');detailsToggle.setAttribute('aria-expanded','false');
detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle);
}
const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
position.setAttribute('role','status');
const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`;
const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
controls.append(position,reason,slideshowReason);left.append(stage,controls);
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
if (detailsToggle) {
details.hidden = true;
local.listen(detailsToggle,'click',() => {
details.hidden = !details.hidden;
detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details';
detailsToggle.setAttribute('aria-expanded',String(!details.hidden));
});
}
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,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
onLoad:() => inspectionController.load?.(),
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;
for (const child of [...stage.children]) if (child !== failure) child.remove();
failure.hidden = true;
const mediaLocal = mediaScope, key = session.state.key, ViewerJs = window.Viewer;
// Transforms follow the Viewer.js this media was built with, which builds once the image has loaded.
withoutViewer = !ViewerJs;
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
// An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server
// original gets a fresh cache-buster on every build.
let original, src;
try { if (adapter.mediaUrl) src = adapter.mediaUrl(selected); else original = adapter.originalDownload(selected); }
catch { session.failed(key);return; }
if (adapter.mediaUrl) {
if (typeof src !== 'string' || !src) { session.failed(key);return; }
} else {
const url = new URL(original.url,window.location.href);
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
src = url.href;
}
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 = !!ViewerJs;
// With Viewer.js the src is released after the viewer (see release below).
if (!ViewerJs) {
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
}
node.src = src;stage.append(node);
if (!selected.is_video && ViewerJs) {
// Viewer.js is built on an image that has loaded. Its own cancel removes its image listeners only
// while `image.complete` is false, so an image left with its load event still queued would build a
// viewer into the released image after destroy() (#293). Until then no viewer exists to cancel.
let mountedViewer = false;
mediaLocal.listen(node,'load',() => { if (current()) { mountedViewer = true;mountViewer(); } },{once:true});
mediaLocal.cleanup(() => { if (!mountedViewer) node.removeAttribute('src'); });
}
function mountViewer() {
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 = stage.offsetWidth, height = stage.offsetHeight;
if (width === fitWidth && height === fitHeight) return;
fitWidth = width;fitHeight = height;instance.resize();
});
};
const instance = new ViewerJs(node,{inline:true,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);
// Before Viewer.js has built (its zero-delay build is pending), destroy() cancels that build;
// deferring it would let the build run into the image the stage has just dropped.
// Once built, 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 || !instance.ready) { 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.' : withoutViewer ?
'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);else node.removeAttribute('aria-describedby');
}
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) {
// A linked card already carries its title, so the header reads "<title> · <filename>" at once.
const known = entry.linked?.status === 'linked' && entry.linked.title;
currentKey = state.key;title.textContent = known ? `${known} · ${entry.filename}` : entry.filename;
try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; }
onSelect?.(entry);
rebuild(entry);
}
updateControls(session.state);
}
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,close,dispose,stopSlideshow};
}