* fix(inspector): pass text through unknown nodes instead of refusing them (#381) The inspector refused any node class missing from the semantics table, so a PromptManagerPair behind an rgthree Any Switch showed "Prompt uses a node the inspector does not support". The inspection resolver (Python and browser) now reads through unknown nodes: a text input is the text, positive/negative route by output slot, a conditioning input is followed alone, otherwise every linked input except core type inputs (pass_through.skip_inputs). PromptManager nodes are authoritative; several different texts become one candidate each ("Several prompts reach the sampler"). A seeded text node with no conditioning input is generated at run time, and a recorded PromptManager execution is not checked against text that passed through an unknown node. INSPECTION_RESOLVER_VERSION goes to 2; ingestion keeps the default profile. A legacy parity gate records every committed graph whose inspection positive matches the legacy parser's; no graph may leave it. * fix(inspector): keep recorded pair text, string-only seed rule, role inputs and unique candidates (#381) Review of the pass-through: a walk through an unknown node never overrides a PromptManagerPair's execution record (any outcome but work-limit is unverifiable while the record is checked); the run-time seed rule reads only string outputs; a node with positive/negative inputs follows only the role of the requested slot, nothing when it is unlinked; one text offered twice is one candidate. * fix(inspector): anchor on the saver that wrote the file and resolve stages on shared links (#381) An inspected file's name now anchors the inspection on the one saver whose filename_prefix (literal or traced) makes the file's stem <prefix>_<digits>[_]; the samplers in that saver's image ancestry are the candidates. No match, several matches or an unreadable walk anchor nothing, so the file stays "several possible prompts". In a latent walk, an earlier sampler reading the same conditioning links as the later one is the same prompt, not an unattributed stage. Python and the browser resolver behave the same; the legacy parity gate states each graph that differs from legacy on purpose. * fix(inspector): skip only plain first stages on shared links; an unfinished prefix scan anchors nothing (#381) Review of the saver anchor: a same-links earlier stage is skipped only when it is not a detailer, sets no prompt override and continues no other sampler, so an earlier prompt or override behind it is never hidden. A work limit while tracing a saver prefix ends the match scan without an anchor instead of counting that saver as no match. * test(inspector): cover every plain-first-stage outcome on both resolvers (#381) The frontend coverage gate found local-resolver-stages.mjs branches at 92.44% (< 93). Each way an earlier stage stops being a plain first stage (no latent, literal, missing or cyclic latent, a sampler-shaped node, a detailer) is now a resolver test and an anchor-first-stage-* parity vector.
231 lines
13 KiB
JavaScript
231 lines
13 KiB
JavaScript
// One chosen local File, read entirely in this browser, as the inspection wire
|
|
// the workspace already renders. The readers are ports of the server's
|
|
// (png-metadata.js readPngMetadataFile/readImageHeader, local-video.mjs); the
|
|
// projection is local-projection.mjs. Nothing here builds a URL or sends bytes:
|
|
// the only URL a handle carries is the session-owned blob: URL it was created with.
|
|
import { readVideoTags, selectVideoCarrier } from './local-video.mjs';
|
|
import { projectMetadata, fitInspectionPayload, MAX_RESPONSE_BYTES } from './local-projection.mjs';
|
|
import { projectInspectionPayload } from './inspection-state.mjs';
|
|
import { resolveLocalEvidence, unreadableEvidence } from './local-resolver.mjs';
|
|
import { loadInspectionSemantics } from './local-semantics.mjs';
|
|
|
|
export const LOCAL_OBSERVATION = 'selected-local-file';
|
|
export const LOCAL_SOURCE_LABEL = 'Selected local file';
|
|
const LOCAL_LIBRARY = Object.freeze({ status: 'unavailable', scope: 'saved-library', tags: [], category_scope: null, tags_scope: null });
|
|
const LOCAL_SCOPE = 'local';
|
|
const PREFIX_BYTES = 32;
|
|
const MAX_FILENAME_CHARS = 255;
|
|
const MEDIA_TYPES = new Set(['image', 'video']);
|
|
|
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
const abortError = () => new DOMException('The local selection ended', 'AbortError');
|
|
|
|
/** `promise`, or an AbortError as soon as `signal` aborts (the work behind `promise` is shared and keeps going). */
|
|
function untilAborted(promise, signal) {
|
|
if (!signal) return promise;
|
|
if (signal.aborted) return Promise.reject(abortError());
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = () => reject(abortError());
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
promise.then(value => { signal.removeEventListener('abort', onAbort); resolve(value); },
|
|
error => { signal.removeEventListener('abort', onAbort); reject(error); });
|
|
});
|
|
}
|
|
|
|
/** One turn of the event loop between bounded steps, so the busy state paints and input is handled. */
|
|
function nextTurn(signal) {
|
|
return untilAborted(new Promise(resolve => setTimeout(resolve, 0)), signal);
|
|
}
|
|
const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata;
|
|
|
|
/** A display name with no path separators or control characters; never empty. */
|
|
export function sanitizeLocalFilename(name) {
|
|
const text = typeof name === 'string' ? name : '';
|
|
const cleaned = text.replace(/[\x00-\x1f\x7f]/g, '').replace(/[\\/]/g, '_').trim();
|
|
return [...cleaned].slice(0, MAX_FILENAME_CHARS).join('') || 'file';
|
|
}
|
|
|
|
/** `pathlib.Path(name).suffix.lower()`. */
|
|
function localExtension(filename) {
|
|
const dot = filename.lastIndexOf('.');
|
|
return dot > 0 && dot < filename.length - 1 ? filename.slice(dot).toLowerCase() : '';
|
|
}
|
|
|
|
function classificationOf(value) {
|
|
if (!object(value) || !MEDIA_TYPES.has(value.media) || typeof value.kind !== 'string' || !value.kind) throw new TypeError('Unsupported local file');
|
|
return Object.freeze({ media: value.media, kind: value.kind });
|
|
}
|
|
|
|
/**
|
|
* Run the readers the server would run for these bytes: the bounded PNG reader,
|
|
* the JPEG/WebP header reader, or the container tag readers plus the carrier rule.
|
|
* Returns `{classification, result}` in the oracle's shape; `result` is null for
|
|
* bytes no reader claims.
|
|
*/
|
|
export async function readLocalFile(file, { classification, signal, pngMetadata = pngReader() } = {}) {
|
|
if (!(file instanceof Blob)) throw new TypeError('A local file is required');
|
|
const reader = () => {
|
|
if (!pngMetadata || typeof pngMetadata.classifyPrefix !== 'function') throw new TypeError('The local PNG reader is unavailable');
|
|
return pngMetadata;
|
|
};
|
|
const prefix = new Uint8Array(await file.slice(0, Math.min(PREFIX_BYTES, file.size)).arrayBuffer());
|
|
const classified = classification === undefined ? reader().classifyPrefix(prefix) : classification;
|
|
if (classified === null) return { classification: null, result: null };
|
|
const { media, kind } = classificationOf(classified);
|
|
if (kind === 'png') return { classification: classified, result: await reader().readPngMetadataFile(file, { signal, prefix }) };
|
|
if (media === 'image') return { classification: classified, result: await reader().readImageHeader(file, kind) };
|
|
const read = await readVideoTags(file, { signal, prefix, kind });
|
|
return { classification: classified, result: { read, selection: read.status === 'complete' ? selectVideoCarrier(read.tags, { limited: read.limited }) : null } };
|
|
}
|
|
|
|
/**
|
|
* The server's InspectionRead for a reader result: `read` is null when the server
|
|
* would have raised MetadataReadError (unreadable), else
|
|
* `{records, record_formats, carrierText, limited}`; `format`, `width` and
|
|
* `height` follow `read_inspection_file` (a video has none).
|
|
*/
|
|
export function toInspectionRead(classification, result) {
|
|
const { media, kind } = classificationOf(classification);
|
|
const none = { read: null, format: null, width: null, height: null };
|
|
if (kind === 'png') {
|
|
if (!object(result) || result.status !== 'complete') return none;
|
|
const limited = [...(result.limited ?? [])];
|
|
const record_formats = Object.fromEntries(Object.keys(result.records).map(key => [key, 'original-text']));
|
|
for (const key of limited) record_formats[key] = 'limited';
|
|
return { read: { records: { ...result.records }, record_formats, carrierText: null, limited }, format: 'PNG', width: result.width ?? null, height: result.height ?? null };
|
|
}
|
|
if (media === 'image') {
|
|
if (!object(result)) return none;
|
|
return { read: { records: {}, record_formats: {}, carrierText: null, limited: [] }, format: result.format ?? null, width: result.width ?? null, height: result.height ?? null };
|
|
}
|
|
const selection = result?.selection;
|
|
if (result?.read?.status !== 'complete' || !object(selection) || selection.status !== 'complete') return none;
|
|
return { read: { records: { ...selection.records }, record_formats: { ...selection.record_formats }, carrierText: selection.carrierText ?? null, limited: [...(selection.limited ?? [])] }, format: null, width: null, height: null };
|
|
}
|
|
|
|
const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
|
|
/** The roles for one local read (`{prompts, resolution}`): the bounded parse and resolution step of buildLocalPayload. */
|
|
export function localEvidence(read, classification, semantics = null, fileName = null) {
|
|
const inspection = toInspectionRead(classificationOf(classification), read);
|
|
return inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics, fileName);
|
|
}
|
|
|
|
/**
|
|
* The `inspection_version: 1` wire for one local read, before the response budget. Its roles are the server
|
|
* inspector's for the same records (local-resolver.mjs with `semantics`, the compiled node table; null answers
|
|
* "unavailable" as the server does), so the legacy fill in projectInspectionPayload applies exactly where it does
|
|
* for Images.
|
|
*/
|
|
export function buildLocalPayload({ read, file, classification, semantics = null, evidence = localEvidence(read, classification, semantics, file?.name ?? null) }) {
|
|
const classified = classificationOf(classification);
|
|
const inspection = toInspectionRead(classified, read);
|
|
const filename = sanitizeLocalFilename(file?.name);
|
|
const size = Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : null;
|
|
const modified = typeof file?.lastModified === 'number' && Number.isFinite(file.lastModified) && file.lastModified >= 0 ? file.lastModified / 1000 : null;
|
|
return {
|
|
success: true, inspection_version: 1,
|
|
file: {
|
|
observation: LOCAL_OBSERVATION, source_label: LOCAL_SOURCE_LABEL, media_type: classified.media, extension: localExtension(filename),
|
|
format: inspection.format, size, width: dimension(inspection.width), height: dimension(inspection.height), modified_time: modified,
|
|
},
|
|
prompts: evidence.prompts,
|
|
resolution: evidence.resolution,
|
|
metadata: projectMetadata(inspection.read),
|
|
library: { ...LOCAL_LIBRARY, tags: [] },
|
|
};
|
|
}
|
|
|
|
/** The server's 8 MiB response budget, applied to the local wire with the same omission order. */
|
|
export function fitLocalPayload(wire, limit = MAX_RESPONSE_BYTES, options = {}) {
|
|
return fitInspectionPayload(wire, limit, options);
|
|
}
|
|
|
|
// Handles this module created, by a private token: the controller and the viewer
|
|
// session spread-copy descriptors, so a copy of an owned handle is the same
|
|
// selection, while a hand-made descriptor or a tampered URL is foreign.
|
|
const TOKEN = Symbol('local-file');
|
|
const OWNED = new WeakMap();
|
|
let sequence = 0;
|
|
|
|
/**
|
|
* One selection of a local file. `url` is the session's own `blob:` URL (or null
|
|
* when none could be created); every selection gets a new key, so choosing the
|
|
* same bytes twice never reuses a stale read.
|
|
*/
|
|
export function createLocalFileHandle({ file, url = null, classification, filename = file?.name }) {
|
|
if (!(file instanceof Blob)) throw new TypeError('A local file is required');
|
|
if (url !== null && (typeof url !== 'string' || !url.startsWith('blob:'))) throw new TypeError('Local media must be a blob: URL');
|
|
const classified = classificationOf(classification);
|
|
const token = Object.freeze({});
|
|
const secret = Object.freeze({ key: `local:${++sequence}`, filename: sanitizeLocalFilename(filename), url, file, classification: classified });
|
|
OWNED.set(token, secret);
|
|
return Object.freeze({ kind: 'local-file', key: secret.key, filename: secret.filename, is_video: classified.media === 'video', url, file, classification: classified, [TOKEN]: token });
|
|
}
|
|
|
|
/**
|
|
* Ends a selection: the handle and every copy of it become foreign, so a replaced
|
|
* or released file can no longer be read, downloaded or shown.
|
|
*/
|
|
export function releaseLocalFileHandle(handle) {
|
|
if (object(handle) && handle[TOKEN]) OWNED.delete(handle[TOKEN]);
|
|
}
|
|
|
|
/** The owned selection behind a handle (or a copy of one); throws for anything else. */
|
|
function owned(handle) {
|
|
const secret = object(handle) ? OWNED.get(handle[TOKEN]) : undefined;
|
|
if (!secret || secret.key !== handle.key || secret.url !== handle.url || secret.file !== handle.file || secret.filename !== handle.filename) throw new TypeError('Unknown local file');
|
|
return secret;
|
|
}
|
|
|
|
function validate(handle) {
|
|
owned(handle);
|
|
}
|
|
|
|
function originalDownload(handle) {
|
|
const secret = owned(handle);
|
|
if (secret.url === null) throw new TypeError('Original media is unavailable');
|
|
return { url: secret.url, filename: secret.filename };
|
|
}
|
|
|
|
export const localFileAdapter = Object.freeze({
|
|
validate,
|
|
key(handle) { return owned(handle).key; },
|
|
scope(handle) { owned(handle); return LOCAL_SCOPE; },
|
|
// An inert sentinel: the controller's same-key check works and no URL is ever built.
|
|
inspectionPath(handle) { return `local-file:${owned(handle).key}`; },
|
|
/**
|
|
* Reads, resolves and projects one selection on the page's thread in bounded steps (each bound by the 2 MiB role
|
|
* and 4 MiB workflow budgets and the resolver's work limits) with a turn of the event loop between them, so the
|
|
* inspector's busy state paints. Every wait ends with the selection's `signal`; `yieldControl` is for tests.
|
|
*/
|
|
async read(handle, { signal, yieldControl = nextTurn } = {}) {
|
|
const secret = owned(handle);
|
|
let result = null;
|
|
try {
|
|
({ result } = await readLocalFile(secret.file, { classification: secret.classification, signal }));
|
|
} catch (error) {
|
|
// The browser could not read the bytes (removed, moved or denied after choosing): the server's
|
|
// MetadataReadError, an unreadable record. Abort and programming errors still reject.
|
|
if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error;
|
|
}
|
|
const semantics = await untilAborted(loadInspectionSemantics(), signal);
|
|
await yieldControl(signal);
|
|
const evidence = localEvidence(result, secret.classification, semantics, secret.filename);
|
|
await yieldControl(signal);
|
|
const file = { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified };
|
|
const payload = buildLocalPayload({ read: result, file, classification: secret.classification, semantics, evidence });
|
|
await yieldControl(signal);
|
|
return fitLocalPayload(payload, MAX_RESPONSE_BYTES, { signal });
|
|
},
|
|
toRecord(result, handle, extractFacts) {
|
|
const secret = owned(handle);
|
|
if (!object(result) || result.success !== true || result.inspection_version !== 1) throw new TypeError('Invalid local inspection');
|
|
return projectInspectionPayload(result, { kind: 'local-file', key: secret.key, path: secret.filename },
|
|
secret.url === null ? null : originalDownload(handle), extractFacts);
|
|
},
|
|
originalDownload,
|
|
// Session-owned, never resolved through the API client.
|
|
mediaUrl(handle) { return owned(handle).url; },
|
|
});
|