Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/local-session.mjs
T
Vito e29ff4d4b2 feat(inspect): the Inspect page for local files (#280)
* feat(inspect): local adapter seams in the shared inspector and viewer

The inspection loader calls adapter.read when an adapter reads by itself, so a
file chosen in this browser never reaches the API. The full inspector shows the
D66 local provenance (no Path row, no Show in folder, local association line),
and the viewer uses adapter.mediaUrl exactly, so a blob: URL is never given the
_pm_viewer cache-buster and Retry media reuses it. Rooted and recorded-history
adapters keep their api.get paths and cache-busted originals.

* feat(inspect): owned local selection session

createLocalSession owns one chosen File at a time: it classifies the bytes,
creates the blob: URL only for a classified image or video, and reads it
through a real inspection controller with the local adapter. Replacement,
dispose, parent abort and pagehide each release the URL, the handle and the
record once; a released handle (and every copy) is foreign to the adapter, and
every await is fenced by a generation counter so a replaced read never
publishes. Empty, multiple and directory drops keep the previous selection.
Baseline entry for local-session.mjs; branch floors raised for the touched
inspection-state.mjs and local-inspection.mjs.

* feat(inspect): native Inspect a file page

#/inspect is now the concept's "Inspect a file" page instead of the legacy
handoff. One chosen or dropped image or video previews inline, and the one
heading action, "Open file metadata", opens the shared viewer with the full
inspector on a record read entirely in this browser. Nothing is uploaded,
imported or stored; only an explicit Send crosses the host bridge.

- inspect.mjs mounts the concept DOM (surface, file-drop, #local-preview)
  under the shell heading, owns one local session and the shared viewer and
  prompt sender, and releases media before the session revokes its URL on
  replacement, route change, page leave and dispose (D60-D65).
- A file that cannot be read after choosing opens the inspector's
  unreadable state when the browser declares a supported type, instead of
  the "unsupported" copy; the local adapter maps a browser read failure to
  an unreadable record.
- The route is native; Overview keeps its card to the legacy metadata page,
  which is unchanged.
- workspace-inspect.spec.mjs covers the chooser and viewer rail against the
  Images rail for the same bytes, drop, cancel and reselect, unsupported and
  unreadable files, video, URL ownership, mid-read replacement, exact send
  and the no-request/no-storage rule.

* test(inspect): concept parity and managed-host send for local files

Compare the native Inspect a file page with the immutable concept's
#metadata at 1600x1100 and 390x844 in both themes (initial, selected,
viewer-open), mapping D60-D66 and pinning the concept's CSS values and
the chooser's displayed file name, with a mutation check. Send a local
PNG (explicitly empty negative) and core-tags.webm to a real Prompt
Manager (+/-) node across Classic and Nodes 2.0 switches and reloads;
a connected input rejects the send unchanged. Document D60-D66, the
legacy-floor dispositions and the commands in TESTING.md.

* fix(inspect): reset a released preview video in the next task

On WebKit (GStreamer) a video replaced while its pipeline was still
starting blocked the page: pause, src removal and load() in one task
never returned, so the next chooser selection hung (CI page workflows
webkit shard 2/2, "URLs and media end with their owning session").
The src is still dropped before the URL is revoked; only the load()
reset moves to the next task. A fresh-page regression replaces the
first video as soon as it shows, fifteen times.

* fix(inspect): never reset a preview video that is still starting

Deferring load() to the next task was not enough: CI on b7db4c3 hung
WebKit one step later in the fresh-page regression. Reset with load()
only a settled video (metadata read, or failed); a starting one is
paused, loses its src before the URL is revoked and is removed, then
left to be collected. The ownership workflow waits for the video to
settle before asserting the reset.

* fix(inspect): final review round for the local Inspect page

- Each inline preview owns its listeners, so a released node is never
  pinned; a video released while still starting is reset with load()
  once it reports metadata or an error (I1).
- The viewer's video teardown on a just-started local video is guarded
  by a 15-page WebKit test; it needed no change (I2).
- The chooser keeps naming the current file after a cancelled picker
  and names the dropped file after an accepted drop (I3).
- The unrelated activity-scan.mjs floor raise is reverted (I4).
- The page owns pagehide: the viewer closes and the inline media goes
  before the session revokes the URL, and the chooser is cleared (M1,
  M2); the session gains clear() and the viewer close().
- The "metadata is available" toast shows only for readable metadata
  (M3); a rejected drop is a second, transient alert that never
  replaces the selection's status (M4).
- TESTING.md: D62/D63 updates, the WebKit loadstart follow-up and the
  pair-node follow-up (M5).
2026-10-22 16:06:07 -07:00

181 lines
9.7 KiB
JavaScript

// One local selection at a time, owned end to end: the chosen File is classified
// by its bytes, a blob: URL is created only for a classified file, the inspection
// controller reads it through localFileAdapter, and replacement, dispose, parent
// abort and page leave each release the URL, the handle and the record exactly once.
import { createInspectionController } from './inspection-state.mjs';
import { createLocalFileHandle, localFileAdapter, releaseLocalFileHandle } from './local-inspection.mjs';
const PREFIX_BYTES = 32;
// Kinds whose metadata the server never reads (AVI, ASF/WMV): preview and original only.
const CONTAINER_ONLY = new Set(['avi', 'asf']);
/** D63 status copy; the page shows `multiple` and `directory` for the matching select() outcomes. */
export const LOCAL_SESSION_MESSAGES = Object.freeze({
multiple: 'Drop one file at a time.',
directory: 'Folders cannot be inspected. Choose one image or video file.',
unsupported: 'This file type cannot be previewed or inspected here. Choose an image or video file.',
previewFailed: 'The preview could not be displayed. Metadata and the original remain available in the viewer.',
containerOnly: 'Metadata in AVI and WMV files is not read here. The preview and the original remain available.',
// A file that could not be read after choosing: the shared inspector's own unreadable copy (file-inspector.mjs).
unreadable: 'Embedded metadata could not be read.',
});
// The browser's declared type for a file whose bytes could not be read, mapped to the kind its bytes would have
// classified as. Only a supported declared type opens the unreadable record; anything else stays unsupported.
const DECLARED_KINDS = Object.freeze({
'image/png': ['image', 'png'], 'image/jpeg': ['image', 'jpeg'], 'image/webp': ['image', 'webp'],
'video/mp4': ['video', 'iso-bmff'], 'video/quicktime': ['video', 'iso-bmff'], 'video/x-m4v': ['video', 'iso-bmff'],
'video/webm': ['video', 'matroska'], 'video/x-matroska': ['video', 'matroska'],
'video/x-msvideo': ['video', 'avi'], 'video/avi': ['video', 'avi'], 'video/x-ms-wmv': ['video', 'asf'], 'video/x-ms-asf': ['video', 'asf'],
});
function declaredClassification(file) {
const kind = DECLARED_KINDS[String(file?.type || '').toLowerCase()];
return kind ? Object.freeze({ media: kind[0], kind: kind[1] }) : null;
}
const readFailure = error => error instanceof DOMException && error.name !== 'AbortError';
const abortError = () => new DOMException('The local selection was replaced', 'AbortError');
const defaultSchedulerYield = () => (typeof globalThis.scheduler?.yield === 'function' ? () => globalThis.scheduler.yield() : null);
const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata;
const EMPTY = Object.freeze({ phase: 'empty', preview: null, message: '' });
/**
* `yieldControl(signal)`: one turn of the event loop. It prefers `scheduler.yield`;
* otherwise a zero timer that, on abort, is cleared and rejects with AbortError and
* removes its abort listener. Rejects at once for an aborted signal.
*/
export function createYield({ schedulerYield = defaultSchedulerYield(), setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
return function yieldControl(signal) {
if (signal?.aborted) return Promise.reject(abortError());
if (typeof schedulerYield === 'function') {
return Promise.resolve(schedulerYield()).then(() => { if (signal?.aborted) throw abortError(); });
}
return new Promise((resolve, reject) => {
const onAbort = () => { clearTimer(timer); reject(abortError()); };
const timer = setTimer(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, 0);
signal?.addEventListener('abort', onAbort, { once: true });
});
};
}
/** The one File a chooser or drop carries, or the select() outcome that keeps the previous selection. */
function chosenFile(input) {
if (input === null || input === undefined) return { outcome: 'ignored' };
const items = input.items ? [...input.items].filter(item => item?.kind === 'file') : null;
const files = [...(input.files ?? input)].filter(file => file instanceof Blob);
const count = Math.max(files.length, items?.length ?? 0);
if (count === 0) return { outcome: 'rejected-empty' };
if (count > 1) return { outcome: 'rejected-multiple' };
if (items?.some(item => item.webkitGetAsEntry?.()?.isDirectory)) return { outcome: 'rejected-directory' };
return files.length === 1 ? { file: files[0] } : { outcome: 'rejected-empty' };
}
export function createLocalSession({
parentScope, createObjectURL = file => URL.createObjectURL(file), revokeObjectURL = url => URL.revokeObjectURL(url),
schedulerYield = defaultSchedulerYield(), setTimer = setTimeout, clearTimer = clearTimeout,
pageTarget = globalThis.window, extractFacts, pngMetadata,
} = {}) {
const controller = createInspectionController({ api: null, adapter: localFileAdapter, extractFacts });
const listeners = new Set();
let state = EMPTY, handle = null, url = null, operation = null, generation = 0, disposed = false;
const yieldControl = createYield({ schedulerYield, setTimer, clearTimer });
function publish(update) {
if (disposed) return;
state = Object.freeze({ ...EMPTY, ...update });
for (const listener of [...listeners]) listener(state);
}
/** Ends the current selection; idempotent. Subscribers drop the preview before its URL is revoked. */
function release(next) {
generation++;
operation?.abort(); operation = null;
if (handle) { releaseLocalFileHandle(handle); handle = null; }
controller.clear();
const previous = url; url = null;
if (next) publish(next);
if (previous) revokeObjectURL(previous);
}
const live = (token, signal) => !disposed && token === generation && !signal.aborted;
async function classify(file, token, signal) {
try {
await yieldControl(signal);
if (!live(token, signal)) return;
let prefix;
try {
prefix = new Uint8Array(await file.slice(0, Math.min(PREFIX_BYTES, file.size)).arrayBuffer());
} catch (error) {
if (!live(token, signal) || !readFailure(error)) throw error;
const declared = declaredClassification(file);
if (!declared) { publish({ phase: 'unsupported', message: LOCAL_SESSION_MESSAGES.unsupported }); return; }
// Unreadable after choosing: no preview and no URL; the record is the inspector's unreadable state.
handle = createLocalFileHandle({ file, url: null, classification: declared });
publish({ phase: 'ready', preview: null, message: LOCAL_SESSION_MESSAGES.unreadable });
controller.select(handle);
return;
}
if (!live(token, signal)) return;
const reader = pngMetadata ?? pngReader();
const classification = prefix.length ? reader.classifyPrefix(prefix) : null;
if (!classification) { publish({ phase: 'unsupported', message: LOCAL_SESSION_MESSAGES.unsupported }); return; }
// The URL exists only for a classified file, and is never revoked on load: the viewer and Download original use it.
let created = null;
try { created = createObjectURL(file); } catch { created = null; }
url = typeof created === 'string' && created.startsWith('blob:') ? created : null;
if (created && !url) revokeObjectURL(created);
handle = createLocalFileHandle({ file, url, classification });
const message = !url ? LOCAL_SESSION_MESSAGES.previewFailed : CONTAINER_ONLY.has(classification.kind) ? LOCAL_SESSION_MESSAGES.containerOnly : '';
publish({ phase: 'ready', preview: url ? Object.freeze({ url, media: classification.media }) : null, message });
controller.select(handle);
} catch (error) {
if (!live(token, signal) || error?.name === 'AbortError') return;
publish({ phase: 'unsupported', message: LOCAL_SESSION_MESSAGES.unsupported });
}
}
function dispose() {
if (disposed) return;
release(EMPTY);
disposed = true;
listeners.clear();
controller.dispose();
pageTarget?.removeEventListener?.('pagehide', leave);
parentScope?.signal?.removeEventListener('abort', dispose);
}
const leave = () => { if (!disposed && (handle || url || operation || state.phase !== 'empty')) release(EMPTY); };
if (parentScope?.signal?.aborted) {
disposed = true;
controller.dispose();
} else {
parentScope?.signal?.addEventListener('abort', dispose, { once: true });
pageTarget?.addEventListener?.('pagehide', leave);
}
return {
get state() { return state; },
get entry() { return handle; },
controller,
select(input) {
if (disposed) return 'ignored';
const { file, outcome } = chosenFile(input);
if (!file) return outcome;
release({ phase: 'classifying' });
const current = operation = new AbortController();
classify(file, generation, current.signal);
return 'accepted';
},
/** Ends the current selection as page leave does; the session stays usable. */
clear: leave,
retry() {
if (disposed || !handle) return undefined;
return controller.retry();
},
subscribe(listener) {
if (disposed) return () => {};
listeners.add(listener); listener(state);
return () => listeners.delete(listener);
},
dispose,
};
}