feat(inspect): local adapter seams in the shared inspector and viewer
The inspection loader calls adapter.read when an adapter reads by itself, so a file chosen in this browser never reaches the API. The full inspector shows the D66 local provenance (no Path row, no Show in folder, local association line), and the viewer uses adapter.mediaUrl exactly, so a blob: URL is never given the _pm_viewer cache-buster and Retry media reuses it. Rooted and recorded-history adapters keep their api.get paths and cache-busted originals.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// A minimal DOM for mounting the workspace viewer and inspector in Node: elements with
|
||||
// children, attributes, listeners (fire() dispatches), dialogs and media methods.
|
||||
export function mountedDom() {
|
||||
class Node {
|
||||
constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};}
|
||||
get textContent(){return this._text+this.children.map(c=>c.textContent).join('');}
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
// As in the DOM, a string argument becomes a text node.
|
||||
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
|
||||
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
getAttribute(key){return this.attributes[key]??null;}
|
||||
removeAttribute(key){delete this.attributes[key];}
|
||||
addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);}
|
||||
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
|
||||
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
|
||||
focus(){this.focused=true;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.fire('close');}
|
||||
contains(node){return node===this||this.children.some(c=>c.contains(node));}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
|
||||
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loaded=true;}
|
||||
play(){this.played=true;return Promise.resolve();}
|
||||
}
|
||||
const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))];
|
||||
const oldDocument=globalThis.document,oldWindow=globalThis.window;
|
||||
const container=new Node('main');container.root=true;
|
||||
const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag);
|
||||
doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true;
|
||||
const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true});
|
||||
globalThis.document=doc;globalThis.window=win;
|
||||
return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}};
|
||||
}
|
||||
@@ -86,3 +86,97 @@ test('Show in folder outcomes use the D38 toasts',async()=>{
|
||||
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
|
||||
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
|
||||
});
|
||||
|
||||
// PR L2 Task 4(c): a record read in this browser renders the D66 local rail.
|
||||
const {mountedDom} = await import('./helpers/mounted-dom.mjs');
|
||||
const {createScope} = await import('../../web/js/shared/toolkit/core.js');
|
||||
const {createRequire} = await import('node:module');
|
||||
const fs = await import('node:fs');
|
||||
const require = createRequire(import.meta.url);
|
||||
const PngMetadata = require('../../web/js/png-metadata.js'), ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
const {raw:pngRaw, ihdr, idat, iend, tEXt} = await import('./helpers/png-bytes.mjs');
|
||||
const LOCAL_PROMPT = '{"3":{"class_type":"KSampler","inputs":{"seed":7,"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0,"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0]}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"synthetic-v1.safetensors"}},"5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}},"6":{"class_type":"CLIPTextEncode","inputs":{"text":"a red fox in snow, detailed fur","clip":["4",1]}},"7":{"class_type":"CLIPTextEncode","inputs":{"text":"blurry, lowres","clip":["4",1]}}}';
|
||||
const LOCAL_URL = 'blob:http://localhost/11111111-2222-3333-4444-555555555555';
|
||||
const PARITY = new URL('../fixtures/inspection_parity/', import.meta.url);
|
||||
|
||||
async function localRecord(file) {
|
||||
const local = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const previous = {png:globalThis.PngMetadata, comfy:globalThis.ComfyMetadata};
|
||||
globalThis.PngMetadata = PngMetadata; globalThis.ComfyMetadata = ComfyMetadata;
|
||||
try {
|
||||
const classification = PngMetadata.classifyPrefix(new Uint8Array(await file.slice(0, 32).arrayBuffer()));
|
||||
const handle = local.createLocalFileHandle({file, url:LOCAL_URL, classification});
|
||||
return local.localFileAdapter.toRecord(await local.localFileAdapter.read(handle), handle, ComfyMetadata.extractGenerationFacts);
|
||||
} finally {
|
||||
if (previous.png === undefined) delete globalThis.PngMetadata; else globalThis.PngMetadata = previous.png;
|
||||
if (previous.comfy === undefined) delete globalThis.ComfyMetadata; else globalThis.ComfyMetadata = previous.comfy;
|
||||
}
|
||||
}
|
||||
const localPng = name => new File([pngRaw(ihdr(640, 480), idat(), tEXt('prompt', LOCAL_PROMPT), iend())], name, {type:'image/png', lastModified:1_700_000_000_000});
|
||||
function renderFull(record) {
|
||||
const f = mountedDom(), scope = createScope();
|
||||
const forbidden = new Proxy({}, {get(_, key) {assert.fail(`api.${String(key)} touched by a local record`);}});
|
||||
const view = mountFileInspector(f.container, {api:forbidden, sender:{open() {}}, parentScope:scope, variant:'full'});
|
||||
view.setState({loading:false, error:false, record});
|
||||
const texts = predicate => f.walk(f.container, predicate).map(node => node.textContent);
|
||||
return {f, texts, done() {view.dispose(); scope.dispose(); f.restore();}};
|
||||
}
|
||||
const {mountFileInspector} = await load();
|
||||
|
||||
test('local record renders local provenance', async () => {
|
||||
const record = await localRecord(localPng('Fox Render.png'));
|
||||
const {f, texts, done} = renderFull(record);
|
||||
try {
|
||||
const section = f.walk(f.container, n => n.tagName === 'details' && n.children[0]?.textContent === 'File & provenance')[0];
|
||||
assert.ok(section, 'the full variant has File & provenance');
|
||||
const lines = f.walk(section, n => n.tagName === 'p').map(n => n.textContent);
|
||||
assert.equal(lines[0], 'Selected local file · read in this browser');
|
||||
assert.ok(lines.includes('Local file; no saved library association was checked.'));
|
||||
assert.ok(!lines.some(line => /historical bytes|saved positive prompt|Saved association/.test(line)));
|
||||
assert.deepEqual(f.walk(section, n => n.tagName === 'dt').map(n => n.textContent), ['Source', 'Size', 'Dimensions', 'Format', 'Media type', 'Modified']);
|
||||
const values = Object.fromEntries(f.walk(section, n => n.tagName === 'dl')[0].children.map(row => [row.children[0].textContent, row.children[1].textContent]));
|
||||
assert.equal(values.Source, 'Selected local file');
|
||||
assert.equal(values.Dimensions, '640 × 480');
|
||||
assert.equal(values.Format, 'PNG');
|
||||
assert.equal(values['Media type'], 'image');
|
||||
assert.equal(texts(n => n.tagName === 'button' && /Show .* in folder|Show in folder/.test(n.textContent)).length, 0);
|
||||
assert.equal(f.walk(f.container, n => n.tagName === 'button' && n.textContent.includes('Prompt history')).length, 0);
|
||||
const original = f.walk(section, n => n.tagName === 'a' && n.textContent === 'Download original')[0];
|
||||
assert.ok(original, 'Download original is present');
|
||||
assert.equal(original.href, LOCAL_URL);
|
||||
assert.equal(original.download, 'Fox Render.png');
|
||||
// The heading names the file itself; no library title replaces it.
|
||||
assert.equal(f.walk(f.container, n => n.tagName === 'h2')[0].textContent, 'Fox Render.png');
|
||||
} finally { done(); }
|
||||
});
|
||||
|
||||
test('local record shows no image-tag chips', async () => {
|
||||
const record = await localRecord(localPng('a.png'));
|
||||
assert.equal(record.library.status, 'unavailable');
|
||||
const {f, done} = renderFull(record);
|
||||
try {
|
||||
// Only the format chip and the D21 evidence badge: no category, saved tag or image-tag chips.
|
||||
const chips = f.walk(f.container, n => /(^| )chip( |$)/.test(n.className ?? '')).map(n => n.textContent);
|
||||
assert.deepEqual(chips, ['PNG', 'Workflow inferred']);
|
||||
assert.equal(f.walk(f.container, n => n.tagName === 'button' && /^Tag/.test(n.textContent)).length, 0);
|
||||
} finally { done(); }
|
||||
});
|
||||
|
||||
test('legacy fill applies to local records', async () => {
|
||||
// f09: the server's inspection is ambiguous; the local record shows the legacy parse, labelled as inferred.
|
||||
const ambiguous = await localRecord(new File([fs.readFileSync(new URL('f09.png', PARITY))], 'f09.png'));
|
||||
const graphs = {prompt:ambiguous.metadata.prompt_graph.value, workflow:ambiguous.metadata.workflow_graph.status === 'parsed' ? ambiguous.metadata.workflow_graph.value : null};
|
||||
const legacy = ComfyMetadata.extractGenerationParams(graphs);
|
||||
assert.deepEqual(ambiguous.prompts.positive, {status:'resolved', text:legacy.positivePrompt, source:'graph-inferred'});
|
||||
assert.deepEqual(ambiguous.prompts.negative, {status:'resolved', text:legacy.negativePrompt, source:'graph-inferred'});
|
||||
assert.equal(ambiguous.prompts.positive.text, 'a red fox in snow, detailed fur');
|
||||
const view = renderFull(ambiguous);
|
||||
try {
|
||||
assert.deepEqual(view.texts(n => n.className === 'chip blue'), ['Workflow inferred']);
|
||||
assert.ok(view.texts(n => n.tagName === 'p').includes('Prompt evidence: workflow inferred'));
|
||||
} finally { view.done(); }
|
||||
// f01b: the legacy parse would read the positive text through ConditioningZeroOut; the guard shows the zeroed negative.
|
||||
const zeroed = await localRecord(new File([fs.readFileSync(new URL('f01b.png', PARITY))], 'f01b.png'));
|
||||
assert.deepEqual(zeroed.prompts.negative, {status:'resolved', text:'', source:'zeroed-conditioning'});
|
||||
assert.notEqual(zeroed.prompts.positive.text, '');
|
||||
});
|
||||
|
||||
@@ -702,3 +702,72 @@ test('a workflow the legacy parser throws on keeps the server result instead of
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
|
||||
assert.equal(record.prompts.negative.status, 'ambiguous');
|
||||
});
|
||||
|
||||
// PR L2 Task 4(a): an adapter that reads by itself (the local file adapter) never reaches the API.
|
||||
test('controller prefers adapter.read and never touches api', async () => {
|
||||
const createController = contract('createInspectionController');
|
||||
const forbidden = new Proxy({}, { get(_, key) { assert.fail(`api.${String(key)} touched`); } });
|
||||
const reads = [], records = [];
|
||||
const adapter = {
|
||||
validate(item) { if (!item?.key) throw new TypeError('bad'); }, key: item => item.key, scope: () => 'own', inspectionPath: item => `own:${item.key}`,
|
||||
read(item, options) { const pending = {}; reads.push({ item, options, pending }); return new Promise((resolve, reject) => Object.assign(pending, { resolve, reject })); },
|
||||
toRecord(result, item) { records.push([result, item.key]); return { identity: { kind: 'own', key: item.key }, prompts: result.prompts }; },
|
||||
};
|
||||
for (const api of [forbidden, null]) {
|
||||
reads.length = 0; records.length = 0;
|
||||
const controller = createController({ api, adapter, extractFacts: extractGenerationFacts });
|
||||
const first = controller.select({ key: 'a' });
|
||||
assert.equal(reads.length, 1);
|
||||
assert.equal(reads[0].item.key, 'a');
|
||||
assert.ok(reads[0].options.signal instanceof AbortSignal);
|
||||
assert.equal(controller.state.loading, true);
|
||||
const second = controller.select({ key: 'b' });
|
||||
assert.equal(reads[0].options.signal.aborted, true, 'a new selection aborts the previous read');
|
||||
reads[0].pending.resolve({ prompts: { positive: { status: 'resolved', text: 'late' } } });
|
||||
await first;
|
||||
assert.equal(controller.state.record, null, 'a superseded read never publishes');
|
||||
reads[1].pending.resolve({ prompts: { positive: { status: 'resolved', text: 'current' } } });
|
||||
await second;
|
||||
assert.deepEqual(records.map(([, key]) => key), ['b']);
|
||||
assert.equal(controller.state.record.prompts.positive.text, 'current');
|
||||
const retry = controller.retry();
|
||||
reads[2].pending.reject(new Error('read failed'));
|
||||
await retry;
|
||||
assert.equal(controller.state.error, true);
|
||||
assert.equal(controller.state.message, 'Could not read details from this file.');
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('rooted and history adapters are unchanged', async () => {
|
||||
const createController = contract('createInspectionController');
|
||||
const { reportedLocationAdapter } = await import('../../web/js/workspace/history-media.mjs');
|
||||
for (const adapter of [module.rootedFileAdapter, reportedLocationAdapter]) {
|
||||
assert.equal(adapter.read, undefined, 'server adapters read through the API');
|
||||
assert.equal(adapter.mediaUrl, undefined, 'server adapters keep the cache-busted original');
|
||||
}
|
||||
const { api, requests } = pendingApi();
|
||||
const rooted = createController({ api, extractFacts: extractGenerationFacts });
|
||||
rooted.select(image());
|
||||
assert.deepEqual(requests.map(request => request.url), [`/prompt_manager/images/inspect/folder/a%20%23%25.png?root=0&root_scope=${scopeA}`]);
|
||||
rooted.dispose();
|
||||
const run = '22222222-2222-2222-2222-222222222222', epoch = '11111111-1111-1111-1111-111111111111';
|
||||
const query = new URLSearchParams({ node: '1', ordinal: '1', epoch, current_scope: scopeA }), base = `/prompt_manager/history/runs/${run}/output/current`;
|
||||
const target = { kind: 'reported-location', location_key: 'c'.repeat(64), history_epoch: epoch, current_scope: scopeA, occurrence: { run_id: run, output_node_id: '1', image_ordinal: 1 },
|
||||
reference: { folder_type: 'output', subfolder: '', filename: 'a.png', path_style: 'posix', root_key: scopeB }, filename: 'a.png', is_video: false,
|
||||
url: `${base}/media?${query}`, thumbnail_url: `${base}/thumbnail?${query}`, inspection_url: `${base}/inspection?${query}` };
|
||||
const history = pendingApi(), recorded = createController({ api: history.api, adapter: reportedLocationAdapter, extractFacts: extractGenerationFacts });
|
||||
recorded.select(target);
|
||||
assert.deepEqual(history.requests.map(request => request.url), [target.inspection_url]);
|
||||
recorded.dispose();
|
||||
});
|
||||
|
||||
test('fileFor rejects selected-local-file for rooted identity', () => {
|
||||
const entry = image(), data = envelope(entry);
|
||||
data.file = { ...data.file, observation: 'selected-local-file', source_label: 'Selected local file' };
|
||||
assert.throws(() => contract('toInspectionRecord')(data, entry, extractGenerationFacts), /Invalid inspection file/);
|
||||
const project = contract('projectInspectionPayload');
|
||||
assert.throws(() => project(data, { kind: 'rooted-file', key: 'k', path: 'a.png' }, null, extractGenerationFacts), /Invalid inspection file/);
|
||||
assert.throws(() => project(envelope(entry), { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts), /Invalid inspection file/);
|
||||
assert.equal(project(data, { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts).file.observation, 'selected-local-file');
|
||||
});
|
||||
|
||||
@@ -267,9 +267,10 @@ test("select reuses the same local key", async () => {
|
||||
assert.equal(m.localFileAdapter.inspectionPath(first), `local-file:${first.key}`);
|
||||
assert.equal(m.localFileAdapter.key(first), first.key);
|
||||
const reads = [];
|
||||
// Until the loader seam lands (PR L2 Task 4) the controller reaches the adapter through api.get.
|
||||
const api = { get: (sentinel) => { reads.push(sentinel); return m.localFileAdapter.read(reads.length === 1 || sentinel === `local-file:${first.key}` ? first : second); } };
|
||||
const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter: m.localFileAdapter });
|
||||
// The controller calls adapter.read itself (PR L2 Task 4); the API is never reached.
|
||||
const api = new Proxy({}, { get: (_, key) => assert.fail(`api.${String(key)} touched`) });
|
||||
const adapter = { ...m.localFileAdapter, read: (handle, options) => { reads.push(m.localFileAdapter.inspectionPath(handle)); return m.localFileAdapter.read(handle, options); } };
|
||||
const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter });
|
||||
const pending = controller.select(first);
|
||||
assert.equal(controller.select(first), pending, "the same handle returns the pending read");
|
||||
await pending;
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createScope} from '../../web/js/shared/toolkit/core.js';
|
||||
import gallerySettings from '../../web/js/gallery-settings.js';
|
||||
import {mountedDom} from './helpers/mounted-dom.mjs';
|
||||
|
||||
const module = await import('../../web/js/workspace/media-viewer.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
@@ -33,44 +34,6 @@ test('preferences handle denied malformed storage without mutation', () => {
|
||||
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults);
|
||||
});
|
||||
|
||||
function mountedDom() {
|
||||
class Node {
|
||||
constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};}
|
||||
get textContent(){return this._text+this.children.map(c=>c.textContent).join('');}
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
// As in the DOM, a string argument becomes a text node.
|
||||
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
|
||||
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
getAttribute(key){return this.attributes[key]??null;}
|
||||
removeAttribute(key){delete this.attributes[key];}
|
||||
addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);}
|
||||
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
|
||||
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
|
||||
focus(){this.focused=true;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.fire('close');}
|
||||
contains(node){return node===this||this.children.some(c=>c.contains(node));}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
|
||||
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loaded=true;}
|
||||
play(){this.played=true;return Promise.resolve();}
|
||||
}
|
||||
const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))];
|
||||
const oldDocument=globalThis.document,oldWindow=globalThis.window;
|
||||
const container=new Node('main');container.root=true;
|
||||
const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag);
|
||||
doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true;
|
||||
const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true});
|
||||
globalThis.document=doc;globalThis.window=win;
|
||||
return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}};
|
||||
}
|
||||
function occurrenceMedia(node,ordinal,key) {
|
||||
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
|
||||
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;
|
||||
@@ -247,3 +210,84 @@ test('viewer titles a linked card "<title> · <filename>" before any inspection
|
||||
assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename);
|
||||
} finally { other.dispose(); scope.dispose(); g.restore(); }
|
||||
});
|
||||
|
||||
// PR L2 Task 4(d): an adapter's own media URL (a session-owned blob:) is used exactly, never cache-busted.
|
||||
async function localHandles() {
|
||||
const {createLocalFileHandle} = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const image = createLocalFileHandle({file:new File([new Uint8Array(8)], 'a.png'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000001', classification:{media:'image', kind:'png'}});
|
||||
const video = createLocalFileHandle({file:new File([new Uint8Array(8)], 'b.mp4'), url:'blob:http://localhost/aaaaaaaa-0000-0000-0000-000000000002', classification:{media:'video', kind:'iso-bmff'}});
|
||||
return {image, video};
|
||||
}
|
||||
function withViewerGlobals(f) {
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame, GallerySettings:globalThis.GallerySettings};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}, GallerySettings:gallerySettings});
|
||||
f.win.localStorage = {getItem:() => null, setItem:() => assert.fail('read-only preferences')};
|
||||
return () => Object.assign(globalThis, globals);
|
||||
}
|
||||
|
||||
test('viewer uses adapter.mediaUrl for image and video', async () => {
|
||||
const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const {image, video} = await localHandles();
|
||||
const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), instances = [];
|
||||
f.win.Viewer = class { constructor(node) { this.node = node; instances.push(this); } destroy() {} resize() {} };
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[image, video],selectedKey:image.key});
|
||||
const img = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.equal(img.src, image.url, 'the blob URL is used exactly');
|
||||
assert.doesNotMatch(img.src, /\?/);
|
||||
assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
|
||||
const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0];
|
||||
assert.equal(download.href, image.url);assert.equal(download.download, 'a.png');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
const node = f.walk(f.container,n => n.tagName === 'video')[0];
|
||||
assert.equal(node.src, video.url);assert.doesNotMatch(node.src, /\?/);
|
||||
assert.equal(f.walk(f.container,n => n.tagName === 'img').length, 0);
|
||||
view.dispose();
|
||||
assert.equal(node.attributes.src, undefined);assert.equal(node.loaded, true, 'closing releases the video source');
|
||||
} finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
});
|
||||
|
||||
test('retry media keeps the blob URL exact', async () => {
|
||||
const {localFileAdapter:adapter} = await import('../../web/js/workspace/local-inspection.mjs');
|
||||
const {image} = await localHandles();
|
||||
const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope();
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[image],selectedKey:image.key});
|
||||
const first = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.equal(first.src, image.url);
|
||||
first.fire('error');
|
||||
const failure = f.walk(f.container,n => n.className === 'media-failure')[0];
|
||||
assert.ok(!failure.hidden, 'the media failure shows');
|
||||
f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click');
|
||||
const second = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.notEqual(second, first, 'retry rebuilds the media element');
|
||||
assert.equal(second.src, image.url, 'retry reuses the exact blob URL');
|
||||
} finally { view.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
});
|
||||
|
||||
test('server adapters still cache-bust the original on open and retry', async () => {
|
||||
const {rootedFileAdapter, createInspectionController} = await import('../../web/js/workspace/inspection-state.mjs');
|
||||
const {reportedLocationAdapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const scope = 'a'.repeat(64);
|
||||
const rooted = {root_index:0,path:'folder/a.png',filename:'a.png',root_scope:scope,is_video:false,url:`/prompt_manager/images/serve/folder/a.png?root=0&root_scope=${scope}`};
|
||||
for (const [adapter, item, key, path] of [[rootedFileAdapter, rooted, '0:folder/a.png', `/prompt_manager/images/inspect/folder/a.png?root=0&root_scope=${scope}`],
|
||||
[reportedLocationAdapter, occurrenceMedia('1',1,'c'), 'c'.repeat(64), occurrenceMedia('1',1,'c').inspection_url]]) {
|
||||
const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), paths = [];
|
||||
const controller = createInspectionController({adapter,api:{get(url) {paths.push(url);return new Promise(() => {});}}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onSelect:entry => controller.select(entry),sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[item],selectedKey:key});
|
||||
assert.deepEqual(paths, [path], 'the same api.get path');
|
||||
const first = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.match(first.src, /_pm_viewer=/);
|
||||
const url = new URL(first.src);url.searchParams.delete('_pm_viewer');
|
||||
assert.equal(`${url.pathname}${url.search}`, item.url);
|
||||
first.fire('error');
|
||||
f.walk(f.container,n => n.tagName === 'button' && n.textContent === 'Retry media')[0].fire('click');
|
||||
const second = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.match(second.src, /_pm_viewer=/);assert.notEqual(second.src, first.src, 'retry busts the cache again');
|
||||
} finally { view.dispose(); controller.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -258,14 +258,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
}
|
||||
return node;
|
||||
}
|
||||
/** "File & provenance": the current-file facts, the D21 prompt evidence, the occurrence and the saved association. */
|
||||
/** "File & provenance": the file facts, the D21 prompt evidence, the occurrence and the saved association (D66 for a local file). */
|
||||
function provenance(record, badge) {
|
||||
const {file, library} = record, section = disclosure('File & provenance');
|
||||
const reported = record.identity.kind === 'reported-location';
|
||||
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'small'));
|
||||
const reported = record.identity.kind === 'reported-location', local = record.identity.kind === 'local-file';
|
||||
// D66: a file chosen in this browser has no server path, no folder to show and no saved association.
|
||||
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : local ? 'Selected local file · read in this browser' :
|
||||
'Current file at this source; historical bytes are not verified.', 'small'));
|
||||
const path = el('dd', record.identity.path ?? 'Not available');
|
||||
const facts = file && definitionList([
|
||||
['Source', file.source_label], ['Path', path], ['Size', formatBytes(file.size)],
|
||||
['Source', file.source_label], ...(local ? [] : [['Path', path]]), ['Size', formatBytes(file.size)],
|
||||
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
|
||||
['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type],
|
||||
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
|
||||
@@ -284,7 +286,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
|
||||
], 'meta-grid'));
|
||||
}
|
||||
section.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small'));
|
||||
section.append(el('p', local ? 'Local file; no saved library association was checked.' : record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small'));
|
||||
if (record.download) {
|
||||
const original = el('a', 'Download original'); original.href = record.download.url;
|
||||
original.download = safeFilename(record.download.filename); section.append(original);
|
||||
|
||||
@@ -308,7 +308,9 @@ export function createInspectionLoader(api, onChange, { extractFacts, adapter =
|
||||
const current = request = new AbortController();
|
||||
onChange({ loading: true });
|
||||
try {
|
||||
const result = await api.get(adapter.inspectionPath(image), { signal: current.signal });
|
||||
// An adapter that reads by itself (a file chosen in this browser) never reaches the API.
|
||||
const result = adapter.read ? await adapter.read(image, { signal: current.signal }) :
|
||||
await api.get(adapter.inspectionPath(image), { signal: current.signal });
|
||||
if (disposed || request !== current) return;
|
||||
const record = adapter.toRecord(result, image, extractFacts);
|
||||
onChange({ prompts: record.prompts, record });
|
||||
|
||||
@@ -115,11 +115,18 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
failure.hidden = true;
|
||||
const mediaLocal = mediaScope, key = session.state.key;
|
||||
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
|
||||
let original;
|
||||
try { original = adapter.originalDownload(selected); }
|
||||
// An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server
|
||||
// original gets a fresh cache-buster on every build.
|
||||
let original, src;
|
||||
try { if (adapter.mediaUrl) src = adapter.mediaUrl(selected); else original = adapter.originalDownload(selected); }
|
||||
catch { session.failed(key);return; }
|
||||
const url = new URL(original.url,window.location.href);
|
||||
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
|
||||
if (adapter.mediaUrl) {
|
||||
if (typeof src !== 'string' || !src) { session.failed(key);return; }
|
||||
} else {
|
||||
const url = new URL(original.url,window.location.href);
|
||||
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
|
||||
src = url.href;
|
||||
}
|
||||
const node = element(selected.is_video ? 'video' : 'img');
|
||||
mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);});
|
||||
if (selected.is_video) {
|
||||
@@ -140,7 +147,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
mediaLocal.cleanup(() => node.removeAttribute('src'));
|
||||
}
|
||||
}
|
||||
node.src = url.href;stage.append(node);
|
||||
node.src = src;stage.append(node);
|
||||
if (!selected.is_video && window.Viewer) {
|
||||
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
|
||||
const release = () => {
|
||||
|
||||
Reference in New Issue
Block a user