* feat(system): run thumbnail generation as an Activity job
GET /prompt_manager/images/thumbnails/plan counts what a run would do
(targets, fresh, remaining past the cap, source labels) and writes
nothing. POST /prompt_manager/jobs/thumbnails {mode, size, quality}
starts the "thumbnails" job: admission guarded, cancellable at a file
boundary, outcome groups generated / skipped / errors. size is the
longest edge (128-2048), quality the encoder, and rebuild writes fresh
previews again through the same atomic rename, so a failed file keeps
its old preview and originals are only read.
The legacy gallery stream now starts or attaches to the same job and
streams its unchanged frames. A client that goes away no longer stops
the work; the gallery's Cancel asks the server to cancel the job.
* feat(activity): per-kind job descriptors
One descriptor table (activity-kinds.mjs) decides each job kind's pill
verb, toasts, line, tiles, outcomes, openable rows and labels; the
store re-exports it. The scan and image tagging views are unchanged.
A thumbnail job counts Generated, Skipped and Errors, reads
"Generating thumbnails" in the pill and "Thumbnails finished · N
generated · M errors" when it ends.
Any other kind (the health check, a held legacy-page operation) gets
the generic descriptor with no tiles and no Results, instead of the
scan's zero tiles. A running job without a known total shows an
indeterminate progress bar and no percentage, and only a job the
server can cancel shows Cancel.
* feat(system): review and start thumbnail jobs
Sources' Thumbnail generation -> Review options and Overview's Review
thumbnails open the concept's Thumbnail maintenance dialog: scope, quality
(preselected from Image loading quality) and preview size limit, a plan
line from the read-only thumbnail plan (D99) and Start, which starts the
thumbnails Activity job and opens it. A size outside 128-2048 requests
nothing; a refused start shows the busy link toast.
* fix(system): thumbnail job review fixes (#249 S5)
- The legacy stream's first frame is job {id, started}, sent after the
subscription so no runner frame is missed; it reads the registry after
prepare, so a job started in that gap is attached to, not refused.
- Every exit sets a truthful final status line; nothing to do never
reads "Processing file 0 of 0".
- Cancel is checked per file during the planning walk; a job cancelled
there publishes no start frame.
- The ffmpeg fallback keeps the chosen quality (-q:v) and the aspect
ratio; the placeholder uses the chosen JPEG settings.
- A locked preview on Windows reads "Preview in use by another program;
the old one was kept", not the raw WinError.
- Thumbnail work no longer defers the monitor's Needs review recording:
it writes previews only, never records.
- The disconnect test now proves the job outlives its stream (one worker,
9 files, three batches); the attach test waits on an event.
* fix(gallery): cancel the stream's own thumbnail job by id
The settings panel and the startup dialog cancel the job named by the
stream's job frame, and only one their stream started. A Cancel before
the frame waits for it, a double Cancel posts once, and a job another
tab or System started keeps running. The startup dialog now settles on
Cancel instead of staying on "Cancelling...".
* fix(activity): indeterminate pill bar and the nothing-to-do line
The pill's mini bar sweeps while a job's total is unknown instead of
showing an empty fill. A finished thumbnail job with nothing to do shows
the server's final line. Coverage floors raised for gallery.js and
activity.mjs.
394 lines
20 KiB
JavaScript
394 lines
20 KiB
JavaScript
/**
|
||
* System › Sources & scans (#249 System S4 Task 11): the concept's `renderSystem` "sources" body (app.js 679–681).
|
||
*
|
||
* - The optional `.notice` "Bring older images into your prompt history" shows until this browser dismissed the 3.2.0
|
||
* update notice (`pm_update_notice_dismissed`, the legacy admin page's own key). Later dismisses it without scanning;
|
||
* Review scan dismisses it, as the legacy page's Scan does, and reviews every source.
|
||
* - "Image sources" lists the server's sources (GET /settings, S4 Task 9) in `#source-list`, one `.operation` each:
|
||
* its label, "<path> · watching | watching until restart | manual scan | restart pending" and "Scan changes"
|
||
* (watched) or "Scan folder", which opens the shared "Review metadata scan" (D55) for that one source ("1 output
|
||
* folder") and starts the session job over it (`roots` with its public path, S4 Task 10). The job opens in Activity;
|
||
* the toast only says it started. When the sources changed since they were read, nothing starts: the toast says so
|
||
* and the list is read again.
|
||
* - "+ Add source" asks the server whether a folder can be a source (`validate-source`, which writes nothing), then adds
|
||
* it to the host draft of `gallery_root_paths`; Remove takes one out. Both wait for the one Save row.
|
||
* - "Watch for new outputs" (`monitoring_enabled`) and "Worker threads" (`worker_threads`, a range up to the server's
|
||
* `cpu_count`, D113) are host drafts too. "Enrich existing metadata" reviews a scan of every source.
|
||
* - "Thumbnail generation → Review options" opens "Thumbnail maintenance" (thumbnail-review.mjs, S5 Task 14), which
|
||
* starts the thumbnail Activity job.
|
||
*
|
||
* D98: per-source Remove; an added source reads "not saved yet" until Save; the line under the list says when
|
||
* ComfyUI's output folder is used instead; "restart pending" for a source the running monitor does not watch yet; the
|
||
* "Refresh files" operation (`/gallery/rescan`) closes the surface. D127: the add dialog's field is "Folder path",
|
||
* starts empty and says the server's reason in place; its action is "Add", and Enter adds too.
|
||
* D128: "watching until restart" for a source the running monitor still watches after monitoring was switched off;
|
||
* "same folder as another source" for a configured source the effective list already holds under another spelling; a
|
||
* save refused for one source (`invalid-source`) marks that row and the status says the server's reason.
|
||
*/
|
||
import { createButton } from '../shared/toolkit/core.js';
|
||
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
|
||
import { openMetadataScan } from './activity-scan.mjs';
|
||
import { openThumbnailReview } from './thumbnail-review.mjs';
|
||
import { settingRow, operationRow } from './system-controls.mjs';
|
||
import { HOST_UNAVAILABLE } from './system-appearance.mjs';
|
||
import { showWorkspaceToast } from './toast.mjs';
|
||
import { noun } from './plural.mjs';
|
||
|
||
const SETTINGS_URL = '/prompt_manager/settings';
|
||
const VALIDATE_URL = '/prompt_manager/settings/validate-source';
|
||
const RESCAN_URL = '/prompt_manager/gallery/rescan';
|
||
const ROOTS_KEY = 'gallery_root_paths';
|
||
|
||
export const NOTICE_KEY = 'pm_update_notice_dismissed';
|
||
export const NOTICE_VERSION = '3.2.0';
|
||
export const WATCH_COPY = Object.freeze({ watching: 'watching', stopping: 'watching until restart', manual: 'manual scan',
|
||
restart_pending: 'restart pending' });
|
||
// The states whose observer runs now: their scan only has to catch up on changes.
|
||
const WATCHED = new Set(['watching', 'stopping']);
|
||
const NOT_SAVED = 'not saved yet';
|
||
export const DUPLICATE_SOURCE = 'same folder as another source';
|
||
/** D98: the line under the source list when it does not say everything by itself. */
|
||
export const NOTES = Object.freeze({
|
||
removedAll: 'No source is configured. After saving, ComfyUI’s output folder is used.',
|
||
autoDetected: 'No source is configured; ComfyUI’s output folder is used.',
|
||
fallback: 'No configured source is available; ComfyUI’s output folder is used.',
|
||
none: 'No source is available.',
|
||
});
|
||
export const LOADING_TEXT = 'Loading sources…';
|
||
export const LOAD_FAILED_TEXT = 'Sources could not be loaded. Your saved data has not changed.';
|
||
export const DUPLICATE_TEXT = 'This folder is already a source.';
|
||
export const VALIDATE_FAILED_TEXT = 'The folder could not be checked. Try again.';
|
||
export const REFRESH_FAILED_TEXT = 'Files could not be refreshed. Try again.';
|
||
|
||
// Labels of the sources added in this page session (validate-source answers them), for the rows not saved yet.
|
||
const addedLabels = new Map();
|
||
|
||
const defaultStorage = () => window.localStorage;
|
||
|
||
/** Whether this browser dismissed the 3.2.0 notice; storage that cannot be read shows it. */
|
||
export function noticeDismissed(getStorage = defaultStorage) {
|
||
try {
|
||
return getStorage().getItem(NOTICE_KEY) === NOTICE_VERSION;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Dismiss the notice for this browser; false when storage refused (it stays dismissed for this view). */
|
||
export function dismissNotice(getStorage = defaultStorage) {
|
||
try {
|
||
getStorage().setItem(NOTICE_KEY, NOTICE_VERSION);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const sameRoots = (a, b) => Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((path, index) => path === b[index]);
|
||
|
||
/** The roots to draft: the saved list itself when they are equal, so the draft is clean again. */
|
||
export function draftRoots(next, saved) {
|
||
return sameRoots(next, saved) ? saved : [...next];
|
||
}
|
||
|
||
const folderName = path => path.split('/').filter(Boolean).at(-1) ?? path;
|
||
|
||
function effectiveRow(source, remove) {
|
||
const watched = WATCHED.has(source.watch);
|
||
return { key: remove ?? `auto:${source.root_index}`, title: source.label,
|
||
description: `${source.path} · ${WATCH_COPY[source.watch] ?? WATCH_COPY.manual}`,
|
||
scan: { label: watched ? 'Scan changes' : 'Scan folder', root: source.root_index, path: source.path }, remove };
|
||
}
|
||
|
||
/**
|
||
* The source list for `sources` (GET /settings' `sources`), the saved `gallery_root_paths` and the drafted ones
|
||
* (`current`): `{rows: [{key, title, description, scan: {label, root, path} | null, remove: path | null}], note}`.
|
||
* A valid configured source missing from the effective list is another spelling of a folder already listed.
|
||
*/
|
||
export function sourceRows({ sources, saved, current, labels = addedLabels }) {
|
||
const configured = sources.configured ?? [];
|
||
const effective = sources.effective ?? [];
|
||
const savedRoots = saved ?? configured.map(source => source.path);
|
||
const roots = current ?? savedRoots;
|
||
const dirty = !sameRoots(roots, savedRoots);
|
||
const rows = [];
|
||
for (const path of new Set(roots)) {
|
||
const entry = configured.find(source => source.path === path);
|
||
const live = entry?.valid ? effective.find(source => source.path === path) : undefined;
|
||
if (live) rows.push(effectiveRow(live, path));
|
||
else if (entry) {
|
||
const why = entry.valid ? DUPLICATE_SOURCE : entry.reason ?? WATCH_COPY.manual;
|
||
rows.push({ key: path, title: entry.label, description: `${path} · ${why}`, scan: null, remove: path });
|
||
} else rows.push({ key: path, title: labels.get(path) ?? folderName(path), description: `${path} · ${NOT_SAVED}`, scan: null, remove: path });
|
||
}
|
||
if (dirty) return { rows, note: roots.length ? null : NOTES.removedAll };
|
||
if (sources.auto_detected) {
|
||
rows.push(...effective.map(source => effectiveRow(source, null)));
|
||
return { rows, note: configured.length ? NOTES.fallback : NOTES.autoDetected };
|
||
}
|
||
return { rows, note: rows.length ? null : NOTES.none };
|
||
}
|
||
|
||
/** The Refresh files toast for the rescan's `{total, roots}`. */
|
||
export function refreshedText(result) {
|
||
const { total, roots } = result ?? {};
|
||
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Files refreshed.';
|
||
return `Files refreshed. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
|
||
}
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
/** The concept's `.notice` with Later and Review scan. */
|
||
function noticeNode({ scope, onLater, onReview }) {
|
||
const notice = el('div', undefined, 'notice');
|
||
const text = el('div');
|
||
text.append(el('h2', 'Bring older images into your prompt history'),
|
||
el('p', 'The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.'));
|
||
const actions = el('div', undefined, 'row-actions');
|
||
const later = createButton('Later');
|
||
const review = createButton('Review scan', 'ca-primary');
|
||
scope.listen(later, 'click', onLater);
|
||
scope.listen(review, 'click', onReview);
|
||
actions.append(later, review);
|
||
notice.append(text, actions);
|
||
return notice;
|
||
}
|
||
|
||
/**
|
||
* "Add image source" (the concept's `simpleInput`, D127): the server checks the folder; a refused one keeps the
|
||
* dialog open with the server's reason under the field. Enter in the field adds, as the Add button does.
|
||
*/
|
||
function openAddSource({ api, scope, trigger, listed, onAdd }) {
|
||
const input = el('input');
|
||
const reason = el('p', undefined, 'small');
|
||
reason.setAttribute('role', 'alert');
|
||
const cancel = createButton('Cancel');
|
||
const add = createButton('Add', 'ca-primary');
|
||
let open = true;
|
||
const refuse = text => {
|
||
reason.textContent = text;
|
||
if (!reason.isConnected) actions.before(reason);
|
||
input.setAttribute('aria-invalid', 'true');
|
||
input.focus();
|
||
};
|
||
scope.listen(input, 'input', () => {
|
||
reason.remove();
|
||
input.removeAttribute('aria-invalid');
|
||
});
|
||
scope.listen(input, 'keydown', event => {
|
||
if (event.key !== 'Enter' || event.isComposing) return;
|
||
event.preventDefault();
|
||
submit();
|
||
});
|
||
scope.listen(cancel, 'click', () => closeWorkspaceDialog());
|
||
scope.listen(add, 'click', () => submit());
|
||
async function submit() {
|
||
const path = input.value.trim();
|
||
if (!path) { input.focus(); return; }
|
||
if (add.getAttribute('aria-disabled') === 'true') return;
|
||
add.setAttribute('aria-disabled', 'true');
|
||
try {
|
||
const result = await api.post(VALIDATE_URL, { path }, { signal: scope.signal });
|
||
if (!open) return;
|
||
if (!result.valid) refuse(result.reason ?? VALIDATE_FAILED_TEXT);
|
||
else if (listed().includes(result.path)) refuse(DUPLICATE_TEXT);
|
||
else {
|
||
closeWorkspaceDialog();
|
||
onAdd(result);
|
||
}
|
||
} catch {
|
||
if (open) refuse(VALIDATE_FAILED_TEXT);
|
||
} finally {
|
||
add.removeAttribute('aria-disabled');
|
||
}
|
||
}
|
||
const actions = el('div', undefined, 'dialog-actions');
|
||
actions.append(cancel, add);
|
||
openContentDialog({ scope, title: 'Add image source', trigger, nodes: [field('Folder path', input, 'action-input'), actions],
|
||
onClose: () => { open = false; } });
|
||
input.focus();
|
||
}
|
||
|
||
/** The row's control (`data-setting`), wherever the Toolkit switch nests it. */
|
||
const controlOf = (row, id) => row.querySelector(`[data-setting="${id}"]`);
|
||
|
||
/**
|
||
* Mount Sources in `container` (System's `.settings-layout`); returns the section's nodes. `activity` is the page's
|
||
* Activity store (scans start through it), `drafts` the System drafts.
|
||
*/
|
||
export function mountSources(container, { api, activity, scope, drafts, notify = showWorkspaceToast, storage = defaultStorage,
|
||
latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
|
||
const statusRead = latestOnly({ lifetime: scope.signal });
|
||
// undefined while read, null when the read failed, else GET /settings' `sources`.
|
||
let sources;
|
||
const nodes = [];
|
||
const scanAll = trigger => openMetadataScan({ scope, store: activity, trigger, sources: sources?.effective?.length ?? 0 });
|
||
|
||
if (!noticeDismissed(storage)) {
|
||
const notice = noticeNode({ scope,
|
||
onLater: () => { dismissNotice(storage); notice.remove(); },
|
||
onReview: () => {
|
||
dismissNotice(storage);
|
||
// The pressed button leaves with the notice, so focus returns to the list's Add source.
|
||
notice.remove();
|
||
scanAll(addButton);
|
||
} });
|
||
nodes.push(notice);
|
||
}
|
||
|
||
const surface = el('section', undefined, 'surface');
|
||
const heading = el('div', undefined, 'panel-heading');
|
||
const addButton = createButton('+ Add source');
|
||
heading.append(el('h2', 'Image sources'), addButton);
|
||
const list = el('div');
|
||
list.id = 'source-list';
|
||
surface.append(heading, el('p', 'The same folders power browsing, monitoring and metadata enrichment.'), list);
|
||
|
||
const roots = () => drafts.get('host', ROOTS_KEY) ?? sources?.configured?.map(source => source.path) ?? [];
|
||
const setRoots = next => drafts.set('host', ROOTS_KEY, draftRoots(next, drafts.saved('host')[ROOTS_KEY]));
|
||
scope.listen(addButton, 'click', () => openAddSource({ api, scope, trigger: addButton, listed: roots,
|
||
onAdd: ({ path, label }) => {
|
||
addedLabels.set(path, label);
|
||
setRoots([...roots(), path]);
|
||
} }));
|
||
|
||
const monitoring = settingRow({ id: 'monitoring', label: 'Watch for new outputs',
|
||
description: 'New files enter the same library. Existing scans do not count as new runs.', control: { type: 'toggle' },
|
||
scope, onChange: value => drafts.set('host', 'monitoring_enabled', value) });
|
||
const workers = settingRow({ id: 'workers', label: 'Worker threads',
|
||
description: 'Balance background scans and thumbnail work against generation.', control: { type: 'range', value: 4 },
|
||
scope, onChange: value => drafts.set('host', 'worker_threads', value) });
|
||
const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [workers, 'workers', 'worker_threads']]
|
||
.map(([row, id, key]) => ({ input: controlOf(row, id), key, text: row.children[0], note: null }));
|
||
for (const row of hostRows) row.input.dataset.key = row.key;
|
||
surface.append(monitoring, workers,
|
||
operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.',
|
||
label: 'Review scan', onClick: scanAll }),
|
||
operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.',
|
||
label: 'Review options', onClick: trigger => openThumbnailReview({ api, store: activity, scope, trigger }) }),
|
||
// D98: the legacy gallery's Refresh, which rereads every source's file list.
|
||
operationRow({ scope, title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
|
||
label: 'Refresh', onClick: refresh }));
|
||
nodes.push(surface);
|
||
|
||
async function refresh(button) {
|
||
if (button.getAttribute('aria-disabled') === 'true') return;
|
||
button.setAttribute('aria-disabled', 'true');
|
||
try {
|
||
const result = await api.post(RESCAN_URL, {}, { signal: scope.signal });
|
||
if (!scope.signal.aborted) notify(refreshedText(result));
|
||
} catch {
|
||
if (!scope.signal.aborted) notify(REFRESH_FAILED_TEXT);
|
||
} finally {
|
||
button.removeAttribute('aria-disabled');
|
||
}
|
||
}
|
||
|
||
function sourceNode(row) {
|
||
const actions = [];
|
||
if (row.scan) {
|
||
const scan = createButton(row.scan.label);
|
||
scope.listen(scan, 'click', () => openMetadataScan({ scope, store: activity, trigger: scan, sources: 1,
|
||
roots: [row.scan.root], paths: [row.scan.path], onStale: reread }));
|
||
actions.push(scan);
|
||
}
|
||
if (row.remove !== null) {
|
||
// D98: a plain button; nothing is removed until Save.
|
||
const remove = createButton('Remove');
|
||
scope.listen(remove, 'click', () => setRoots(roots().filter(path => path !== row.remove)));
|
||
actions.push(remove);
|
||
}
|
||
const node = el('div', undefined, 'operation');
|
||
// D128: the source the last save was refused for; the status says why.
|
||
if (row.remove !== null && row.remove === drafts.invalidSource?.()) node.setAttribute('aria-invalid', 'true');
|
||
const text = el('div');
|
||
text.append(el('h3', row.title), el('p', row.description));
|
||
const group = el('div', undefined, 'row-actions');
|
||
group.append(...actions);
|
||
node.append(text, group);
|
||
return node;
|
||
}
|
||
|
||
function paintList() {
|
||
if (sources === undefined) {
|
||
const loading = el('p', LOADING_TEXT, 'small');
|
||
loading.setAttribute('role', 'status');
|
||
list.replaceChildren(loading);
|
||
return;
|
||
}
|
||
if (sources === null) {
|
||
const alert = el('p', LOAD_FAILED_TEXT);
|
||
alert.setAttribute('role', 'alert');
|
||
const retry = createButton('Retry');
|
||
scope.listen(retry, 'click', () => {
|
||
load();
|
||
if (drafts.hostState?.() === 'failed') drafts.reloadHost();
|
||
});
|
||
list.replaceChildren(alert, retry);
|
||
return;
|
||
}
|
||
const { rows, note } = sourceRows({ sources, saved: drafts.saved('host')[ROOTS_KEY], current: drafts.get('host', ROOTS_KEY) });
|
||
list.replaceChildren(...rows.map(sourceNode), ...(note ? [el('p', note, 'small')] : []));
|
||
}
|
||
|
||
function paintControls() {
|
||
const unavailable = drafts.hostState?.() === 'failed';
|
||
for (const row of hostRows) {
|
||
const value = drafts.get('host', row.key);
|
||
row.input.disabled = value === undefined;
|
||
if (row.key === 'worker_threads') {
|
||
// D113: the range ends at the server's core count (the concept's fixed 16).
|
||
const cpus = drafts.get('host', 'cpu_count');
|
||
if (Number.isSafeInteger(cpus) && cpus >= 1) row.input.max = String(cpus);
|
||
if (value !== undefined) row.input.value = String(value);
|
||
} else if (value !== undefined) row.input.checked = value === true;
|
||
if (unavailable && value === undefined) {
|
||
if (!row.note) { row.note = el('p', HOST_UNAVAILABLE, 'small'); row.text.append(row.note); }
|
||
} else if (row.note) { row.note.remove(); row.note = null; }
|
||
}
|
||
}
|
||
|
||
// The sources changed since this page read them (a refused per-source scan): read them and the saved values again.
|
||
function reread() {
|
||
load();
|
||
drafts.reloadHost?.();
|
||
}
|
||
|
||
async function load() {
|
||
sources = undefined;
|
||
paintList();
|
||
let answer;
|
||
try {
|
||
answer = await statusRead(signal => api.get(SETTINGS_URL, { signal, cache: 'no-store' }));
|
||
} catch {
|
||
answer = null;
|
||
}
|
||
if (answer === STALE || scope.signal.aborted) return;
|
||
sources = answer?.sources ?? null;
|
||
paintList();
|
||
}
|
||
|
||
// A save (or a reread after a conflict) changed the saved sources or monitoring: read their state again. The first
|
||
// host read on mount is not a change.
|
||
const savedState = () => {
|
||
const saved = drafts.saved('host');
|
||
return saved[ROOTS_KEY] === undefined ? undefined : JSON.stringify([saved[ROOTS_KEY], saved.monitoring_enabled]);
|
||
};
|
||
let state = savedState();
|
||
scope.cleanup(drafts.subscribe(() => {
|
||
const previous = state;
|
||
state = savedState();
|
||
paintControls();
|
||
if (previous !== undefined && state !== previous) load();
|
||
else if (sources) paintList();
|
||
}));
|
||
paintControls();
|
||
load();
|
||
return nodes;
|
||
}
|