diff --git a/.github/workflows/viewerjs-check.yml b/.github/workflows/viewerjs-check.yml
index f6f05f3..6f0118d 100644
--- a/.github/workflows/viewerjs-check.yml
+++ b/.github/workflows/viewerjs-check.yml
@@ -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
diff --git a/TESTING.md b/TESTING.md
index 67c1ca2..0e356ad 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -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
diff --git a/docs/application-redesign.md b/docs/application-redesign.md
index 0d920b3..a18b637 100644
--- a/docs/application-redesign.md
+++ b/docs/application-redesign.md
@@ -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
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
index 86815e2..e6dae5d 100644
--- a/tests/browser/full-inspector.spec.mjs
+++ b/tests/browser/full-inspector.spec.mjs
@@ -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);
diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs
index a8beeeb..b015a5a 100644
--- a/tests/comfyui/prompt-sender.spec.mjs
+++ b/tests/comfyui/prompt-sender.spec.mjs
@@ -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();}
});
diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js
index f72f14e..b50e1f6 100644
--- a/tests/js/workspace-media-viewer.test.js
+++ b/tests/js/workspace-media-viewer.test.js
@@ -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);
+});
diff --git a/tests/js/workspace-viewer-state.test.js b/tests/js/workspace-viewer-state.test.js
index 9607290..3bf7265 100644
--- a/tests/js/workspace-viewer-state.test.js
+++ b/tests/js/workspace-viewer-state.test.js
@@ -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();
+});
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index 4bfb204..ceeb6ab 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -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
}
}
diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs
index 2a7d304..4926c97 100644
--- a/web/js/workspace/file-inspector.mjs
+++ b/web/js/workspace/file-inspector.mjs
@@ -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) {
diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css
index 0ae35f1..7c8d45a 100644
--- a/web/js/workspace/images.css
+++ b/web/js/workspace/images.css
@@ -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; }
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index 5576241..8d9762f 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -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;
diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css
index f8090c4..40d6b12 100644
--- a/web/js/workspace/inspector.css
+++ b/web/js/workspace/inspector.css
@@ -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; }
diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs
index 2fd2692..2006af2 100644
--- a/web/js/workspace/media-viewer.mjs
+++ b/web/js/workspace/media-viewer.mjs
@@ -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};
}
diff --git a/web/js/workspace/viewer-state.mjs b/web/js/workspace/viewer-state.mjs
index 638c0b0..7a26a16 100644
--- a/web/js/workspace/viewer-state.mjs
+++ b/web/js/workspace/viewer-state.mjs
@@ -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});
},
};
}
diff --git a/web/workspace.html b/web/workspace.html
index 620aaf5..8ff41bd 100644
--- a/web/workspace.html
+++ b/web/workspace.html
@@ -13,6 +13,7 @@
+