feat(viewer): add lifecycle-safe slideshow and media preferences

This commit is contained in:
Vito Sansevero
2026-10-18 07:16:14 -07:00
parent f61f319521
commit eb530382a6
15 changed files with 430 additions and 32 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
viewerjs:
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
+26
View File
@@ -145,6 +145,32 @@ cover cancellation, malformed responses and bounded visibility lookup. These run
automatically in the existing four-browser-project workflow and frontend coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
managed host with both renderer startup configurations.
The shared inline and collection inspector reads the current rooted file's supported
embedded records, exact generation facts and recorded or inferred prompt evidence.
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
response limits, source changes, nested sending, matched dark/light and mobile
geometry, and the loaded-collection viewer. The viewer never fetches another page
or preloads all originals. Its explicit slideshow waits 3.5 seconds after image
readiness, pauses at native video without advancing on `ended`, and resumes on a
manually selected image only while prior intent remains. Pause/Stop, inspector
reading/copy/download/send, hidden document, media failure, close, query/route
disposal and pagehide cancel that intent; returning visibility or closing the sender
does not resume it. Stop leaves native video playback alone. Video uses only the
existing normalized `gallerySettings` autoplay/mute/loop values, read without writes;
denied/malformed storage falls back to autoplay off, mute on and loop on. Rejected
autoplay keeps native controls usable.
The inspector does not establish historical file identity, infer run membership,
edit image tags, export all EXIF, or load/execute a workflow. Saved vocabulary is
labelled by its recipe or positive-prompt scope. Limited raw evidence is omitted
atomically, with JSON exports unavailable and the rooted original still downloadable.
The inspection API uses one bounded read (2 MiB aggregate metadata, 8 MiB non-PNG
header, 10,000 PNG chunks, three-second ffprobe, 500 graph nodes/semantic expansions,
64 link/JSON-container levels and an 8 MiB serialized response); retry does not raise
these limits. Installing the Python inspection/source-scope changes requires a
maintainer ComfyUI restart. Automated tests use disposable hosts and never restart
the running personal ComfyUI.
Run the focused page checks with:
```sh
+1 -1
View File
@@ -11,7 +11,7 @@ code. This plan proposes implementation order and acceptance gates.
- Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206).
- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates.
- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209).
- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. Full inspector/viewer parity remains pending under [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216); this presentation slice does not establish whole-application parity. Tagging/review, System consolidation and the remaining organization capabilities continue under #196.
- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196.
## Scope and compatibility
+186 -2
View File
@@ -1,6 +1,7 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.inspectorErrors).toEqual([]);});
@@ -235,7 +236,8 @@ test.describe('full inspector coarse pointer',()=>{
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) {
const box=await target.boundingBox();expect(box.width).toBeGreaterThanOrEqual(80);expect(box.height).toBeGreaterThanOrEqual(80);
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
}
const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport();
});
@@ -283,6 +285,8 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true});
await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
@@ -317,7 +321,182 @@ test('collection viewer uses real public transforms and resets on navigation',as
await expect(controls.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');await expect(modal.locator('.viewer-container')).toHaveCount(1);
expect(await page.evaluate(()=>window.currentMedia!==document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true);
expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1});
await expect(controls.getByRole('button',{name:/slideshow/})).toHaveCount(0);
await expect(controls.getByRole('button',{name:'Play slideshow',exact:true})).toBeVisible();
});
async function slideshowFixture(page,request,preferences={videoAutoplay:false,videoMute:true,videoLoop:false}) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
await page.evaluate(async preferences=>{
if(preferences) localStorage.setItem('gallerySettings',JSON.stringify(preferences));
const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([
import('/prompt_manager/js/workspace/media-viewer.mjs'),import('/prompt_manager/js/workspace/inspection-state.mjs'),
import('/prompt_manager/js/shared/toolkit/core.js'),import('/prompt_manager/js/workspace/prompt-sender.mjs')]);
const api=window.ApiClient.createApiClient(),collection=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40');
const items=['complete.png','clip.webm','explicit-empty.png'].map(filename=>collection.images.find(image=>image.root_index===0&&image.filename===filename));
window.slideshowTimers=new Set();window.slideshowCallbacks=[];window.slideshowReady=[];
const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window);
window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500){window.slideshowTimers.add(id);window.slideshowCallbacks.push(callback);}return id;};
window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);};
const Native=window.Viewer;
window.Viewer=class extends Native {constructor(node,options){window.slideshowReady.push(options.viewed);super(node,options);}};
const scope=createScope(),controller=createInspectionController({api}),sender=createPromptSender({scope});scope.cleanup(()=>controller.dispose());
const view=createMediaViewer({container:document.getElementById('workspace-main'),parentScope:scope,inspectionController:controller,sender,onSelect:entry=>controller.select(entry)});
window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open();
},preferences);
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
}
const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click();
async function readyClockImage(page) {
await expect.poll(async()=>{await page.clock.runFor(50);return mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true}).getAttribute('aria-disabled');}).toBe('false');
}
test('slideshow exposes exact state labels and respects video playback',async({page,request})=>{
const originals=[];page.on('request',request=>{if(request.url().includes('_pm_viewer='))originals.push(request.url());});
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
await expect(button).toHaveText('Play');await expect(button).not.toHaveAttribute('aria-pressed');
expect(originals).toHaveLength(1);
await button.click();await expect(button).toHaveText('Pause');await expect(button).toBeFocused();await expect(position(page)).toContainText('Slideshow playing.');expect(await timerCount(page)).toBe(1);
await page.clock.runFor(3499);await expect(position(page)).toContainText('1 of 3 loaded: complete.png');
await page.clock.runFor(1);await expect(position(page)).toContainText('2 of 3 loaded: clip.webm');
await expect(button).toHaveAccessibleName('Stop slideshow');await expect(button).toHaveText('Stop');await expect(button).toBeFocused();
await expect(position(page)).toContainText('Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.');expect(await timerCount(page)).toBe(0);
const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);
expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:false});
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable for video.');
await video.evaluate(node=>node.play());await expect.poll(()=>video.evaluate(node=>node.ended)).toBe(true);
await page.clock.runFor(10500);await expect(position(page)).toContainText('2 of 3 loaded');await expect(button).toHaveText('Stop');
await video.evaluate(node=>{node.loop=true;node.currentTime=0;return node.play();});
await button.click();await expect(button).toHaveText('Play');expect(await video.evaluate(node=>node.paused)).toBe(false);
await expect(button).toHaveAttribute('aria-disabled','true');await mediaStep(page,'Next');
await readyClockImage(page);await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);
await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop');
await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1);
expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3);
await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1);
});
test('slideshow stop fences old timer and readiness callbacks',async({page,request})=>{
await slideshowFixture(page,request);const button=slideshowButton(page);
await button.click();await page.clock.runFor(3499);await button.click();
await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();});
await page.clock.runFor(7000);await expect(position(page)).toContainText('1 of 3 loaded');await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);
await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Slideshow waiting for image.');await button.click();
await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();});
await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await mediaStep(page,'Next');await readyClockImage(page);
await page.route('**/images/serve/inspector/explicit-empty.png?*',route=>route.abort());
await button.click();await mediaStep(page,'Previous');await expect(position(page)).toContainText('Media could not be loaded.');
await expect(button).toHaveText('Play');await expect(button).toHaveAttribute('aria-disabled','true');expect(await timerCount(page)).toBe(0);
await mediaStep(page,'Next');await expect(button).toHaveText('Play');
await page.evaluate(()=>window.slideshowFixture.scope.dispose());await expect(mediaDialog(page)).toHaveCount(0);
await page.evaluate(()=>{for(const callback of window.slideshowCallbacks)callback();for(const callback of window.slideshowReady)callback();window.slideshowFixture.view.stopSlideshow();});
expect(await timerCount(page)).toBe(0);
});
test('slideshow production query and route disposal cancel the active deadline',async({page,request})=>{
await fullFixture(page,request);await page.clock.install();
await page.evaluate(()=>{
window.slideshowTimers=new Set();const schedule=window.setTimeout.bind(window),cancel=window.clearTimeout.bind(window);
window.setTimeout=(callback,delay,...args)=>{let id;id=schedule(()=>{window.slideshowTimers.delete(id);callback(...args);},delay);if(delay===3500)window.slideshowTimers.add(id);return id;};
window.clearTimeout=id=>{window.slideshowTimers.delete(id);cancel(id);};
});
await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1);
await page.clock.runFor(3499);
const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
expect(pendingAtReset).toBe(0);await page.clock.runFor(1);
await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
for(const action of [
()=>modal.locator('.prompt-positive pre').click(),
()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(),
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
]) {
await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded');
}
for(let mount=0;mount<3;mount++) {
await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play');
await readyClockImage(page);
}
await button.click();await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(modal).toHaveCount(0);
});
test('slideshow preference failures leave native playback usable',async({page,request})=>{
await slideshowFixture(page,request,null);const modal=mediaDialog(page);
for(const failure of ['denied','malformed','rejected']) {
await page.evaluate(failure=>{
if(failure==='denied') {window.normalStorage=Object.getOwnPropertyDescriptor(window,'localStorage');Object.defineProperty(window,'localStorage',{configurable:true,get(){throw new DOMException('Denied','SecurityError');}});}
else {if(window.normalStorage)Object.defineProperty(window,'localStorage',window.normalStorage);localStorage.setItem('gallerySettings',failure==='malformed'?'{broken':JSON.stringify({videoAutoplay:true,videoMute:true,videoLoop:false}));}
if(failure==='rejected'){window.nativePlay=HTMLMediaElement.prototype.play;window.playRejections=0;HTMLMediaElement.prototype.play=function(){window.playRejections++;return Promise.reject(new DOMException('Blocked','NotAllowedError'));};}
},failure);
await mediaStep(page,'Next');const video=modal.locator('video');await expect.poll(()=>video.evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);
expect(await video.evaluate(node=>({controls:node.controls,inline:node.playsInline,muted:node.muted,loop:node.loop}))).toEqual({controls:true,inline:true,muted:true,loop:failure!=='rejected'});
if(failure==='rejected') {expect(await page.evaluate(()=>window.playRejections)).toBe(1);await page.evaluate(()=>{HTMLMediaElement.prototype.play=window.nativePlay;});}
await video.evaluate(node=>node.play());expect(await video.evaluate(node=>node.paused)).toBe(false);
await mediaStep(page,'Previous');await expect(slideshowButton(page)).toHaveText('Play');
}
});
test('full inspector attaches matched theme and mobile evidence with reachable content',async({page,request},testInfo)=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
const measurements=[];
async function capture(name,viewer=false) {
await page.evaluate(()=>document.fonts.ready);
if(viewer) {
const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
measurements.push({name,...await modal.evaluate(node=>{
const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};};
return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')),
toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')),
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
})});
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
else await expect(rail).toHaveCSS('overflow-y','visible');
}
const file=testInfo.outputPath(`${name}.png`);await page.screenshot({path:file});await testInfo.attach(name,{path:file,contentType:'image/png'});
}
for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
const label=`${theme}-${width}`;await page.setViewportSize({width,height});
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
await capture(`inline-${label}`);
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true);
await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true);
await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
await modal.getByRole('button',{name:'Close preview',exact:true}).click();
}
const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json');
await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'});
});
test('collection viewer preserves original download and rooted retries',async({page,request})=>{
@@ -371,9 +550,14 @@ test('collection viewer matches approved responsive frame',async({page,request},
await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)});
}
for(const [width,expected] of [[1200,300],[900,270]]) {await page.setViewportSize({width,height:1000});await expect(rail).toHaveCSS('width',`${expected}px`);}
await page.setViewportSize({width:390,height:844});expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(356,1);
for(const [width,expected]of [[840,806],[850,816],[860,825.6]]) {
await page.setViewportSize({width,height:1000});expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(expected,1);
}
await page.setViewportSize({width:390,height:844});
for(const zoom of [1,2]) {
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
+24 -4
View File
@@ -7,11 +7,13 @@ installHostHooks(test,['PromptManagerPair']);
test('native image inspector previews and sends a pair into real visible widgets without loading or queueing',async({page,context,request},testInfo)=>{
const positive=`Inspector host positive ${testInfo.project.name}`;const negative='host negative, watermark';
const image=execFileSync(process.env.PM_TEST_PYTHON||'python',['-c',[
'import io,sys',
'import io,json,sys',
'from PIL import Image',
'from PIL.PngImagePlugin import PngInfo',
'info=PngInfo()',
'info.add_text("parameters",sys.argv[1]+"\\nNegative prompt: "+sys.argv[2]+"\\nSteps: 20, Sampler: Euler")',
'graph={"1":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[1]}},"2":{"class_type":"CLIPTextEncode","inputs":{"text":sys.argv[2]}},"3":{"class_type":"KSampler","inputs":{"positive":["1",0],"negative":["2",0],"model":["4",0],"seed":9007199254740993,"steps":28,"cfg":6.5,"sampler_name":"dpmpp_2m","scheduler":"karras"}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"host-fixture.safetensors"}}}',
'info.add_text("prompt",json.dumps(graph))',
'info.add_text("workflow",json.dumps({"nodes":[],"links":[]}))',
'buffer=io.BytesIO()',
'Image.new("RGB",(64,64),(30,80,140)).save(buffer,format="PNG",pnginfo=info)',
'sys.stdout.buffer.write(buffer.getvalue())',
@@ -44,7 +46,23 @@ test('native image inspector previews and sends a pair into real visible widgets
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
await inspector.getByRole('button',{name:'Send prompt pair',exact:true}).click();
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(viewer.locator('pre code').first()).toContainText('9007199254740993');
await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true');
expect(await page.evaluate(()=>window.senderEffects)).toEqual({queue:0,load:0});
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
}
expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0);
await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};});
await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);
@@ -60,9 +78,11 @@ test('native image inspector previews and sends a pair into real visible widgets
const state=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.senderEffects};
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),
effects:{queue:window.senderEffects.queue-window.senderSendBaseline.queue,load:window.senderEffects.load-window.senderSendBaseline.load}};
},id);
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')});
} finally {await workspace.close();}
});
+16 -1
View File
@@ -1,6 +1,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';
const module = await import('../../web/js/workspace/media-viewer.mjs').catch(error => {
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
@@ -14,6 +15,20 @@ test('disposed media viewer touches no DOM observers or requests', () => {
const view = module.createMediaViewer({container:forbidden,parentScope,inspectionController:forbidden,sender:forbidden,
onSelect:() => assert.fail('Disposed viewer selected an entry'),onRefresh:() => assert.fail('Disposed viewer refreshed')});
if (!aborted) view.dispose();
view.open(forbidden); view.dispose(); view.dispose(); parentScope.dispose();
view.open(forbidden); view.stopSlideshow?.(); view.dispose(); view.dispose(); parentScope.dispose();
}
});
test('preferences handle denied malformed storage without mutation', () => {
assert.equal(typeof module.readViewerPreferences,'function');
const normalize=gallerySettings.normalizeGallerySettings,defaults={videoAutoplay:false,videoMute:true,videoLoop:true};
for(const getStorage of [() => {throw new Error('Denied');},() => ({getItem:() => {throw new Error('Denied');}}),
...['{broken','null','[]','42'].map(value=>() => ({getItem:key=>{assert.equal(key,'gallerySettings');return value;},setItem:()=>assert.fail('Read-only preferences')}))]) {
assert.deepEqual(module.readViewerPreferences(getStorage,normalize),defaults);
}
let raw=JSON.stringify({videoAutoplay:true,videoMute:false,videoLoop:false,infiniteScroll:true});
const storage={getItem:key=>{assert.equal(key,'gallerySettings');return raw;},setItem:()=>assert.fail('Read-only preferences')};
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),{videoAutoplay:true,videoMute:false,videoLoop:false});
raw=JSON.stringify({videoAutoplay:'true',videoMute:0,videoLoop:null});
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults);
});
+57
View File
@@ -51,3 +51,60 @@ test('viewer ignores stale readiness after navigation and disposal', () => {
view.ready('1:folder/1.png'); view.failed('1:folder/1.png'); view.navigate(1); view.dispose();
assert.equal(calls,after); assert.equal(view.state.phase,'disposed'); assert.equal(view.state.requested,false);
});
function timedSession(items = entries().slice(0,3)) {
let now = 0, serial = 0;
const pending = new Map(), callbacks = [];
const view = session({items,initialKey:`${items[0].root_index}:${items[0].path}`,
setTimer: (callback, delay) => {const id=++serial;pending.set(id,{at:now+delay,callback});callbacks.push(callback);return id;},
clearTimer: id => pending.delete(id)});
const tick = ms => {now+=ms;for(const [id,timer] of [...pending])if(timer.at<=now){pending.delete(id);timer.callback();}};
return {view,pending,callbacks,tick};
}
test('slideshow schedules once only after current image ready', () => {
const {view,pending,tick}=timedSession();
view.play?.();assert.equal(view.state.phase,'waiting-image');assert.equal(view.state.requested,true);assert.equal(pending.size,0);
view.ready(view.state.key);assert.equal(view.state.phase,'playing-image');assert.equal(pending.size,1);
view.ready(view.state.key);view.play();assert.equal(pending.size,1);
tick(3499);assert.equal(view.state.index,0);tick(1);assert.equal(view.state.index,1);
assert.equal(view.state.phase,'waiting-image');assert.equal(pending.size,0);
view.ready(view.state.key);assert.equal(pending.size,1);view.dispose();assert.equal(pending.size,0);
});
test('slideshow pauses at video and resumes retained intent only after manual image navigation', () => {
const items=entries().slice(0,3);items[1].is_video=true;
const {view,pending,tick}=timedSession(items);
view.ready(view.state.key);view.play?.();tick(3500);
assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video');assert.equal(view.state.requested,true);assert.equal(pending.size,0);
view.ready(view.state.key);tick(7000);assert.equal(view.state.index,1);assert.equal(view.state.phase,'paused-video');
view.navigate(1);assert.equal(view.state.phase,'waiting-image');view.ready(view.state.key);assert.equal(view.state.phase,'playing-image');
view.navigate(-1);view.stop();view.ready(view.state.key);view.navigate(1);view.ready(view.state.key);
assert.equal(view.state.phase,'stopped');assert.equal(pending.size,0);view.dispose();
});
test('stop cancels waiting and paused intent despite late callbacks', () => {
const {view,pending,callbacks}=timedSession();view.play?.();view.stop?.();view.ready(view.state.key);
assert.equal(view.state.requested,false);assert.equal(pending.size,0);
view.play?.();assert.equal(pending.size,1);const late=callbacks.at(-1);
view.stop();late();view.ready(view.state.key);assert.equal(view.state.index,0);assert.equal(view.state.phase,'stopped');
view.play();view.navigate(1);late();assert.equal(view.state.index,1);assert.equal(view.state.phase,'waiting-image');
view.ready(view.state.key);assert.equal(pending.size,1);view.dispose();
});
test('hidden interaction error and disposal never resume', () => {
const {view,pending,callbacks}=timedSession();view.ready(view.state.key);view.play?.();
assert.equal(pending.size,1);view.stop();for(const callback of callbacks)callback();view.ready(view.state.key);
assert.equal(view.state.phase,'stopped');view.play();view.failed(view.state.key);view.ready(view.state.key);
assert.equal(view.state.phase,'stopped');assert.equal(view.state.requested,false);assert.equal(pending.size,0);
view.play();view.dispose();const disposed=view.state;
for(const callback of callbacks)callback();view.play();view.stop();view.ready(view.state.key);view.failed(view.state.key);view.navigate(1);view.dispose();
assert.equal(view.state,disposed);assert.equal(pending.size,0);
});
test('slideshow cannot start on video one entry or media error', () => {
for(const items of [[entries()[0]],[{...entries()[0],is_video:true},entries()[1]]]) {
const {view,pending}=timedSession(items);view.play?.();assert.equal(view.state.requested,false);assert.equal(pending.size,0);view.dispose();
}
const {view,pending}=timedSession();view.failed(view.state.key);view.play?.();assert.equal(view.state.phase,'error');assert.equal(pending.size,0);view.dispose();
});
+36 -6
View File
@@ -13,7 +13,7 @@
},
"comfy-metadata.js": {
"lines": 97,
"branches": 90,
"branches": 91,
"functions": 98,
"statements": 97
},
@@ -168,10 +168,10 @@
"statements": 98
},
"workspace/images.mjs": {
"lines": 95,
"lines": 97,
"branches": 87,
"functions": 98,
"statements": 95
"statements": 97
},
"prompt-bridge-adapter.js": {
"lines": 98,
@@ -192,10 +192,10 @@
"statements": 98
},
"workspace/prompt-inspector.mjs": {
"lines": 97,
"branches": 92,
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 97
"statements": 98
},
"workspace/prompt-sender.mjs": {
"lines": 98,
@@ -226,5 +226,35 @@
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/file-inspector.mjs": {
"lines": 95,
"branches": 92,
"functions": 88,
"statements": 95
},
"workspace/inspection-state.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/media-viewer.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/prompt-blocks.mjs": {
"lines": 88,
"branches": 88,
"functions": 98,
"statements": 88
},
"workspace/viewer-state.mjs": {
"lines": 98,
"branches": 95,
"functions": 98,
"statements": 98
}
}
+2
View File
@@ -88,6 +88,8 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
container.classList.add('file-inspector');
scope.listen(container, 'pointerdown', onInteraction);
scope.listen(container, 'keydown', onInteraction);
scope.listen(container, 'click', onInteraction);
scope.listen(container, 'wheel', onInteraction, {passive:true});
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
function setState(state) {
+1 -1
View File
@@ -43,7 +43,7 @@
.image-details .workspace-link { display: flex; font-size: 11px; }
.image-continuation { text-align: center; padding-block: 24px; font-size: 12px; }
.image-continuation [role="alert"] { color: var(--ca-error-text); }
.image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-inline-size: none; max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; }
.image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; }
.image-preview[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 20px; }
.image-preview::backdrop { background: #080b12b8; overflow: hidden; overscroll-behavior: contain; }
.image-preview-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-inline-size: 0; }
+1
View File
@@ -357,6 +357,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
observer.observe(continuation);
}
function reset() {
mediaViewer.stopSlideshow();
main.querySelector('.image-preview[open]')?.close();
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
+1
View File
@@ -1,4 +1,5 @@
.image-details.file-inspector { padding: 0; }
.image-preview-slideshow-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; }
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector-inner { padding: 17px; }
.image-preview-details .file-inspector-inner { padding: 0; }
+38 -8
View File
@@ -4,6 +4,12 @@ import {originalMediaDownload} from './inspection-state.mjs';
import {mountFileInspector} from './file-inspector.mjs';
let viewerId = 0;
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
let stored;
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
const {videoAutoplay,videoMute,videoLoop} = normalize(stored);
return {videoAutoplay,videoMute,videoLoop};
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -15,7 +21,8 @@ export function createMediaViewer({container,parentScope,inspectionController,se
const scope = createScope();
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose}; }
const stopSlideshow = () => active?.stop();
if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose,stopSlideshow}; }
parentScope.signal.addEventListener('abort',dispose,{once:true});
scope.cleanup(() => parentScope.signal.removeEventListener('abort',dispose));
scope.cleanup(() => active?.close(false));
@@ -57,14 +64,17 @@ export function createMediaViewer({container,parentScope,inspectionController,se
const transforms = [button('Zoom out','−',() => viewer.zoom(-0.2)),button('Zoom in','+',() => viewer.zoom(0.2)),
button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)),
button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))];
const slideshow = button('Play slideshow','Play',() => session.state.requested ? session.stop() : session.play());
const slideshowReason = element('p','image-preview-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
const download = element('a','image-preview-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
local.listen(download,'click',event => {if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
controls.append(download);
const next = button('Next media','Next →',() => session.navigate(1));
toolbar.append(controls,reason);left.append(stage,toolbar);
toolbar.append(controls,reason,slideshowReason);left.append(stage,toolbar);
const details = element('aside','image-preview-details');details.setAttribute('aria-label','Preview prompt details');
body.append(left,details);dialog.append(header,body);container.append(dialog);
const inspector = mountFileInspector(details,{sender,parentScope:local,onRetry:() => inspectionController.retry(),
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
function closeViewer(restore = true) {
if (closed) return;
@@ -74,12 +84,16 @@ export function createMediaViewer({container,parentScope,inspectionController,se
else onRestoreFocus?.(entry);
}
}
active = {close:closeViewer};
active = {close:closeViewer,stop:() => session.stop()};
local.cleanup(() => {
session.dispose();mediaScope?.dispose();inspector.dispose();
if (dialog.open) dialog.close();dialog.remove();
});
local.listen(close,'click',() => dialog.close());local.listen(dialog,'close',() => closeViewer());
local.listen(close,'click',() => closeViewer());local.listen(dialog,'close',() => closeViewer());
// Finish focus restoration before a later key event can select another control.
local.listen(dialog,'cancel',event => {event.preventDefault();closeViewer();});
local.listen(document,'visibilitychange',() => {if (document.hidden) session.stop();});
local.listen(window,'pagehide',() => closeViewer(false));
local.listen(retry,'click',() => {if (!inspectionState.refreshRequired) rebuild(entry);});
local.listen(dialog,'keydown',event => {
if (!['ArrowLeft','ArrowRight'].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
@@ -102,8 +116,13 @@ export function createMediaViewer({container,parentScope,inspectionController,se
mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);});
if (selected.is_video) {
node.controls = true;node.playsInline = true;node.preload = 'metadata';
const preferences = readViewerPreferences();
node.muted = preferences.videoMute;node.loop = preferences.videoLoop;
mediaLocal.listen(node,'loadedmetadata',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => {node.pause();node.removeAttribute('src');node.load();});
if (preferences.videoAutoplay) mediaLocal.listen(node,'loadedmetadata',() => {
if (current()) node.play().catch(() => {});
},{once:true});
} else {
node.alt = selected.filename;node.decoding = 'async';
node.hidden = !!window.Viewer;
@@ -146,6 +165,14 @@ export function createMediaViewer({container,parentScope,inspectionController,se
node.setAttribute('aria-disabled',String(state.total === 1));
if (state.total === 1) {node.setAttribute('aria-describedby',position.id);}
}
const action = state.phase === 'paused-video' ? 'Stop' : state.requested ? 'Pause' : 'Play';
slideshow.textContent = action;slideshow.setAttribute('aria-label',`${action} slideshow`);
const cannotPlay = !state.requested && (inspectionState.refreshRequired || state.phase === 'error' || state.total < 2 || entry.is_video);
slideshow.setAttribute('aria-disabled',String(!!cannotPlay));
slideshowReason.textContent = !cannotPlay ? '' : inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ?
'Media could not be loaded. Retry this file.' : state.total < 2 ? 'Slideshow requires at least two loaded media entries.' : 'Start slideshow on an image.';
slideshowReason.hidden = !cannotPlay;
if (cannotPlay) slideshow.setAttribute('aria-describedby',slideshowReason.id);else slideshow.removeAttribute('aria-describedby');
const canDownload = originalDownload && !inspectionState.refreshRequired;
download.setAttribute('aria-disabled',String(!canDownload));
if (canDownload) {
@@ -159,7 +186,9 @@ export function createMediaViewer({container,parentScope,inspectionController,se
function update(state,selected) {
if (closed || state.phase === 'disposed') return;
entry = selected;
const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${state.phase === 'error' ? ' — Media could not be loaded.' : ''}`;
const status = {'waiting-image':'Slideshow waiting for image.','playing-image':'Slideshow playing.',
'paused-video':'Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.',error:'Media could not be loaded.'}[state.phase];
const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${status ? ` — ${status}` : ''}`;
if (position.textContent !== label) position.textContent = label;
if (currentKey !== state.key) {
currentKey = state.key;title.textContent = entry.filename;
@@ -173,9 +202,10 @@ export function createMediaViewer({container,parentScope,inspectionController,se
dialog.showModal();mounted = true;update(...initial);
local.cleanup(inspectionController.subscribe(state => {
inspectionState = state;updateControls(session.state);
if (state.error) session.stop();
inspector.setState(state);
if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} · ${entry.filename}`;
}));
}
return {open,dispose};
return {open,dispose,stopSlideshow};
}
+39 -8
View File
@@ -15,28 +15,59 @@ export function createViewerSession({items, initialKey, onChange = () => {}, set
const index = loaded.findIndex(item => imageKey(item) === initialKey);
if (index < 0) throw new TypeError('Selected media is not loaded');
let state = Object.freeze({key:initialKey,index,total:loaded.length,phase:'stopped',requested:false,mediaReady:false});
let timer = null, deadline = 0;
function cancel() {
deadline++;
if (timer !== null) clearTimer(timer);
timer = null;
}
function publish(update) {
state = Object.freeze({...state,...update});
onChange(state, loaded[state.index]);
}
function phase(ready = state.mediaReady) {
return !state.requested ? 'stopped' : loaded[state.index].is_video ? 'paused-video' : ready ? 'playing-image' : 'waiting-image';
}
function schedule() {
if (state.phase !== 'playing-image' || timer !== null) return;
const key = state.key, token = ++deadline;
timer = setTimer(() => {
if (token !== deadline || state.phase !== 'playing-image' || !state.requested || state.key !== key) return;
timer = null;navigate(1);
},3500);
}
function navigate(direction) {
if (state.phase === 'disposed') return;
if (direction !== -1 && direction !== 1) throw new TypeError('Viewer direction must be -1 or 1');
if (loaded.length === 1) return;
cancel();
const next = (state.index + direction + loaded.length) % loaded.length;
publish({index:next,key:imageKey(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false});
}
publish({});
return {
get state() { return state; },
navigate(direction) {
navigate,
play() {
if (state.phase === 'disposed' || state.phase === 'error' || state.requested || loaded.length < 2 || loaded[state.index].is_video) return;
publish({requested:true,phase:state.mediaReady ? 'playing-image' : 'waiting-image'});
schedule();
},
stop() {
if (state.phase === 'disposed') return;
if (direction !== -1 && direction !== 1) throw new TypeError('Viewer direction must be -1 or 1');
if (loaded.length === 1) return;
const next = (state.index + direction + loaded.length) % loaded.length;
publish({index:next,key:imageKey(loaded[next]),phase:'stopped',mediaReady:false});
cancel();publish({requested:false,phase:state.phase === 'error' ? 'error' : 'stopped'});
},
ready(key) {
if (state.phase !== 'disposed' && key === state.key) publish({mediaReady:true,phase:'stopped'});
if (state.phase === 'disposed' || key !== state.key || state.mediaReady) return;
publish({mediaReady:true,phase:phase(true)});schedule();
},
failed(key) {
if (state.phase !== 'disposed' && key === state.key) publish({mediaReady:false,phase:'error'});
if (state.phase === 'disposed' || key !== state.key) return;
cancel();publish({mediaReady:false,phase:'error',requested:false});
},
dispose() {
if (state.phase !== 'disposed') publish({phase:'disposed',requested:false,mediaReady:false});
if (state.phase === 'disposed') return;
cancel();publish({phase:'disposed',requested:false,mediaReady:false});
},
};
}
+1
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="js/workspace/inspector.css">
<script src="lib/viewerjs/viewer.min.js" defer></script>
<script src="js/comfy-metadata.js" defer></script>
<script src="js/gallery-settings.js" defer></script>
<script type="module" src="js/workspace/index.mjs"></script>
</head>
<body class="ca-tools">