From eb530382a6d8b2ea2b92912f1f9b672bdd4a5c9e Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 18 Oct 2026 07:16:14 -0700 Subject: [PATCH] feat(viewer): add lifecycle-safe slideshow and media preferences --- .github/workflows/viewerjs-check.yml | 2 +- TESTING.md | 26 +++ docs/application-redesign.md | 2 +- tests/browser/full-inspector.spec.mjs | 188 ++++++++++++++++++- tests/comfyui/prompt-sender.spec.mjs | 28 ++- tests/js/workspace-media-viewer.test.js | 17 +- tests/js/workspace-viewer-state.test.js | 57 ++++++ tools/vendor/frontend-coverage-baseline.json | 42 ++++- web/js/workspace/file-inspector.mjs | 2 + web/js/workspace/images.css | 2 +- web/js/workspace/images.mjs | 1 + web/js/workspace/inspector.css | 1 + web/js/workspace/media-viewer.mjs | 46 ++++- web/js/workspace/viewer-state.mjs | 47 ++++- web/workspace.html | 1 + 15 files changed, 430 insertions(+), 32 deletions(-) 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 @@ +