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('x

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 @@ +