feat(viewer): add lifecycle-safe slideshow and media preferences
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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; }
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user