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.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
// web/js/workspace/local-session.mjs owns one local selection at a time: it
|
||||
// classifies the chosen File by its bytes, creates the blob: URL only for a
|
||||
// classified file, hands an owned handle to a real inspection controller, and
|
||||
// releases the URL, the handle and the record on replacement, dispose, parent
|
||||
// abort and page leave.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { raw, ihdr, idat, iend, tEXt } from "./helpers/png-bytes.mjs";
|
||||
import { createScope } from "../../web/js/shared/toolkit/core.js";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const PngMetadata = require("../../web/js/png-metadata.js");
|
||||
const ComfyMetadata = require("../../web/js/comfy-metadata.js");
|
||||
globalThis.PngMetadata = PngMetadata;
|
||||
globalThis.ComfyMetadata = ComfyMetadata;
|
||||
const m = await import("../../web/js/workspace/local-session.mjs").catch((error) => {
|
||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||
return {};
|
||||
});
|
||||
const { localFileAdapter } = await import("../../web/js/workspace/local-inspection.mjs");
|
||||
|
||||
const FIXTURES = path.join(here, "..", "fixtures", "local-inspect");
|
||||
const promptFor = (text) => JSON.stringify({ 3: { class_type: "KSampler", inputs: { seed: 7, steps: 20, cfg: 8, sampler_name: "euler", scheduler: "normal", model: ["4", 0], positive: ["6", 0], negative: ["7", 0] } },
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, 6: { class_type: "CLIPTextEncode", inputs: { text, clip: ["4", 1] } }, 7: { class_type: "CLIPTextEncode", inputs: { text: "blurry", clip: ["4", 1] } } });
|
||||
const MODIFIED = 1_700_000_000_000;
|
||||
|
||||
/** A File that records each slice and lets a test hold slice reads (`gate(index)` returns a promise). */
|
||||
class RecordingFile extends File {
|
||||
constructor(parts, name, { gate, ...options } = {}) {
|
||||
super(parts, name, { lastModified: MODIFIED, ...options });
|
||||
this.ranges = [];
|
||||
this.gate = gate;
|
||||
}
|
||||
slice(start, end) {
|
||||
const index = this.ranges.push([start, end]) - 1;
|
||||
const part = super.slice(start, end), gate = this.gate;
|
||||
return { size: part.size, arrayBuffer: async () => { if (gate) await gate(index); return part.arrayBuffer(); } };
|
||||
}
|
||||
}
|
||||
const pngFile = (name, text = "a red fox", options) => new RecordingFile([raw(ihdr(64, 48), idat(), tEXt("prompt", promptFor(text)), iend())], name, { type: "image/png", ...options });
|
||||
const fixture = (name, as = name) => new RecordingFile([fs.readFileSync(path.join(FIXTURES, name))], as);
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((resolve) => setImmediate(resolve)); };
|
||||
|
||||
function urls({ fail = false } = {}) {
|
||||
const created = [], revoked = [];
|
||||
return {
|
||||
created, revoked,
|
||||
createObjectURL(file) { if (fail) throw new Error("no object URLs"); const url = `blob:http://localhost/${created.length + 1}`; created.push({ url, file }); return url; },
|
||||
revokeObjectURL(url) { revoked.push(url); },
|
||||
};
|
||||
}
|
||||
function pageTarget() {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, callback) { listeners.set(type, callback); },
|
||||
removeEventListener(type, callback) { if (listeners.get(type) === callback) listeners.delete(type); },
|
||||
fire(type) { listeners.get(type)?.({ type }); },
|
||||
};
|
||||
}
|
||||
function session(options = {}) {
|
||||
const parentScope = options.parentScope ?? createScope(), spies = options.urls ?? urls(), page = options.page ?? pageTarget();
|
||||
const value = m.createLocalSession({ parentScope, createObjectURL: spies.createObjectURL, revokeObjectURL: spies.revokeObjectURL,
|
||||
schedulerYield: () => Promise.resolve(), pageTarget: page, extractFacts: ComfyMetadata.extractGenerationFacts, ...options.overrides });
|
||||
return { session: value, parentScope, urls: spies, page };
|
||||
}
|
||||
/** Resolves with the first controller state `predicate` accepts. */
|
||||
function until(controller, predicate) {
|
||||
return new Promise((resolve) => {
|
||||
let stop = null, done = false;
|
||||
stop = controller.subscribe((state) => { if (!done && predicate(state)) { done = true; resolve(state); stop?.(); } });
|
||||
if (done) stop();
|
||||
});
|
||||
}
|
||||
const recorded = (controller) => until(controller, (state) => state.record !== null || state.error);
|
||||
|
||||
test("contract", () => {
|
||||
assert.equal(typeof m.createLocalSession, "function", "createLocalSession is exported");
|
||||
assert.equal(typeof m.createYield, "function", "createYield is exported");
|
||||
assert.deepEqual(Object.keys(m.LOCAL_SESSION_MESSAGES).sort(), ["containerOnly", "directory", "multiple", "previewFailed", "unsupported"]);
|
||||
const { session: value } = session();
|
||||
for (const name of ["select", "retry", "subscribe", "dispose"]) assert.equal(typeof value[name], "function", name);
|
||||
assert.equal(value.state.phase, "empty");
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(typeof value.controller.select, "function");
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
test("replacement synchronously clears old capabilities", async () => {
|
||||
const { session: value, urls: spies } = session();
|
||||
const states = [];
|
||||
value.subscribe((state) => states.push({ ...state, revokedAtPublish: spies.revoked.length }));
|
||||
// Two different Files with the same name, size and time are two selections.
|
||||
const a = pngFile("same.png", "first fox"), b = pngFile("same.png", "other fox");
|
||||
assert.equal(a.size, b.size);
|
||||
assert.equal(value.select([a]), "accepted");
|
||||
assert.equal(value.state.phase, "classifying");
|
||||
const first = await recorded(value.controller);
|
||||
assert.equal(first.record.prompts.positive.text, "first fox");
|
||||
const entryA = value.entry, urlA = spies.created[0].url;
|
||||
assert.deepEqual([entryA.filename, entryA.is_video], ["same.png", false]);
|
||||
assert.deepEqual(value.state.preview, { url: urlA, media: "image" });
|
||||
assert.equal(localFileAdapter.originalDownload(entryA).url, urlA);
|
||||
|
||||
const before = states.length;
|
||||
assert.equal(value.select([b]), "accepted");
|
||||
// Synchronously: no record, no entry, no preview, the old URL revoked once and the old handle foreign.
|
||||
assert.equal(value.controller.state.record, null);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.deepEqual(spies.revoked, [urlA]);
|
||||
for (const method of ["originalDownload", "mediaUrl", "key"]) assert.throws(() => localFileAdapter[method](entryA), TypeError, method);
|
||||
assert.throws(() => localFileAdapter.originalDownload({ ...entryA }), TypeError, "a copy of a released handle is foreign too");
|
||||
// The preview left the published state before its URL was revoked.
|
||||
assert.deepEqual([states[before].phase, states[before].preview, states[before].revokedAtPublish], ["classifying", null, 0]);
|
||||
const second = await recorded(value.controller);
|
||||
assert.equal(second.record.prompts.positive.text, "other fox");
|
||||
assert.notEqual(value.entry.key, entryA.key);
|
||||
assert.equal(spies.revoked.length, 1);
|
||||
|
||||
// A read still in flight when the next file arrives never publishes.
|
||||
const held = deferred(), published = [];
|
||||
value.controller.subscribe((state) => { if (state.record) published.push(state.record.identity.key); });
|
||||
const slow = pngFile("slow.png", "late fox", { gate: (index) => (index < 2 ? Promise.resolve() : held.promise) });
|
||||
value.select([slow]);
|
||||
await until(value.controller, (state) => state.loading);
|
||||
const slowKey = value.entry.key;
|
||||
assert.equal(value.select([pngFile("next.png", "current fox")]), "accepted");
|
||||
held.resolve();
|
||||
const current = await recorded(value.controller);
|
||||
await settle();
|
||||
assert.equal(current.record.prompts.positive.text, "current fox");
|
||||
assert.ok(!published.includes(slowKey), "the replaced read never published");
|
||||
assert.equal(value.controller.state.record.prompts.positive.text, "current fox");
|
||||
value.dispose();
|
||||
assert.equal(new Set(spies.revoked).size, spies.revoked.length, "each URL revoked once");
|
||||
assert.deepEqual(spies.revoked.sort(), spies.created.map((item) => item.url).sort());
|
||||
});
|
||||
|
||||
test("cancel and invalid drops keep the previous selection", async () => {
|
||||
const { session: value, urls: spies } = session();
|
||||
value.select([pngFile("kept.png", "kept fox")]);
|
||||
await recorded(value.controller);
|
||||
const entry = value.entry, state = value.state;
|
||||
const directory = new File([], "folder");
|
||||
const drops = [
|
||||
[[], "rejected-empty"], [{ files: [], items: [] }, "rejected-empty"], [null, "ignored"], [undefined, "ignored"],
|
||||
[[pngFile("one.png"), pngFile("two.png")], "rejected-multiple"],
|
||||
[{ files: [pngFile("one.png"), pngFile("two.png")], items: [{ kind: "file" }, { kind: "file" }] }, "rejected-multiple"],
|
||||
[{ files: [directory], items: [{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: true, isFile: false }) }] }, "rejected-directory"],
|
||||
[{ files: [], items: [{ kind: "string" }] }, "rejected-empty"],
|
||||
];
|
||||
for (const [input, outcome] of drops) {
|
||||
assert.equal(value.select(input), outcome, JSON.stringify(input));
|
||||
assert.equal(value.entry, entry);
|
||||
assert.equal(value.state, state);
|
||||
assert.equal(value.controller.state.record.prompts.positive.text, "kept fox");
|
||||
}
|
||||
assert.equal(spies.created.length, 1);
|
||||
assert.deepEqual(spies.revoked, []);
|
||||
// One File in a DataTransfer whose entry is a file is accepted.
|
||||
assert.equal(value.select({ files: [pngFile("dropped.png", "dropped fox")], items: [{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: false, isFile: true }) }] }), "accepted");
|
||||
assert.equal((await recorded(value.controller)).record.prompts.positive.text, "dropped fox");
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
test("unclassified input creates no object URL", async () => {
|
||||
const { session: value, urls: spies } = session();
|
||||
const text = (body, name) => new RecordingFile([body], name);
|
||||
for (const file of [text("plain text", "a.png"), text("<!doctype html><p>hi", "a.mp4"), text('<svg xmlns="http://www.w3.org/2000/svg"/>', "a.png"),
|
||||
text("#EXTM3U\n#EXT-X-VERSION:3\n", "a.mp4"), new RecordingFile([], "empty.png")]) {
|
||||
assert.equal(value.select([file]), "accepted");
|
||||
await settle();
|
||||
assert.equal(value.state.phase, "unsupported", file.name);
|
||||
assert.equal(value.state.message, m.LOCAL_SESSION_MESSAGES.unsupported);
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(value.controller.state.record, null);
|
||||
assert.ok(file.ranges.every(([start, end]) => start === 0 && end <= 32), "only the prefix is read");
|
||||
}
|
||||
// A file that cannot be read (removed after choosing) is never classified.
|
||||
const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
|
||||
assert.equal(value.select([gone]), "accepted");
|
||||
await settle();
|
||||
assert.deepEqual([value.state.phase, value.state.message, value.entry], ["unsupported", m.LOCAL_SESSION_MESSAGES.unsupported, null]);
|
||||
assert.equal(spies.created.length, 0);
|
||||
// A real MP4 named .png is a video by its bytes.
|
||||
value.select([fixture("core-faststart.mp4", "clip.png")]);
|
||||
await settle();
|
||||
assert.equal(value.state.phase, "ready");
|
||||
assert.equal(value.state.preview.media, "video");
|
||||
assert.equal(value.entry.is_video, true);
|
||||
assert.equal(spies.created.length, 1);
|
||||
value.dispose();
|
||||
});
|
||||
|
||||
test("video record publishes even if preview fails", async () => {
|
||||
const { session: value } = session({ urls: urls({ fail: true }) });
|
||||
value.select([fixture("core-tags.webm")]);
|
||||
const state = await recorded(value.controller);
|
||||
assert.equal(value.state.phase, "ready");
|
||||
assert.equal(value.state.preview, null);
|
||||
assert.equal(value.state.message, m.LOCAL_SESSION_MESSAGES.previewFailed);
|
||||
assert.equal(value.entry.is_video, true);
|
||||
assert.equal(state.error, false);
|
||||
assert.equal(state.record.file.media_type, "video");
|
||||
assert.equal(state.record.metadata.status, "available");
|
||||
assert.equal(state.record.generation.seed.value, "7");
|
||||
assert.equal(state.record.download, null, "no original without an object URL");
|
||||
value.dispose();
|
||||
// AVI keeps its preview and says its metadata is not read.
|
||||
const avi = session();
|
||||
avi.session.select([new RecordingFile([Buffer.from("RIFF"), new Uint8Array(4), Buffer.from("AVI LIST"), new Uint8Array(64)], "clip.avi")]);
|
||||
await recorded(avi.session.controller);
|
||||
assert.equal(avi.session.state.message, m.LOCAL_SESSION_MESSAGES.containerOnly);
|
||||
assert.equal(avi.session.state.preview.media, "video");
|
||||
avi.session.dispose();
|
||||
});
|
||||
|
||||
test("disposal settles a pending fallback yield", async () => {
|
||||
const timers = new Map(), cleared = [];
|
||||
let serial = 0;
|
||||
const setTimer = (callback, delay) => { assert.equal(delay, 0); timers.set(++serial, callback); return serial; };
|
||||
const clearTimer = (token) => { cleared.push(token); timers.delete(token); };
|
||||
// The yield itself: a timer that rejects with AbortError on abort and removes its listener.
|
||||
const yieldControl = m.createYield({ schedulerYield: null, setTimer, clearTimer });
|
||||
const controller = new AbortController();
|
||||
const pending = yieldControl(controller.signal);
|
||||
assert.equal(timers.size, 1);
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: "AbortError" });
|
||||
assert.deepEqual(cleared, [1], "the timer is cleared on abort (the once listener is gone with it)");
|
||||
// A turn that completes removes its abort listener, so a long-lived signal does not collect them.
|
||||
const kept = new AbortController(), removed = [];
|
||||
const remove = kept.signal.removeEventListener.bind(kept.signal);
|
||||
kept.signal.removeEventListener = (type, callback) => { removed.push(type); remove(type, callback); };
|
||||
const ok = yieldControl(kept.signal);
|
||||
timers.get(2)();
|
||||
await ok;
|
||||
assert.deepEqual(removed, ["abort"]);
|
||||
await assert.rejects(yieldControl(AbortSignal.abort()), { name: "AbortError" });
|
||||
// The session: a selection waiting on the fallback yield is settled by dispose and does no work.
|
||||
timers.clear(); cleared.length = 0;
|
||||
const { session: value, urls: spies } = session({ overrides: { schedulerYield: null, setTimer, clearTimer } });
|
||||
const file = pngFile("a.png");
|
||||
value.select([file]);
|
||||
assert.equal(timers.size, 1);
|
||||
const [token] = timers.keys();
|
||||
value.dispose();
|
||||
assert.deepEqual(cleared, [token]);
|
||||
await settle();
|
||||
assert.deepEqual(file.ranges, []);
|
||||
assert.equal(spies.created.length, 0);
|
||||
});
|
||||
|
||||
test("old scheduler.yield continuation is fenced", async () => {
|
||||
const turns = [];
|
||||
const { session: value, urls: spies } = session({ overrides: { schedulerYield: () => { const turn = deferred(); turns.push(turn); return turn.promise; } } });
|
||||
const a = pngFile("a.png", "old fox"), b = pngFile("b.png", "new fox");
|
||||
value.select([a]);
|
||||
value.select([b]);
|
||||
assert.equal(turns.length, 2);
|
||||
turns[0].resolve();
|
||||
await settle();
|
||||
assert.deepEqual(a.ranges, [], "the replaced selection never reads");
|
||||
assert.equal(spies.created.length, 0);
|
||||
assert.equal(value.state.phase, "classifying");
|
||||
turns[1].resolve();
|
||||
const state = await recorded(value.controller);
|
||||
assert.equal(state.record.prompts.positive.text, "new fox");
|
||||
assert.deepEqual(spies.created.map((item) => item.file), [b]);
|
||||
// A continuation that resumes after dispose is fenced too.
|
||||
value.select([pngFile("c.png")]);
|
||||
const late = turns.at(-1);
|
||||
value.dispose();
|
||||
late.resolve();
|
||||
await settle();
|
||||
assert.equal(spies.created.length, 1);
|
||||
});
|
||||
|
||||
test("retry rereads identically", async () => {
|
||||
const { session: value } = session();
|
||||
const file = pngFile("a.png", "retry fox");
|
||||
value.select([file]);
|
||||
const first = await recorded(value.controller);
|
||||
const classified = 1, reads = file.ranges.slice(classified);
|
||||
assert.ok(reads.length > 1);
|
||||
const key = value.entry.key;
|
||||
await value.retry();
|
||||
const again = value.controller.state;
|
||||
assert.deepEqual(file.ranges.slice(classified + reads.length), reads, "the same slice ranges");
|
||||
assert.equal(again.record.identity.key, key);
|
||||
assert.equal(again.record.identity.key, first.record.identity.key);
|
||||
assert.equal(again.record.prompts.positive.text, "retry fox");
|
||||
value.dispose();
|
||||
assert.equal(value.retry(), undefined, "a disposed session never reads");
|
||||
assert.equal(file.ranges.length, classified + 2 * reads.length);
|
||||
});
|
||||
|
||||
test("already disposed parent does zero work", () => {
|
||||
const parentScope = createScope();
|
||||
parentScope.dispose();
|
||||
const fail = (name) => () => assert.fail(`${name} called`);
|
||||
const page = { addEventListener: fail("addEventListener"), removeEventListener: fail("removeEventListener") };
|
||||
const value = m.createLocalSession({ parentScope, createObjectURL: fail("createObjectURL"), revokeObjectURL: fail("revokeObjectURL"),
|
||||
schedulerYield: fail("schedulerYield"), setTimer: fail("setTimer"), clearTimer: fail("clearTimer"), pageTarget: page });
|
||||
const file = pngFile("a.png");
|
||||
assert.equal(value.select([file]), "ignored");
|
||||
assert.equal(value.retry(), undefined);
|
||||
assert.equal(value.entry, null);
|
||||
assert.equal(value.state.phase, "empty");
|
||||
const unsubscribe = value.subscribe(fail("listener"));
|
||||
unsubscribe();
|
||||
value.dispose(); value.dispose();
|
||||
assert.deepEqual(file.ranges, []);
|
||||
});
|
||||
|
||||
test("dispose, parent abort and page leave each release the URL once", async () => {
|
||||
// dispose: the preview is cleared for subscribers before its URL is revoked.
|
||||
const first = session(), seen = [];
|
||||
first.session.select([pngFile("a.png")]);
|
||||
await recorded(first.session.controller);
|
||||
first.session.subscribe((state) => seen.push([state.phase, state.preview, first.urls.revoked.length]));
|
||||
first.session.dispose(); first.session.dispose();
|
||||
assert.deepEqual(seen.at(-1), ["empty", null, 0]);
|
||||
assert.deepEqual(first.urls.revoked, [first.urls.created[0].url]);
|
||||
assert.equal(first.session.select([pngFile("b.png")]), "ignored");
|
||||
assert.equal(first.page.listeners.size, 0, "the page listener is removed");
|
||||
// Parent abort disposes.
|
||||
const second = session();
|
||||
second.session.select([pngFile("a.png")]);
|
||||
await recorded(second.session.controller);
|
||||
second.parentScope.dispose();
|
||||
assert.deepEqual(second.urls.revoked, [second.urls.created[0].url]);
|
||||
assert.equal(second.session.controller.state.record, null);
|
||||
assert.equal(second.page.listeners.size, 0);
|
||||
// pagehide releases the selection; a page restored from the back/forward cache can choose again.
|
||||
const third = session();
|
||||
third.session.select([pngFile("a.png")]);
|
||||
await recorded(third.session.controller);
|
||||
const entry = third.session.entry;
|
||||
third.page.fire("pagehide");
|
||||
assert.deepEqual(third.urls.revoked, [third.urls.created[0].url]);
|
||||
assert.equal(third.session.entry, null);
|
||||
assert.equal(third.session.state.phase, "empty");
|
||||
assert.equal(third.session.controller.state.record, null);
|
||||
assert.throws(() => localFileAdapter.originalDownload(entry), TypeError);
|
||||
third.page.fire("pagehide");
|
||||
assert.equal(third.urls.revoked.length, 1);
|
||||
assert.equal(third.session.select([pngFile("b.png", "after return")]), "accepted");
|
||||
assert.equal((await recorded(third.session.controller)).record.prompts.positive.text, "after return");
|
||||
third.session.dispose();
|
||||
assert.equal(third.urls.revoked.length, 2);
|
||||
});
|
||||
+8
-2
@@ -259,7 +259,7 @@
|
||||
},
|
||||
"workspace/inspection-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -295,7 +295,7 @@
|
||||
},
|
||||
"workspace/local-inspection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 89,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -305,6 +305,12 @@
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/local-session.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 83,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/local-video.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
|
||||
@@ -135,6 +135,14 @@ export function createLocalFileHandle({ file, url = null, classification, filena
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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.',
|
||||
});
|
||||
|
||||
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;
|
||||
const prefix = new Uint8Array(await file.slice(0, Math.min(PREFIX_BYTES, file.size)).arrayBuffer());
|
||||
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';
|
||||
},
|
||||
retry() {
|
||||
if (disposed || !handle) return undefined;
|
||||
return controller.retry();
|
||||
},
|
||||
subscribe(listener) {
|
||||
if (disposed) return () => {};
|
||||
listeners.add(listener); listener(state);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user