diff --git a/tests/browser/local-inspect-files.mjs b/tests/browser/local-inspect-files.mjs new file mode 100644 index 0000000..af9f6ae --- /dev/null +++ b/tests/browser/local-inspect-files.mjs @@ -0,0 +1,66 @@ +// Files for the Inspect page workflows, as Playwright `setInputFiles` payloads ({name, mimeType, buffer}) or as the +// bytes a page builds a File or DataTransfer from. Built from the committed fixtures and the byte-exact PNG builders +// the node tests use (tests/js/helpers/png-bytes.mjs), so the browser reads exactly what the unit tests pin. +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { raw, ihdr, idat, iend, tEXt } from '../js/helpers/png-bytes.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join(here, '..', 'fixtures'); +const read = (...parts) => fs.readFileSync(path.join(FIXTURES, ...parts)); +const file = (name, mimeType, buffer) => ({ name, mimeType, buffer: Buffer.from(buffer) }); + +/** One committed parity PNG (tests/fixtures/inspection_parity), e.g. f02.png. */ +export const parityPng = (name, as = name) => file(as, 'image/png', read('inspection_parity', name)); +/** One committed local-inspect fixture (tests/fixtures/local-inspect). */ +export const localFixture = (name, mimeType, as = name) => file(as, mimeType, read('local-inspect', name)); +/** The committed CC0 VP8 clip with no generation metadata. */ +export const tinyWebm = () => file('tiny.webm', 'video/webm', read('tiny.webm')); + +/** One decodable 2×2 opaque pixel row set, so a synthetic PNG previews. */ +function pixels(width = 2, height = 2) { + const row = [0, ...Array.from({ length: width }, () => [40, 120, 200, 255]).flat()]; + return zlib.deflateSync(Buffer.from(Array.from({ length: height }, () => row).flat())); +} + +/** + * A previewable PNG whose positive spans lines and whose negative is a ConditioningZeroOut: the legacy fill shows the + * positive exactly and the ZeroOut guard makes the negative explicitly empty. + */ +export const POSITIVE_LINES = 'a lighthouse at dusk\nsoft rain, long exposure'; +export function sendablePng(name = 'send-me.png') { + const prompt = JSON.stringify({ + 3: { class_type: 'KSampler', inputs: { seed: 11, steps: 12, cfg: 5, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['8', 0], latent_image: ['5', 0] } }, + 4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'local.safetensors' } }, + 5: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } }, + 6: { class_type: 'CLIPTextEncode', inputs: { text: POSITIVE_LINES, clip: ['4', 1] } }, + 8: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['6', 0] } }, + }); + return file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', prompt), iend())); +} + +/** A PNG whose text chunk fails its CRC: the reader reports the metadata unreadable; the bytes stay downloadable. */ +export const corruptPng = (name = 'corrupt.png') => + file(name, 'image/png', raw(ihdr(2, 2), idat(pixels()), tEXt('prompt', '{"3":{}}', { crc: 1 }), iend())); + +/** Bytes no reader claims, under an image or video name. */ +export const disguised = () => [ + file('notes.png', 'image/png', Buffer.from('plain text, not an image')), + file('page.png', 'image/png', Buffer.from('
hi')),
+ file('vector.png', 'image/png', Buffer.from('')),
+ file('stream.mp4', 'video/mp4', Buffer.from('#EXTM3U\n#EXT-X-VERSION:3\n')),
+];
+
+/**
+ * core-faststart.mp4 with its video sample entry renamed from `avc1` to `zzzz`: the container and its tags read as
+ * before, but no browser has a decoder for the stream, so the preview fails deterministically on every engine.
+ */
+export function undecodableMp4(name = 'undecodable.mp4') {
+ const bytes = Buffer.from(read('local-inspect', 'core-faststart.mp4'));
+ const at = bytes.indexOf(Buffer.from('avc1', 'latin1'), bytes.indexOf(Buffer.from('stsd', 'latin1')));
+ if (at < 0) throw new Error('core-faststart.mp4 has no avc1 sample entry');
+ bytes.write('zzzz', at, 'latin1');
+ return file(name, 'video/mp4', bytes);
+}
diff --git a/tests/browser/server.py b/tests/browser/server.py
index c5ae192..459d971 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -509,6 +509,33 @@ def create_app(root: Path) -> web.Application:
shutil.copyfile(root / "output" / "shared" / "same.png", destination)
return web.json_response({"success": True})
+ @routes.post("/__test__/add-local-inspect-file")
+ async def add_local_inspect_file(request: web.Request) -> web.Response:
+ """Serve one committed Inspect fixture from the output folder.
+
+ The local Inspect page reads a chosen file in the browser; its page
+ workflow compares that rail with the Images viewer for the same bytes.
+ Only a committed fixture name is accepted.
+ """
+ data = await request.json() if request.can_read_body else None
+ fixtures = CANONICAL_ROOT / "tests" / "fixtures"
+ allowed = {
+ path.name: path
+ for folder in ("inspection_parity", "local-inspect")
+ for path in (fixtures / folder).iterdir()
+ if path.suffix.lower() in {".png", ".jpg", ".webp", ".webm", ".mp4"}
+ }
+ if (
+ not isinstance(data, dict)
+ or set(data) != {"name"}
+ or data["name"] not in allowed
+ ):
+ raise web.HTTPBadRequest()
+ destination = root / "output" / "local-inspect" / data["name"]
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(allowed[data["name"]], destination)
+ return web.json_response({"success": True})
+
@routes.get("/api/settings/Comfy.ColorPalette")
async def palette(request: web.Request) -> web.Response:
return web.json_response("dark")
diff --git a/tests/browser/workspace-inspect.spec.mjs b/tests/browser/workspace-inspect.spec.mjs
new file mode 100644
index 0000000..98e6c3d
--- /dev/null
+++ b/tests/browser/workspace-inspect.spec.mjs
@@ -0,0 +1,518 @@
+// The native "Inspect a file" page (#249 local Inspect, PR L2 Task 6): one chosen or dropped local file previews
+// inline, and the heading's one action opens the shared viewer with the full inspector on a record read entirely in
+// this browser. The concept is docs/concepts/workspace/app.js renderMetadata (740–748) and its selection (1715–1729);
+// the production differences are D60–D66 in TESTING.md.
+import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
+import { test, expect } from './fixtures.mjs';
+import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised, undecodableMp4, POSITIVE_LINES } from './local-inspect-files.mjs';
+
+const workspace = '/prompt_manager/workspace';
+const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
+const ACTION = 'Open file metadata';
+const COPY = {
+ intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
+ note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
+ chooser: 'Choose an image or video for a local preview',
+ 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.',
+};
+
+test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
+
+test.beforeEach(async ({ page, request, browserName }) => {
+ await startCoverage(page, browserName);
+ await expect(await request.post('/__test__/reset')).toBeOK();
+ page.inspectErrors = [];
+ page.on('pageerror', error => page.inspectErrors.push(error.message));
+});
+
+test.afterEach(async ({ page, browserName }) => {
+ await page.unrouteAll({ behavior: 'ignoreErrors' });
+ await saveCoverage(page, browserName);
+ expect(page.inspectErrors).toEqual([]);
+});
+
+const main = page => page.locator('#workspace-main');
+const action = page => main(page).locator('.heading-actions').getByRole('button', { name: ACTION, exact: true });
+const chooser = page => page.locator('#local-file');
+const preview = page => page.locator('#local-preview');
+const status = page => preview(page).locator('p.small');
+const viewer = page => page.locator('dialog#viewer-dialog');
+const rail = page => viewer(page).locator('#viewer-details');
+
+async function openInspect(page) {
+ await page.goto(`${workspace}#/inspect`);
+ await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
+ await expect(chooser(page)).toBeAttached();
+}
+
+/** Selects through the real chooser, then waits for the page to classify the file. */
+async function choose(page, file) {
+ await chooser(page).setInputFiles(file);
+}
+
+async function openViewer(page) {
+ await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
+ await action(page).click();
+ await expect(viewer(page)).toBeVisible();
+ await expect(rail(page).locator('.panel-heading')).toBeVisible();
+}
+
+/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
+function drop(page, selector, { files = [], strings = [] } = {}) {
+ return page.evaluate(async ({ selector, files, strings }) => {
+ const transfer = new DataTransfer();
+ for (const file of files) transfer.items.add(new File([new Uint8Array(file.bytes)], file.name, { type: file.mimeType }));
+ for (const [type, value] of strings) transfer.items.add(value, type);
+ const target = document.querySelector(selector);
+ const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: transfer });
+ const dropped = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer });
+ target.dispatchEvent(over);
+ target.dispatchEvent(dropped);
+ return { over: over.defaultPrevented, drop: dropped.defaultPrevented };
+ }, { selector, files: files.map(file => ({ name: file.name, mimeType: file.mimeType, bytes: [...file.buffer] })), strings });
+}
+
+/** The rail's comparable content: prompts, the six facts, the raw records and the two downloads (D66 aside). */
+const railContent = locator => locator.evaluate(node => {
+ const raw = [...node.querySelectorAll('details')].find(item => item.querySelector('summary')?.textContent === 'Workflow & raw metadata');
+ const facts = node.querySelector(':scope .meta-grid:not(details .meta-grid)');
+ return {
+ positive: node.querySelector('.prompt-section.positive p.prompt-text')?.textContent ?? null,
+ negative: node.querySelector('.prompt-section.negative p.prompt-text')?.textContent ?? null,
+ facts: facts ? [...facts.querySelectorAll(':scope > div')].map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]) : null,
+ raw: raw ? [...raw.querySelectorAll('p.small, pre')].map(item => item.textContent) : null,
+ downloads: raw ? [...raw.querySelectorAll('button')].map(item => [item.textContent, item.getAttribute('aria-disabled')]) : null,
+ };
+});
+
+/** Counts storage writes and object URLs created and revoked for Files (download blobs are not the session's). */
+async function instrument(page) {
+ await page.addInitScript(() => {
+ window.__inspect = { writes: [], created: [], revoked: [], files: new Set() };
+ for (const name of ['setItem', 'removeItem', 'clear']) {
+ const original = Storage.prototype[name];
+ Storage.prototype[name] = function (...args) { window.__inspect.writes.push([name, args[0]]); return original.apply(this, args); };
+ }
+ const open = indexedDB.open.bind(indexedDB);
+ indexedDB.open = (...args) => { window.__inspect.writes.push(['indexedDB.open', args[0]]); return open(...args); };
+ const create = URL.createObjectURL, revoke = URL.revokeObjectURL;
+ URL.createObjectURL = object => {
+ const url = create(object);
+ if (object instanceof File) { window.__inspect.created.push(url); window.__inspect.files.add(url); }
+ return url;
+ };
+ URL.revokeObjectURL = url => { if (window.__inspect.files.has(url)) window.__inspect.revoked.push(url); return revoke(url); };
+ });
+}
+const urls = page => page.evaluate(() => ({ created: [...window.__inspect.created], revoked: [...window.__inspect.revoked], writes: [...window.__inspect.writes] }));
+
+test('chooser opens the shared full inspector without a second read', async ({ page, request }) => {
+ // The Images viewer's rail for the same bytes, served from the output folder.
+ await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK();
+ await page.goto(`${workspace}#/images`);
+ await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click();
+ await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible();
+ const served = await railContent(rail(page));
+ await page.keyboard.press('Escape');
+ await expect(viewer(page)).toHaveCount(0);
+
+ await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
+ await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
+ await expect(main(page).locator('.heading p.muted')).toHaveText('A standalone file uses the same image viewer and prompt actions.');
+ // The concept DOM: one surface, the drop area with its icon, label, input and note, then the preview slot.
+ const surface = main(page).locator('section.surface');
+ await expect(surface).toHaveCount(1);
+ await expect(surface.locator('h2')).toHaveText('Inspect without importing');
+ await expect(surface.locator(':scope > p')).toHaveText(COPY.intro);
+ const area = surface.locator('div.file-drop.spaced-20');
+ await expect(area.locator(':scope > svg')).toHaveCount(1);
+ await expect(area.locator('label[for="local-file"]')).toHaveText(COPY.chooser);
+ await expect(chooser(page)).toHaveAttribute('type', 'file');
+ await expect(chooser(page)).toHaveAttribute('accept', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv');
+ await expect(chooser(page)).not.toHaveAttribute('multiple', /.*/);
+ await expect(area.locator('span.small#local-file-note')).toHaveText(COPY.note);
+ await expect(surface.locator(':scope > #local-preview')).toBeEmpty();
+ // D60: the one heading action is described by the note until a file is chosen.
+ await expect(main(page).locator('.heading-actions button')).toHaveCount(1);
+ await expect(action(page)).toHaveClass(/\bca-primary\b/);
+ await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
+ await expect(action(page)).toHaveAccessibleDescription(COPY.note);
+ await action(page).dispatchEvent('click');
+ await expect(viewer(page)).toHaveCount(0);
+
+ await choose(page, parityPng('f02.png'));
+ const image = preview(page).locator('img.local-file-preview');
+ await expect(image).toHaveAttribute('alt', 'Local file preview');
+ await expect(image).toHaveJSProperty('complete', true);
+ expect(await image.evaluate(node => node.naturalWidth)).toBe(64);
+ expect(await image.getAttribute('src')).toMatch(/^blob:/);
+ const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
+ expect(box.height).toBeLessThanOrEqual(400);
+ expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
+ await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
+ await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
+ await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
+
+ await openViewer(page);
+ await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
+ const position = viewer(page).locator('.media-position');
+ await expect(position).toHaveText('1 of 1 loaded: f02.png');
+ for (const name of ['Previous media', 'Next media', 'Play slideshow']) {
+ await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'true');
+ }
+ await expect(viewer(page).locator('.viewer-canvas img')).toBeVisible();
+ for (const name of ['Fit image', 'Rotate image 90 degrees', 'Flip image horizontally']) {
+ await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'false');
+ }
+ const canvas = viewer(page).locator('.viewer-canvas img');
+ await viewer(page).getByRole('button', { name: 'Rotate image 90 degrees', exact: true }).click();
+ await expect(canvas).toHaveAttribute('style', /rotate\(90deg\)/);
+ await viewer(page).getByRole('button', { name: 'Flip image horizontally', exact: true }).click();
+ await expect(canvas).toHaveAttribute('style', /scaleX\(-1\)/);
+ await viewer(page).getByRole('button', { name: 'Fit image', exact: true }).click();
+ await expect(canvas).not.toHaveAttribute('style', /rotate\(90deg\)/);
+
+ // The same rail as Images for the same bytes, except the D66 provenance.
+ const local = await railContent(rail(page));
+ expect(local).toEqual(served);
+ expect(local.positive).toBe('a red fox in snow, detailed fur');
+ expect(local.negative).toBe('blurry, lowres');
+ expect(local.facts.map(([label]) => label)).toEqual(['Seed', 'Steps', 'CFG', 'Sampler', 'Scheduler', 'Model']);
+ expect(local.downloads).toEqual([['Download metadata JSON', null], ['Download workflow JSON', null]]);
+ const provenance = rail(page).locator('details').filter({ has: page.locator('summary', { hasText: 'File & provenance' }) });
+ await expect(provenance).toContainText('Selected local file · read in this browser');
+ await expect(provenance).toContainText('Local file; no saved library association was checked.');
+ await expect(provenance.locator('dt', { hasText: /^Path$/ })).toHaveCount(0);
+ await expect(rail(page).getByRole('button', { name: 'Show in folder', exact: true })).toHaveCount(0);
+ await expect(rail(page).getByRole('button', { name: /Prompt history/ })).toHaveCount(0);
+ await expect(rail(page).locator('.chip-row button.chip')).toHaveCount(0);
+ const original = viewer(page).getByRole('link', { name: 'Download original', exact: true });
+ await expect(original).toHaveAttribute('href', /^blob:/);
+ await expect(original).toHaveAttribute('download', 'f02.png');
+
+ await page.keyboard.press('Escape');
+ await expect(viewer(page)).toHaveCount(0);
+ await expect(action(page)).toBeFocused();
+ // Opening the viewer again shows the same record without reading the File a second time.
+ await page.evaluate(() => {
+ window.__slices = 0;
+ const slice = Blob.prototype.slice;
+ Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
+ });
+ await openViewer(page);
+ expect(await railContent(rail(page))).toEqual(served);
+ expect(await page.evaluate(() => window.__slices)).toBe(0);
+});
+
+test('Import → Inspect a local image lands here', async ({ page }) => {
+ await page.goto(`${workspace}#/images`);
+ await page.getByRole('button', { name: 'Import', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
+ await dialog.getByRole('button', { name: 'Open inspector', exact: true }).click();
+ await expect(page).toHaveURL(/#\/inspect$/);
+ await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
+ await expect(chooser(page)).toBeAttached();
+ await expect(main(page).locator('.handoff')).toHaveCount(0);
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+});
+
+test('drop accepts exactly one File and never navigates', async ({ page }) => {
+ await openInspect(page);
+ // A drop that misses the area is cancelled, so the browser never opens the file in place of the page.
+ expect(await drop(page, 'body', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
+ await expect(preview(page)).toBeEmpty();
+ expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
+ const first = await preview(page).locator('img').getAttribute('src');
+ // Two files, a link and markup keep the selection and say why.
+ await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
+ await expect(status(page)).toHaveText(COPY.multiple);
+ await expect(status(page)).toHaveAttribute('role', 'alert');
+ await expect(preview(page).locator('img')).toHaveAttribute('src', first);
+ for (const strings of [[['text/uri-list', 'https://example.invalid/a.png']], [['text/html', '
']]]) {
+ await drop(page, '.file-drop', { strings });
+ await expect(status(page)).toHaveText(COPY.unsupported);
+ await expect(preview(page).locator('img')).toHaveAttribute('src', first);
+ }
+ await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
+ await openViewer(page);
+ await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
+ await expect(page).toHaveURL(/#\/inspect$/);
+});
+
+test('cancel and same-file reselect', async ({ page }) => {
+ await instrument(page);
+ await openInspect(page);
+ await choose(page, parityPng('f02.png'));
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ const first = await preview(page).locator('img').getAttribute('src');
+ // The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
+ expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
+ await chooser(page).setInputFiles([]);
+ await expect(preview(page).locator('img')).toHaveAttribute('src', first);
+ await expect(status(page)).toHaveCount(0);
+ await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
+ // The same file again is a new selection with its own URL; the old one is released. Opening the picker clears
+ // the chooser, so the browser reports the same file as a change.
+ await chooser(page).dispatchEvent('click');
+ await expect(chooser(page)).toHaveJSProperty('value', '');
+ await choose(page, parityPng('f02.png'));
+ await expect(preview(page).locator('img')).not.toHaveAttribute('src', first);
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ const state = await urls(page);
+ expect(state.created).toHaveLength(2);
+ expect(state.revoked).toEqual([first]);
+ await openViewer(page);
+ await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
+});
+
+test('preview does not imply metadata support', async ({ page }) => {
+ await instrument(page);
+ await openInspect(page);
+ // JPEG and WebP preview and report no supported metadata, exactly as Images reports them.
+ for (const [file, format] of [[localFixture('tiny.jpg', 'image/jpeg'), 'JPEG'], [localFixture('tiny.webp', 'image/webp'), 'WEBP']]) {
+ await choose(page, file);
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ await openViewer(page);
+ await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
+ await expect(rail(page).locator('details').first()).toContainText(format);
+ await page.keyboard.press('Escape');
+ await expect(viewer(page)).toHaveCount(0);
+ }
+ // A PNG whose text chunk fails its CRC: unreadable metadata, the original still downloads.
+ await choose(page, corruptPng());
+ await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
+ await openViewer(page);
+ await expect(rail(page)).toContainText('Embedded metadata could not be read.');
+ const [download] = await Promise.all([page.waitForEvent('download'), viewer(page).getByRole('link', { name: 'Download original', exact: true }).click()]);
+ expect(download.suggestedFilename()).toBe('corrupt.png');
+ await page.keyboard.press('Escape');
+ await expect(viewer(page)).toHaveCount(0);
+ // Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
+ const before = (await urls(page)).created.length;
+ for (const file of disguised()) {
+ await choose(page, file);
+ await expect(status(page)).toHaveText(COPY.unsupported);
+ await expect(status(page)).toHaveAttribute('role', 'alert');
+ await expect(preview(page).locator('img, video')).toHaveCount(0);
+ await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
+ }
+ expect((await urls(page)).created).toHaveLength(before);
+});
+
+test('local video plays inline and in the viewer with real tags', async ({ page, browserName }) => {
+ await openInspect(page);
+ await choose(page, localFixture('core-tags.webm', 'video/webm'));
+ const video = preview(page).locator('video.local-file-preview');
+ await expect(video).toBeVisible();
+ expect(await video.evaluate(node => ({ controls: node.controls, playsInline: node.playsInline, preload: node.preload, muted: node.muted, autoplay: node.autoplay, paused: node.paused })))
+ .toEqual({ controls: true, playsInline: true, preload: 'metadata', muted: true, autoplay: false, paused: true });
+ // WebKit may not decode VP8: whichever state it reports, metadata is still read.
+ await expect.poll(() => video.evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true);
+ await openViewer(page);
+ await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
+ await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
+ await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
+ await page.keyboard.press('Escape');
+ await choose(page, tinyWebm());
+ await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
+ await openViewer(page);
+ await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
+ if (browserName !== 'webkit') await expect.poll(() => viewer(page).locator('#viewer-stage video').evaluate(node => node.readyState >= 1)).toBe(true);
+});
+
+test('undecodable video keeps container metadata', async ({ page }) => {
+ await openInspect(page);
+ // A real MP4 whose sample entry names a codec no engine decodes; its container tags are intact.
+ await choose(page, undecodableMp4());
+ await expect(status(page)).toHaveText(COPY.previewFailed);
+ await expect(status(page)).toHaveAttribute('role', 'alert');
+ await expect(preview(page).locator('video')).toHaveCount(0);
+ await openViewer(page);
+ await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
+ await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
+});
+
+test('URLs and media end with their owning session', async ({ page }) => {
+ await instrument(page);
+ await openInspect(page);
+ // Every release pauses the media and drops its src before the URL is revoked.
+ await page.evaluate(() => {
+ window.__released = [];
+ const revoke = URL.revokeObjectURL;
+ URL.revokeObjectURL = url => {
+ const live = [...document.querySelectorAll('#local-preview img, #local-preview video')].filter(node => node.getAttribute('src') === url);
+ window.__released.push({ url, live: live.length });
+ return revoke(url);
+ };
+ const load = HTMLMediaElement.prototype.load;
+ window.__loads = 0;
+ HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) window.__loads++; return load.call(this); };
+ });
+ await choose(page, localFixture('core-tags.webm', 'video/webm'));
+ await expect(preview(page).locator('video')).toBeVisible();
+ // Replacement.
+ await choose(page, parityPng('f02.png'));
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ let state = await urls(page);
+ expect(state.revoked).toEqual([state.created[0]]);
+ expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
+ // Route change.
+ await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
+ await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
+ state = await urls(page);
+ expect(state.revoked).toEqual(state.created);
+ // Five mount/unmount cycles with a selection and an opened viewer leave no live URL and no dialog.
+ for (let cycle = 0; cycle < 5; cycle++) {
+ await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
+ await choose(page, parityPng('f02.png', `cycle-${cycle}.png`));
+ await openViewer(page);
+ // The modal viewer makes the sidebar inert, so the route changes underneath it.
+ await page.evaluate(() => { location.hash = '#/overview'; });
+ await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
+ }
+ state = await urls(page);
+ expect(state.created).toHaveLength(7);
+ expect([...state.revoked].sort()).toEqual([...state.created].sort());
+ expect(new Set(state.revoked).size).toBe(state.revoked.length);
+ expect((await page.evaluate(() => window.__released)).every(entry => entry.live === 0)).toBe(true);
+ await expect(page.locator('dialog')).toHaveCount(0);
+ // Page leave releases the selection once.
+ await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
+ await choose(page, parityPng('f02.png', 'leave.png'));
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })));
+ state = await urls(page);
+ expect(state.created).toHaveLength(8);
+ expect(state.revoked.filter(url => url === state.created[7])).toHaveLength(1);
+});
+
+test('replacing a file mid-read never shows or sends the old record', async ({ page, context }) => {
+ const host = await bridgeHost(context);
+ try {
+ await openInspect(page);
+ // Hold every read of held.png after its classification prefix.
+ await page.evaluate(() => {
+ let release;
+ window.__gate = new Promise(resolve => { release = resolve; });
+ window.__releaseGate = release;
+ window.__held = 0;
+ const slice = Blob.prototype.slice;
+ Blob.prototype.slice = function (...args) {
+ const part = slice.apply(this, args);
+ if (this.name !== 'held.png' || (args[0] ?? 0) === 0 && (args[1] ?? this.size) <= 32) return part;
+ const read = part.arrayBuffer.bind(part);
+ part.arrayBuffer = async () => { window.__held++; await window.__gate; return read(); };
+ return part;
+ };
+ });
+ await choose(page, parityPng('f03.png', 'held.png'));
+ await expect.poll(() => page.evaluate(() => window.__held)).toBeGreaterThan(0);
+ await choose(page, sendablePng('fresh.png'));
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ await page.evaluate(() => window.__releaseGate());
+ await openViewer(page);
+ await expect(viewer(page).locator('#viewer-title')).toHaveText('fresh.png');
+ await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
+ await sendPair(page);
+ expect(await host.evaluate(() => window.inspectApplied)).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
+ } finally {
+ await host.evaluate(() => window.inspectHost.dispose());
+ await host.close();
+ }
+});
+
+/** A second tab acting as a ComfyUI host with one Prompt Manager pair destination. */
+async function bridgeHost(context) {
+ const host = await context.newPage();
+ await host.goto(`${workspace}#/overview`);
+ await host.evaluate(async () => {
+ const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
+ const target = { id: 'pair-1', label: 'Inspect destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
+ window.inspectApplied = [];
+ window.inspectPreviews = [];
+ window.inspectHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Inspect ComfyUI', adapter: {
+ listTargets: () => [target],
+ preview: (_id, roles) => {
+ window.inspectPreviews.push(roles);
+ return { preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: `Previous ${role}`, after })),
+ composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: 'Previous positive', after: roles.positive ?? 'Previous positive' } }, snapshot: roles };
+ },
+ apply: roles => { window.inspectApplied.push(roles); return { targetId: target.id, values: roles }; },
+ } });
+ });
+ return host;
+}
+
+async function sendPair(page) {
+ await rail(page).getByRole('button', { name: /^Send prompt pair to ComfyUI: / }).click();
+ const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
+ await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Inspect ComfyUI' });
+ await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Inspect destination' });
+ await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
+ await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();
+ await expect(sender).toContainText('Sent to ComfyUI.');
+ await sender.getByRole('button', { name: 'Close', exact: true }).click();
+}
+
+test('local roles send exactly', async ({ page, context }) => {
+ const host = await bridgeHost(context);
+ try {
+ await openInspect(page);
+ await choose(page, sendablePng());
+ await openViewer(page);
+ await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
+ await expect(rail(page).locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
+ await sendPair(page);
+ const applied = await host.evaluate(() => window.inspectApplied);
+ expect(applied).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
+ const payload = JSON.stringify([applied, await host.evaluate(() => window.inspectPreviews)]);
+ expect(payload).not.toContain('blob:');
+ expect(payload).not.toContain('send-me');
+ // The viewer is still open, focus came back to the send action.
+ await expect(viewer(page)).toBeVisible();
+ } finally {
+ await host.evaluate(() => window.inspectHost.dispose());
+ await host.close();
+ }
+});
+
+test('local inspection makes no content request or storage write', async ({ page }) => {
+ await instrument(page);
+ await openInspect(page);
+ // The shell's own reads (counts, Activity) settle before the baseline.
+ await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached();
+ const baseline = (await urls(page)).writes.length;
+ const requests = [];
+ page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() }));
+ page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null }));
+ for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) {
+ await choose(page, file);
+ await openViewer(page);
+ await rail(page).locator('summary', { hasText: 'Workflow & raw metadata' }).click();
+ await page.keyboard.press('Escape');
+ await expect(viewer(page)).toHaveCount(0);
+ }
+ const network = requests.filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
+ expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]);
+ expect(network.filter(entry => entry.method !== 'GET')).toEqual([]);
+ expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]);
+ expect((await urls(page)).writes.slice(baseline)).toEqual([]);
+});
+
+test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await openInspect(page);
+ await choose(page, parityPng('f02.png'));
+ await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
+ for (const zoom of ['1', '2']) {
+ await page.evaluate(value => { document.documentElement.style.zoom = value; }, zoom);
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+ const area = await page.locator('.file-drop').evaluate(node => node.getBoundingClientRect().right <= document.documentElement.clientWidth + 0.5);
+ expect(area).toBe(true);
+ }
+});
diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs
index c8ed505..fff42a6 100644
--- a/tests/browser/workspace.spec.mjs
+++ b/tests/browser/workspace.spec.mjs
@@ -16,7 +16,8 @@ const routes = [
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
['library', 'Prompt library', null],
['tags', 'Tags & categories', null],
- ['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
+ // #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
+ ['inspect', 'Inspect a file', null],
['system', 'System', '/prompt_manager/admin'],
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
['favorites', 'Favorites', null],
@@ -80,7 +81,8 @@ test('all shell destinations deep-link, reload and expose working existing appli
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
- if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
+ if (route === 'tags' || route === 'inspect') await expect(main.locator('.handoff')).toHaveCount(0);
+ if (route === 'inspect') await expect(main.locator('#local-file')).toBeAttached();
if (legacy) {
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
await expect(await request.get(legacy.split('#')[0])).toBeOK();
@@ -93,6 +95,10 @@ test('all shell destinations deep-link, reload and expose working existing appli
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
await expect(await request.get('/prompt_manager/admin')).toBeOK();
+ // The legacy metadata page stays reachable from Overview beside the native Inspect page.
+ const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true });
+ await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html');
+ await expect(await request.get('/prompt_manager/gallery.html')).toBeOK();
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
diff --git a/tests/js/workspace-local-session.test.js b/tests/js/workspace-local-session.test.js
index 9638819..0bc5cb6 100644
--- a/tests/js/workspace-local-session.test.js
+++ b/tests/js/workspace-local-session.test.js
@@ -87,7 +87,7 @@ const recorded = (controller) => until(controller, (state) => state.record !== n
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"]);
+ assert.deepEqual(Object.keys(m.LOCAL_SESSION_MESSAGES).sort(), ["containerOnly", "directory", "multiple", "previewFailed", "unreadable", "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");
@@ -189,10 +189,37 @@ test("unclassified input creates no object URL", async () => {
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.
+ // A file that cannot be read after choosing (removed or denied) is not an unsupported type: when the browser
+ // declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling).
const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
assert.equal(value.select([gone]), "accepted");
await settle();
+ const unreadable = value.controller.state;
+ assert.ok(unreadable.record, "the unreadable record is published");
+ assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]);
+ assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read.");
+ assert.equal(value.entry.filename, "gone.png");
+ assert.equal(unreadable.record.metadata.status, "unreadable");
+ assert.equal(unreadable.record.download, null);
+ assert.equal(spies.created.length, 0);
+ // A prefix read that fails for another reason than the browser's read failure is not claimed as unreadable.
+ const broken = pngFile("broken.png", "fox", { gate: () => Promise.reject(new TypeError("not a read failure")) });
+ assert.equal(value.select([broken]), "accepted");
+ await settle();
+ assert.deepEqual([value.state.phase, value.state.message, value.entry], ["unsupported", m.LOCAL_SESSION_MESSAGES.unsupported, null]);
+ // A read failure for a selection already replaced publishes nothing for it.
+ const slow = deferred();
+ const replaced = pngFile("replaced.png", "fox", { gate: () => slow.promise });
+ value.select([replaced]);
+ await settle();
+ value.select([new RecordingFile([new Uint8Array(4)], "next.txt")]);
+ slow.reject(new DOMException("The file was removed", "NotReadableError"));
+ await settle();
+ assert.deepEqual([value.state.phase, value.entry], ["unsupported", null]);
+ // An unreadable file the browser declares no supported type for stays unsupported.
+ const unknown = new RecordingFile([new Uint8Array(8)], "gone.bin", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
+ assert.equal(value.select([unknown]), "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.
diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js
index 5706dea..a02129a 100644
--- a/tests/js/workspace-state.test.js
+++ b/tests/js/workspace-state.test.js
@@ -23,8 +23,21 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
assert.equal(routeTitle(route), route.label);
}
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
- for (const id of ['images', 'library', 'tags', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
- for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
+ for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
+ for (const id of ['system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
+});
+
+test('inspect is the native Inspect a file page with the concept description', async () => {
+ const { resolveRoute, routeTitle, isHandoff } = await source;
+ const inspect = resolveRoute('#/inspect');
+ assert.equal(inspect.label, 'Inspect a file');
+ assert.equal(routeTitle(inspect), 'Inspect a file');
+ // The concept's heading description (app.js renderMetadata 743).
+ assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.');
+ assert.equal(isHandoff(inspect), false);
+ // Overview keeps its card for the legacy metadata page, which stays working.
+ assert.equal(inspect.href, '/prompt_manager/gallery.html');
+ assert.equal(inspect.action, 'Open existing metadata inspector');
});
test('a job of the Activity page is its own deep link', async () => {
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index f83de3f..97ac1b5 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -162,10 +162,10 @@
"statements": 97
},
"workspace/activity-scan.mjs": {
- "lines": 95,
- "branches": 64,
+ "lines": 96,
+ "branches": 73,
"functions": 98,
- "statements": 95
+ "statements": 96
},
"workspace/activity-store.mjs": {
"lines": 98,
@@ -257,6 +257,12 @@
"functions": 98,
"statements": 98
},
+ "workspace/inspect.mjs": {
+ "lines": 98,
+ "branches": 90,
+ "functions": 88,
+ "statements": 98
+ },
"workspace/inspection-state.mjs": {
"lines": 98,
"branches": 94,
@@ -295,7 +301,7 @@
},
"workspace/local-inspection.mjs": {
"lines": 98,
- "branches": 90,
+ "branches": 91,
"functions": 98,
"statements": 98
},
@@ -308,7 +314,7 @@
"workspace/local-session.mjs": {
"lines": 98,
"branches": 92,
- "functions": 83,
+ "functions": 98,
"statements": 98
},
"workspace/local-video.mjs": {
diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs
index 967062c..5e29f04 100644
--- a/web/js/workspace/index.mjs
+++ b/web/js/workspace/index.mjs
@@ -14,6 +14,8 @@ import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash
import { createActivityStore } from './activity-store.mjs';
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
import { mountActivityJobView } from './activity-job-view.mjs';
+import { mountInspect } from './inspect.mjs';
+import { createPromptSender } from './prompt-sender.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -235,6 +237,12 @@ function mountWorkspace() {
else mountActivityPage(main, { store: activity, scope: view });
return;
}
+ if (route.id === 'inspect') {
+ // The concept's Inspect a file (app.js renderMetadata): a local file, read only in this browser.
+ description.textContent = route.description;
+ mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) });
+ return;
+ }
if (isHandoff(route)) {
description.textContent = route.description;
const panel = element('section', undefined, 'surface handoff');
diff --git a/web/js/workspace/inspect.mjs b/web/js/workspace/inspect.mjs
new file mode 100644
index 0000000..1bcc059
--- /dev/null
+++ b/web/js/workspace/inspect.mjs
@@ -0,0 +1,171 @@
+// The concept's "Inspect a file" page (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729):
+// one local file, chosen or dropped, previews inline; the heading's one action opens the shared viewer with the full
+// inspector on a record read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its
+// blob: URL and its record belong to one local session (local-session.mjs) that ends with this page.
+import { createButton, createScope } from '../shared/toolkit/core.js';
+import { pageHeading } from './heading.mjs';
+import { createWorkspaceIcon } from './icons.mjs';
+import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs';
+import { localFileAdapter } from './local-inspection.mjs';
+import { createMediaViewer } from './media-viewer.mjs';
+import { showWorkspaceToast } from './toast.mjs';
+
+/** D60/D61 copy (maintainer ruling LI-3). */
+export const INSPECT_COPY = Object.freeze({
+ action: 'Open file metadata',
+ heading: 'Inspect without importing',
+ intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
+ chooser: 'Choose an image or video for a local preview',
+ note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
+ toast: 'Local preview only. No file uploaded; metadata is available above.',
+});
+// D62: the concept's image list plus the videos the local readers support (AVI/WMV preview only).
+export const INSPECT_ACCEPT = 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv';
+const REJECTIONS = Object.freeze({
+ 'rejected-multiple': LOCAL_SESSION_MESSAGES.multiple,
+ 'rejected-directory': LOCAL_SESSION_MESSAGES.directory,
+ 'rejected-empty': LOCAL_SESSION_MESSAGES.unsupported,
+});
+// D63: the status line is an alert for anything the person must act on; AVI/WMV's note is plain status.
+const ALERTS = new Set([LOCAL_SESSION_MESSAGES.multiple, LOCAL_SESSION_MESSAGES.directory, LOCAL_SESSION_MESSAGES.unsupported,
+ LOCAL_SESSION_MESSAGES.previewFailed, LOCAL_SESSION_MESSAGES.unreadable]);
+
+function element(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+/** Stops a preview from holding its blob: URL: media is paused and unloaded before the session revokes the URL. */
+function releaseMedia(node) {
+ if (!node) return;
+ if (node instanceof HTMLMediaElement) { node.pause(); node.removeAttribute('src'); node.load(); }
+ else node.removeAttribute('src');
+ node.remove();
+}
+
+/**
+ * Mounts the page into `main` below the heading the shell rendered. `sender` is the shared prompt sender; `api` is
+ * only handed to the viewer (the local adapter never calls it). Returns `{dispose}`; the page also ends with
+ * `parentScope`.
+ */
+export function mountInspect(main, { parentScope, api, sender }) {
+ const scope = createScope();
+ const dispose = () => scope.dispose();
+ if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; }
+ parentScope.signal.addEventListener('abort', dispose, { once: true });
+ scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
+
+ // The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview.
+ const surface = element('section', 'surface');
+ const area = element('div', 'file-drop spaced-20');
+ const label = element('label', undefined, INSPECT_COPY.chooser);
+ label.htmlFor = 'local-file';
+ const input = element('input');
+ input.id = 'local-file'; input.type = 'file'; input.accept = INSPECT_ACCEPT;
+ const note = element('span', 'small', INSPECT_COPY.note);
+ note.id = 'local-file-note';
+ area.append(createWorkspaceIcon('upload'), label, input, note);
+ const preview = element('div');
+ preview.id = 'local-preview';
+ surface.append(element('h2', undefined, INSPECT_COPY.heading), element('p', undefined, INSPECT_COPY.intro), area, preview);
+ main.append(surface);
+
+ // /* D60 */ the concept's one primary heading action, unavailable until a classified file is chosen.
+ const open = createButton(INSPECT_COPY.action, 'ca-primary');
+ pageHeading(main)?.actions.append(open);
+ scope.cleanup(() => open.remove());
+
+ // The session ends after the viewer has closed and the inline media is gone, so no element holds its URL then.
+ const sessionScope = createScope();
+ const session = createLocalSession({ parentScope: sessionScope });
+ const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
+ adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } });
+
+ let media = null, mediaUrl = null, statusLine = null, announced = null;
+ function showStatus(message) {
+ statusLine?.remove(); statusLine = null;
+ if (!message) return;
+ // /* D63 */ one status line for the states the mock never shows.
+ statusLine = element('p', 'small spaced-16', message);
+ statusLine.setAttribute('role', ALERTS.has(message) ? 'alert' : 'status');
+ preview.append(statusLine);
+ }
+ function setAvailable(available) {
+ open.setAttribute('aria-disabled', String(!available));
+ if (available) open.removeAttribute('aria-describedby');
+ else open.setAttribute('aria-describedby', note.id);
+ }
+ function showMedia(state) {
+ const url = state.preview?.url ?? null;
+ if (url === mediaUrl) return;
+ releaseMedia(media); media = null; mediaUrl = url;
+ if (!url) return;
+ const video = state.preview.media === 'video';
+ const node = element(video ? 'video' : 'img', 'local-file-preview');
+ if (video) {
+ // /* D62 */ native controls, no inline autoplay.
+ node.controls = true; node.playsInline = true; node.preload = 'metadata'; node.muted = true;
+ } else {
+ node.alt = 'Local file preview';
+ }
+ // /* D65 */ the URL is not revoked on load: the viewer and Download original use it.
+ node.addEventListener('error', () => {
+ if (media !== node) return;
+ releaseMedia(node); media = null;
+ showStatus(LOCAL_SESSION_MESSAGES.previewFailed);
+ }, { once: true, signal: scope.signal });
+ node.src = url;
+ media = node;
+ preview.prepend(node);
+ }
+ scope.cleanup(session.subscribe(state => {
+ showMedia(state);
+ showStatus(state.message);
+ const entry = session.entry;
+ setAvailable(state.phase === 'ready' && !!entry);
+ if (state.phase === 'ready' && entry && announced !== entry.key) {
+ announced = entry.key;
+ showWorkspaceToast(INSPECT_COPY.toast);
+ }
+ }));
+ // A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the
+ // viewer (on abort), drops the media, then ends the session.
+ scope.cleanup(() => viewer.dispose());
+ scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
+ scope.cleanup(() => sessionScope.dispose());
+
+ function choose(source) {
+ const outcome = session.select(source);
+ if (REJECTIONS[outcome]) showStatus(REJECTIONS[outcome]);
+ return outcome;
+ }
+ // The chooser keeps showing the chosen name, as in the concept; it is cleared as the picker opens, so choosing
+ // the same file again is a new selection.
+ scope.listen(input, 'click', () => { input.value = ''; });
+ scope.listen(input, 'change', () => {
+ // A cancelled chooser carries no files: the page stays as it is.
+ choose(input.files?.length ? input.files : null);
+ });
+ // /* D64 */ drop on the area, with no visual change; only `dataTransfer.files` is read, never its strings.
+ scope.listen(area, 'dragover', event => {
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
+ });
+ scope.listen(area, 'drop', event => {
+ event.preventDefault();
+ if (document.querySelector('dialog[open]')) return;
+ const transfer = event.dataTransfer;
+ // A dropped file replaces the chosen one, so the chooser no longer names the old file.
+ if (choose(transfer ? { files: transfer.files, items: transfer.items } : null) === 'accepted') input.value = '';
+ });
+ // A drop that misses the area never navigates away from the page while it is mounted.
+ for (const type of ['dragover', 'drop']) scope.listen(window, type, event => event.preventDefault());
+ scope.listen(open, 'click', () => {
+ const entry = session.entry;
+ if (open.getAttribute('aria-disabled') === 'true' || !entry || session.state.phase !== 'ready') return;
+ viewer.open({ items: [entry], selectedKey: entry.key, trigger: open });
+ });
+ return { dispose };
+}
diff --git a/web/js/workspace/local-inspection.mjs b/web/js/workspace/local-inspection.mjs
index 11b2e40..3ccae32 100644
--- a/web/js/workspace/local-inspection.mjs
+++ b/web/js/workspace/local-inspection.mjs
@@ -168,7 +168,14 @@ export const localFileAdapter = Object.freeze({
inspectionPath(handle) { return `local-file:${owned(handle).key}`; },
async read(handle, { signal } = {}) {
const secret = owned(handle);
- const { result } = await readLocalFile(secret.file, { classification: secret.classification, signal });
+ 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;
+ }
return fitLocalPayload(buildLocalPayload({ read: result, file: { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }, classification: secret.classification }), MAX_RESPONSE_BYTES, { signal });
},
toRecord(result, handle, extractFacts) {
diff --git a/web/js/workspace/local-session.mjs b/web/js/workspace/local-session.mjs
index 0212577..9b7687a 100644
--- a/web/js/workspace/local-session.mjs
+++ b/web/js/workspace/local-session.mjs
@@ -15,8 +15,24 @@ export const LOCAL_SESSION_MESSAGES = Object.freeze({
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;
@@ -84,7 +100,19 @@ export function createLocalSession({
try {
await yieldControl(signal);
if (!live(token, signal)) return;
- const prefix = new Uint8Array(await file.slice(0, Math.min(PREFIX_BYTES, file.size)).arrayBuffer());
+ 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;
diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs
index b0a3a52..7957f4d 100644
--- a/web/js/workspace/state.mjs
+++ b/web/js/workspace/state.mjs
@@ -6,8 +6,9 @@ export const routes = [
// Native screen; `title` is the concept's breadcrumb/title, the href stays for Overview's card.
{ id: 'tags', label: 'Tags & categories', title: 'Tags', href: '/prompt_manager/admin#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
+ // Native screen (#249 local Inspect); the href stays for Overview's card to the legacy metadata page.
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
- description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
+ description: 'A standalone file uses the same image viewer and prompt actions.', action: 'Open existing metadata inspector' },
{ id: 'overview', label: 'Overview' },
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
{ id: 'favorites', label: 'Favorites' },
@@ -17,7 +18,7 @@ export const routes = [
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
];
-const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites', 'activity']);
+const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity']);
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
diff --git a/web/workspace.html b/web/workspace.html
index 03bd56f..e912c2c 100644
--- a/web/workspace.html
+++ b/web/workspace.html
@@ -12,6 +12,7 @@
+