* 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.
173 lines
8.3 KiB
JavaScript
173 lines
8.3 KiB
JavaScript
/**
|
||
* Thumbnail review (#249 System S5 Task 14): the concept's "Thumbnail maintenance" dialog (`thumbnail-review`, app.js
|
||
* 1433–1437), opened by Sources' "Thumbnail generation → Review options" and Overview's "Review thumbnails". It starts
|
||
* the `thumbnails` Activity job (S5 Task 12) with the chosen options.
|
||
*
|
||
* - Scope: "Missing thumbnails only" (`missing`, the default) or "Rebuild all (explicit opt-in)" (`rebuild`).
|
||
* - Quality: "Medium · optimized" / "High · original" / "Low · fastest" (`medium` / `high` / `low`), preselected from
|
||
* Appearance's Image loading quality (`gallerySettings.imageQuality`, the same three values; Q3).
|
||
* - Preview size limit: the longest edge, a whole number from 128 to 2048 (concept default 512). A size outside that
|
||
* marks the field `aria-invalid`, says so in the plan line and sends nothing; Start reports the field's validity.
|
||
* - D99: a `p.small[role=status]` line before the actions says what the server's plan counts for the chosen scope
|
||
* (`GET /images/thumbnails/plan`, read-only): "N previews to generate · M already up to date." or, for a rebuild,
|
||
* "Every preview is regenerated: N files.", plus "· R more on the next run" past the per-run cap. Each scope is read
|
||
* once per dialog (the size and quality change no count). The primary is "Start" (the concept's "Simulate thumbnail
|
||
* job"): it closes the dialog and starts the job through the Activity store, which toasts that it started; the job
|
||
* opens in Activity, where its progress lives. A start refused because another job holds the slot toasts the D57
|
||
* busy link toast and stays.
|
||
*/
|
||
import { createButton } from '../shared/toolkit/core.js';
|
||
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
|
||
import { busyToast } from './activity-scan.mjs';
|
||
import { readPreferences } from './preferences.mjs';
|
||
import { showWorkspaceToast } from './toast.mjs';
|
||
import { navigateTo } from './state.mjs';
|
||
import { noun } from './plural.mjs';
|
||
|
||
export const THUMBNAIL_PLAN_URL = '/prompt_manager/images/thumbnails/plan';
|
||
export const THUMBNAILS_KIND = 'thumbnails';
|
||
export const SCOPE_OPTIONS = Object.freeze([['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)']]);
|
||
export const QUALITY_OPTIONS = Object.freeze([['medium', 'Medium · optimized'], ['high', 'High · original'], ['low', 'Low · fastest']]);
|
||
export const SIZE_LIMIT = Object.freeze({ min: 128, max: 2048, value: 512 });
|
||
export const PLAN_LOADING = 'Counting previews…';
|
||
export const PLAN_FAILED = 'The previews could not be counted.';
|
||
export const SIZE_INVALID = `Enter a size from ${SIZE_LIMIT.min} to ${SIZE_LIMIT.max} pixels.`;
|
||
export const START_FAILED = 'Thumbnail generation could not start. Try again.';
|
||
|
||
const QUALITIES = new Set(QUALITY_OPTIONS.map(([value]) => value));
|
||
const count = value => value.toLocaleString('en-US');
|
||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||
|
||
/** The preview size for the field's text: a whole number of pixels in range, else null. */
|
||
export function previewSize(text) {
|
||
if (typeof text !== 'string' || !/^\d{1,5}$/.test(text)) return null;
|
||
const size = Number(text);
|
||
return size >= SIZE_LIMIT.min && size <= SIZE_LIMIT.max ? size : null;
|
||
}
|
||
|
||
/** Q3: Image loading quality preselects the dialog's quality; anything it does not know is Medium. */
|
||
export function preselectedQuality(imageQuality) {
|
||
return QUALITIES.has(imageQuality) ? imageQuality : 'medium';
|
||
}
|
||
|
||
/** D99: the plan line for the server's `{mode, targets, fresh, remaining}`; an answer without counts failed. */
|
||
export function planText(plan) {
|
||
if (!plan || !['missing', 'rebuild'].includes(plan.mode) || ![plan.targets, plan.fresh, plan.remaining].every(isCount)) {
|
||
return PLAN_FAILED;
|
||
}
|
||
const more = plan.remaining ? ` · ${count(plan.remaining)} more on the next run` : '';
|
||
if (plan.mode === 'rebuild') return `Every preview is regenerated: ${count(plan.targets)} ${noun(plan.targets, 'file')}${more}.`;
|
||
return `${count(plan.targets)} ${noun(plan.targets, 'preview')} to generate · ${count(plan.fresh)} already up to date${more}.`;
|
||
}
|
||
|
||
/**
|
||
* Start the thumbnail job with `options` (`{mode, size, quality}`) and open its view; a refused or failed start says
|
||
* why and stays. Nothing is said once the section that started it is gone.
|
||
*/
|
||
export async function startThumbnails({ store, scope, options, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||
try {
|
||
const result = await store.start(THUMBNAILS_KIND, options);
|
||
if (scope.signal.aborted) return;
|
||
if (result.busy !== undefined) {
|
||
notify(busyToast(result.busy));
|
||
return;
|
||
}
|
||
navigate(`#/activity/${result.job.id}`);
|
||
} catch {
|
||
if (!scope.signal.aborted) notify(START_FAILED);
|
||
}
|
||
}
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
function select(options, value) {
|
||
const node = el('select');
|
||
node.append(...options.map(([optionValue, label]) => {
|
||
const option = el('option', label);
|
||
option.value = optionValue;
|
||
return option;
|
||
}));
|
||
node.value = value;
|
||
return node;
|
||
}
|
||
|
||
const defaultQuality = () => {
|
||
try {
|
||
return readPreferences().values.imageQuality;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Open "Thumbnail maintenance" on the section's `scope`. `store` is the page's Activity store; `trigger` gets focus
|
||
* back when the dialog closes.
|
||
*/
|
||
export function openThumbnailReview({ api, store, scope, trigger = null, quality = defaultQuality(), notify = showWorkspaceToast,
|
||
navigate = navigateTo }) {
|
||
const mode = select(SCOPE_OPTIONS, 'missing');
|
||
const encoder = select(QUALITY_OPTIONS, preselectedQuality(quality));
|
||
const size = el('input');
|
||
size.type = 'number';
|
||
size.value = String(SIZE_LIMIT.value);
|
||
size.min = String(SIZE_LIMIT.min);
|
||
size.max = String(SIZE_LIMIT.max);
|
||
const line = el('p', PLAN_LOADING, 'small');
|
||
line.setAttribute('role', 'status');
|
||
const cancel = createButton('Cancel');
|
||
const start = createButton('Start', 'ca-primary');
|
||
const actions = el('div', undefined, 'dialog-actions');
|
||
actions.append(cancel, start);
|
||
|
||
// The plan per scope: a promise of its answer (null when it failed); the size and quality change no count.
|
||
const plans = new Map();
|
||
let open = true;
|
||
const chosenSize = () => previewSize(size.value);
|
||
const validate = () => {
|
||
const valid = chosenSize() !== null;
|
||
size.setCustomValidity(valid ? '' : SIZE_INVALID);
|
||
if (valid) size.removeAttribute('aria-invalid');
|
||
else size.setAttribute('aria-invalid', 'true');
|
||
return valid;
|
||
};
|
||
const readPlan = chosen => {
|
||
const query = new URLSearchParams({ mode: chosen, size: String(chosenSize()), quality: encoder.value });
|
||
return api.get(`${THUMBNAIL_PLAN_URL}?${query}`, { signal: scope.signal, cache: 'no-store' }).catch(() => null);
|
||
};
|
||
async function paint() {
|
||
if (!validate()) { line.textContent = SIZE_INVALID; return; }
|
||
const chosen = mode.value;
|
||
if (!plans.has(chosen)) plans.set(chosen, readPlan(chosen));
|
||
const pending = plans.get(chosen);
|
||
line.textContent = PLAN_LOADING;
|
||
const plan = await pending;
|
||
// A later choice painted its own line meanwhile, or the dialog closed.
|
||
if (!open || scope.signal.aborted || mode.value !== chosen || !validate()) return;
|
||
line.textContent = planText(plan);
|
||
}
|
||
|
||
scope.listen(mode, 'change', paint);
|
||
scope.listen(size, 'input', paint);
|
||
scope.listen(cancel, 'click', () => closeWorkspaceDialog());
|
||
scope.listen(start, 'click', () => {
|
||
if (!validate()) {
|
||
size.reportValidity();
|
||
size.focus();
|
||
return;
|
||
}
|
||
const options = { mode: mode.value, size: chosenSize(), quality: encoder.value };
|
||
closeWorkspaceDialog();
|
||
startThumbnails({ store, scope, options, notify, navigate });
|
||
});
|
||
openContentDialog({ scope, title: 'Thumbnail maintenance', trigger,
|
||
nodes: [field('Scope', mode, 'thumb-mode'), field('Quality', encoder, 'thumb-quality'),
|
||
field('Preview size limit', size, 'thumb-limit'), line, actions],
|
||
onClose: () => { open = false; } });
|
||
paint();
|
||
}
|