diff --git a/e2e/queue-sidebar.spec.mjs b/e2e/queue-sidebar.spec.mjs index 150f039..15bac2c 100644 --- a/e2e/queue-sidebar.spec.mjs +++ b/e2e/queue-sidebar.spec.mjs @@ -159,15 +159,124 @@ test.describe('Queue Sidebar E2E', () => { await waitForQueueEmpty(page) }) + // ── #5 — Running card preview reflects live execution previews ──── + test('running card preview shows img element during execution', async ({ page }) => { + const wf = await getModifiedWorkflow(page) + test.skip(!wf, 'No workflow available') + + await queuePrompt(page, wf) + + // Wait for a running card to appear + await expect(page.locator('[data-status="running"]').first()) + .toBeAttached({ timeout: 10_000 }) + + // Poll until we observe a running card with an element, OR the + // task finishes. Using a manual loop avoids a race where the card + // transitions to completed between the two separate evaluate() calls + // that toPass() would make. + let sawRunningWithImg = false + const pollStart = Date.now() + while (Date.now() - pollStart < 60_000) { + const result = await page.evaluate(() => { + const card = document.querySelector('[data-status="running"]') + if (!card) return 'completed' + return card.querySelector('.task-preview img') ? 'has-img' : 'no-img' + }) + if (result === 'has-img') { sawRunningWithImg = true; break } + if (result === 'completed') break + await page.waitForTimeout(200) + } + + expect(sawRunningWithImg).toBe(true) + await waitForQueueEmpty(page) + }) + + // ── #7 — Completed card shows preview; persists after reload ─────── + test('completed card shows preview image and persists after page reload', async ({ page }) => { + const wf = await getModifiedWorkflow(page) + test.skip(!wf, 'No workflow available') + + await queuePrompt(page, wf) + await waitForQueueEmpty(page) + await page.waitForTimeout(1500) // let render cycle complete + + // Completed card must have an whose src contains /view?filename= + const completedCard = page.locator('[data-status="completed"]').first() + await expect(completedCard).toBeAttached({ timeout: 10_000 }) + + // Wait for the img to be present before reading its src + const imgLocator = completedCard.locator('.task-preview img').first() + await expect(imgLocator).toBeAttached({ timeout: 5_000 }) + const imgSrc = await imgLocator.getAttribute('src') + expect(imgSrc).toMatch(/\/view\?/) + + // Extract filename to verify it's the same image after reload (cache hit) + const beforeFilename = new URLSearchParams(imgSrc.split('?')[1]).get('filename') + expect(beforeFilename).toBeTruthy() + + // Reload and confirm the same card still shows the same image (localStorage cache hit) + await page.reload({ waitUntil: 'networkidle' }) + await page.waitForTimeout(2000) + await openSidebar(page) + + const reloadedImg = page.locator('[data-status="completed"] .task-preview img').first() + await expect(reloadedImg).toBeAttached({ timeout: 5_000 }) + const reloadedSrc = await reloadedImg.getAttribute('src') + expect(reloadedSrc).toMatch(/\/view\?/) + + // Same filename proves the preview came from localStorage cache, not a random re-fetch + const afterFilename = new URLSearchParams(reloadedSrc.split('?')[1]).get('filename') + expect(afterFilename).toBe(beforeFilename) + }) + + // ── #8 — localStorage cache is populated after execution ─────────── + test('localStorage cache is populated with output after execution', async ({ page }) => { + const wf = await getModifiedWorkflow(page) + test.skip(!wf, 'No workflow available') + + const queueResult = await queuePrompt(page, wf) + const promptId = queueResult?.prompt_id + test.skip(!promptId, 'Queue response did not include prompt_id') + + await waitForQueueEmpty(page) + await page.waitForTimeout(1000) + + const cacheEntry = await page.evaluate((pid) => { + try { + const raw = localStorage.getItem('queueSidebar.lastOutput') + if (!raw) return null + const cache = JSON.parse(raw) + return cache[pid] ?? null + } catch { + return null + } + }, promptId) + + expect(cacheEntry).not.toBeNull() + expect(typeof cacheEntry.filename).toBe('string') + expect(cacheEntry.filename.length).toBeGreaterThan(0) + }) + // ── #6 — No [QueueSidebar] console.warn during normal operation ──── test('no QueueSidebar console warnings during normal operation', async ({ page }) => { - const warnings = [] - page.on('console', msg => { - if (msg.type() === 'warning' && msg.text().includes('[QueueSidebar]')) { - warnings.push(msg.text()) + // Intercept console.warn at the JS level BEFORE page load so we capture + // warnings emitted during extension initialisation (i18n fetch failures, + // setup errors, etc.) — not just warnings emitted after test setup. + await page.addInitScript(() => { + window.__qsWarnings = [] + const origWarn = console.warn.bind(console) + console.warn = (...args) => { + const msg = args.map(a => (typeof a === 'string' ? a : String(a))).join(' ') + if (msg.includes('[QueueSidebar]')) window.__qsWarnings.push(msg) + origWarn(...args) } }) + // Navigate fresh so the init script runs before any extension code executes + await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 }) + await page.waitForTimeout(2000) // let extensions load + await openSidebar(page) + const wf = await getModifiedWorkflow(page) if (wf) { await queuePrompt(page, wf) @@ -175,6 +284,8 @@ test.describe('Queue Sidebar E2E', () => { } await page.waitForTimeout(1000) + + const warnings = await page.evaluate(() => window.__qsWarnings) expect(warnings).toEqual([]) }) }) diff --git a/tests/outputCache.test.js b/tests/outputCache.test.js new file mode 100644 index 0000000..1ef0ca6 --- /dev/null +++ b/tests/outputCache.test.js @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + saveOutputCache, + loadOutputCache, + firstOutput, + OUTPUT_CACHE_KEY, + OUTPUT_CACHE_MAX, +} from '../web/lib/outputCache.js' + +// clear localStorage before every test to ensure isolation across all describe blocks +beforeEach(() => localStorage.clear()) + +describe('loadOutputCache', () => { + it('returns null for unknown promptId', () => { + expect(loadOutputCache('nonexistent')).toBeNull() + }) + + it('returns null when localStorage value is corrupt JSON', () => { + localStorage.setItem(OUTPUT_CACHE_KEY, 'not-valid-json{{{') + expect(loadOutputCache('any')).toBeNull() + }) + + it('returns null for null or undefined promptId', () => { + expect(loadOutputCache(null)).toBeNull() + expect(loadOutputCache(undefined)).toBeNull() + }) +}) + +describe('saveOutputCache', () => { + it('round-trips an output object', () => { + const output = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] } + saveOutputCache('prompt-1', output) + expect(loadOutputCache('prompt-1')).toEqual(output) + }) + + it('overwrites existing entry for the same promptId', () => { + const first = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] } + const second = { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] } + saveOutputCache('prompt-1', first) + saveOutputCache('prompt-1', second) + expect(loadOutputCache('prompt-1')).toEqual(second) + }) + + it('returns null for a different promptId', () => { + saveOutputCache('prompt-1', { images: [{ filename: 'a.png' }] }) + expect(loadOutputCache('prompt-2')).toBeNull() + }) + + it('does not write to cache for falsy promptId', () => { + saveOutputCache(null, { filename: 'img.png', subfolder: '', type: 'output' }) + saveOutputCache(undefined, { filename: 'img.png', subfolder: '', type: 'output' }) + // cache 應維持空白 + const raw = localStorage.getItem(OUTPUT_CACHE_KEY) + expect(raw).toBeNull() + }) + + it('evicts the oldest entry when exceeding OUTPUT_CACHE_MAX', () => { + for (let i = 0; i < OUTPUT_CACHE_MAX; i++) { + saveOutputCache(`p-${i}`, { filename: `img-${i}.png`, subfolder: '', type: 'temp' }) + } + saveOutputCache('p-overflow', { filename: 'overflow.png', subfolder: '', type: 'temp' }) + + expect(loadOutputCache('p-0')).toBeNull() // 被踢出 + expect(loadOutputCache('p-1')).not.toBeNull() // 仍保留 + expect(loadOutputCache(`p-${OUTPUT_CACHE_MAX - 1}`)).not.toBeNull() // 仍保留 + expect(loadOutputCache('p-overflow')).not.toBeNull() // 新增的存在 + }) +}) + +describe('firstOutput', () => { + it('returns null when outputs is empty', () => { + expect(firstOutput({}, 'prompt-1')).toBeNull() + }) + + it('returns the first image found in dict order (no cache)', () => { + const outputs = { + nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }, + nodeB: { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] }, + } + expect(firstOutput(outputs, 'no-cache')).toEqual({ filename: 'a.png', subfolder: '', type: 'output' }) + }) + + it('returns cached output when promptId matches, ignoring dict order', () => { + const cached = { filename: 'cached.png', subfolder: 'sub', type: 'output' } + saveOutputCache('prompt-cached', cached) + const outputs = { + nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }, + } + expect(firstOutput(outputs, 'prompt-cached')).toEqual(cached) + }) + + it('falls back to dict iteration when cache misses', () => { + const outputs = { + nodeA: { images: [{ filename: 'fallback.png', subfolder: '', type: 'output' }] }, + } + expect(firstOutput(outputs, 'cache-miss')).toEqual({ filename: 'fallback.png', subfolder: '', type: 'output' }) + }) + + it('returns null when outputs contains only empty arrays', () => { + const outputs = { + nodeA: { images: [] }, + nodeB: { gifs: [] }, + } + expect(firstOutput(outputs, 'prompt-empty')).toBeNull() + }) + + it.each([ + ['gifs', { gifs: [{ filename: 'anim.gif', subfolder: '', type: 'output' }] }, { filename: 'anim.gif', subfolder: '', type: 'output' }], + ['video', { video: { filename: 'clip.mp4', subfolder: '', type: 'output' } }, { filename: 'clip.mp4', subfolder: '', type: 'output' }], + ['audio', { audio: { filename: 'sound.wav', subfolder: '', type: 'output' } }, { filename: 'sound.wav', subfolder: '', type: 'output' }], + ])('returns item for %s key', (_key, nodeOut, expected) => { + expect(firstOutput({ n: nodeOut }, undefined)).toEqual(expected) + }) + + it('prefers images key over later keys in the same node', () => { + const imgItem = { filename: 'img.png', subfolder: '', type: 'output' } + const gifItem = { filename: 'anim.gif', subfolder: '', type: 'output' } + const outputs = { + nodeA: { + images: [imgItem], + gifs: [gifItem], + }, + } + expect(firstOutput(outputs, 'prompt-prefer')).toEqual(imgItem) + }) +}) diff --git a/tests/preview.test.js b/tests/preview.test.js new file mode 100644 index 0000000..b8ea364 --- /dev/null +++ b/tests/preview.test.js @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + renderImagePreview, + renderVideoPreview, + renderOutputPreview, + makePreview, + updateRunningPreview, +} from '../web/lib/preview.js' + +function makeWrap() { + return document.createElement('div') +} + +function makeCardWithPreview() { + const card = document.createElement('div') + const preview = document.createElement('div') + preview.className = 'task-preview' + card.appendChild(preview) + return card +} + +// ─── renderImagePreview ─────────────────────────────────────────────────────── + +describe('renderImagePreview', () => { + it('contain mode: appends two img elements (blurred bg + foreground)', () => { + const wrap = makeWrap() + renderImagePreview(wrap, 'http://example.com/img.png', 'contain') + const imgs = wrap.querySelectorAll('img') + expect(imgs).toHaveLength(2) + expect(imgs[0].src).toBe('http://example.com/img.png') + expect(imgs[1].src).toBe('http://example.com/img.png') + }) + + it('cover mode: appends a single img element', () => { + const wrap = makeWrap() + renderImagePreview(wrap, 'http://example.com/img.png', 'cover') + const imgs = wrap.querySelectorAll('img') + expect(imgs).toHaveLength(1) + expect(imgs[0].src).toBe('http://example.com/img.png') + }) + + it('both modes set loading="lazy"', () => { + const wrapContain = makeWrap() + renderImagePreview(wrapContain, 'http://x.com/a.png', 'contain') + for (const img of wrapContain.querySelectorAll('img')) { + expect(img.loading).toBe('lazy') + } + + const wrapCover = makeWrap() + renderImagePreview(wrapCover, 'http://x.com/b.png', 'cover') + expect(wrapCover.querySelector('img').loading).toBe('lazy') + }) +}) + +// ─── renderVideoPreview ─────────────────────────────────────────────────────── + +describe('renderVideoPreview', () => { + it('appends a video element with correct src, muted, and loop attributes', () => { + const wrap = makeWrap() + renderVideoPreview(wrap, 'http://example.com/clip.mp4', 'contain') + const vid = wrap.querySelector('video') + expect(vid).not.toBeNull() + expect(vid.src).toBe('http://example.com/clip.mp4') + expect(vid.muted).toBe(true) + expect(vid.loop).toBe(true) + }) +}) + +// ─── renderOutputPreview ───────────────────────────────────────────────────── + +describe('renderOutputPreview', () => { + let firstOutput + let viewUrl + + beforeEach(() => { + viewUrl = vi.fn((output) => `http://comfy/view?filename=${output.filename}`) + firstOutput = vi.fn() + }) + + it('passes task.outputs and task.promptId to firstOutput', () => { + firstOutput.mockReturnValue(null) + const outputs = { '1': { images: [{ filename: 'a.png' }] } } + const task = { promptId: 'p-abc', status: 'completed', outputs } + renderOutputPreview(makeWrap(), task, { firstOutput, viewUrl, imageFit: 'contain' }) + expect(firstOutput).toHaveBeenCalledWith(outputs, 'p-abc') + }) + + it('shows check-circle icon when firstOutput returns null (completed)', () => { + firstOutput.mockReturnValue(null) + const wrap = makeWrap() + renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' }) + expect(wrap.querySelector('.pi-check-circle')).not.toBeNull() + }) + + it('shows exclamation-circle icon when firstOutput returns null (failed)', () => { + firstOutput.mockReturnValue(null) + const wrap = makeWrap() + renderOutputPreview(wrap, { promptId: 'p1', status: 'failed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' }) + expect(wrap.querySelector('.pi-exclamation-circle')).not.toBeNull() + }) + + it('renders img with the URL returned by viewUrl for image output', () => { + firstOutput.mockReturnValue({ filename: 'result.png', subfolder: '', type: 'output' }) + const wrap = makeWrap() + renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' }) + // contain mode renders two imgs (blurred bg + foreground); both get the view URL + const imgs = wrap.querySelectorAll('img') + expect(imgs.length).toBeGreaterThan(0) + expect(imgs[0].src).toBe('http://comfy/view?filename=result.png') + }) + + it('renders video with correct src, muted, and loop for video output', () => { + firstOutput.mockReturnValue({ filename: 'clip.mp4', subfolder: '', type: 'output' }) + const wrap = makeWrap() + renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' }) + const vid = wrap.querySelector('video') + expect(vid).not.toBeNull() + expect(vid.src).toBe('http://comfy/view?filename=clip.mp4') + expect(vid.muted).toBe(true) + expect(vid.loop).toBe(true) + }) + + it('renders volume-up icon for audio output', () => { + firstOutput.mockReturnValue({ filename: 'sound.mp3', subfolder: '', type: 'output' }) + const wrap = makeWrap() + renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' }) + expect(wrap.querySelector('.pi-volume-up')).not.toBeNull() + }) + + it('renders file icon for unknown file type', () => { + firstOutput.mockReturnValue({ filename: 'data.json', subfolder: '', type: 'output' }) + const wrap = makeWrap() + renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' }) + expect(wrap.querySelector('.pi-file')).not.toBeNull() + }) + + it('calls viewUrl with the output returned by firstOutput', () => { + const output = { filename: 'upscaled.png', subfolder: 'sub', type: 'output' } + firstOutput.mockReturnValue(output) + const wrap = makeWrap() + renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' }) + expect(viewUrl).toHaveBeenCalledWith(output) + }) +}) + +// ─── updateRunningPreview ───────────────────────────────────────────────────── + +describe('updateRunningPreview', () => { + it('creates img and sets src when progressUrl is provided', () => { + const card = makeCardWithPreview() + updateRunningPreview(card, 'blob:http://example.com/abc') + const img = card.querySelector('.task-preview img') + expect(img).not.toBeNull() + expect(img.src).toBe('blob:http://example.com/abc') + }) + + it('reuses existing img element and updates src', () => { + const card = makeCardWithPreview() + const preview = card.querySelector('.task-preview') + const img = document.createElement('img') + img.src = 'blob:http://example.com/old' + preview.appendChild(img) + + updateRunningPreview(card, 'blob:http://example.com/new') + + const imgs = card.querySelectorAll('.task-preview img') + expect(imgs).toHaveLength(1) + expect(imgs[0].src).toBe('blob:http://example.com/new') + }) + + it('calling with the same progressUrl does not create a duplicate img element', () => { + const card = makeCardWithPreview() + const preview = card.querySelector('.task-preview') + const img = document.createElement('img') + img.src = 'blob:http://example.com/same' + preview.appendChild(img) + + updateRunningPreview(card, 'blob:http://example.com/same') + + // Only one img should exist — not a second one appended + expect(card.querySelectorAll('.task-preview img')).toHaveLength(1) + }) + + it('shows spinner when progressUrl is null', () => { + const card = makeCardWithPreview() + updateRunningPreview(card, null) + expect(card.querySelector('.task-preview .pi-spin')).not.toBeNull() + }) + + it('replaces img with spinner when progressUrl becomes null', () => { + const card = makeCardWithPreview() + updateRunningPreview(card, 'blob:http://example.com/abc') + updateRunningPreview(card, null) + expect(card.querySelector('.task-preview .pi-spin')).not.toBeNull() + expect(card.querySelector('.task-preview img')).toBeNull() + }) + + it('does not recreate spinner if one is already present', () => { + const card = makeCardWithPreview() + const preview = card.querySelector('.task-preview') + preview.innerHTML = '' + const spinnerBefore = preview.querySelector('.pi-spin') + + updateRunningPreview(card, null) + + expect(preview.querySelector('.pi-spin')).toBe(spinnerBefore) // same node, no re-render + }) + + it('does nothing when card has no .task-preview element', () => { + const card = document.createElement('div') + expect(() => updateRunningPreview(card, 'blob:http://example.com/abc')).not.toThrow() + }) +}) + +// ─── makePreview ────────────────────────────────────────────────────────────── + +describe('makePreview', () => { + let deps + + beforeEach(() => { + deps = { + progressUrl: null, + firstOutput: vi.fn().mockReturnValue(null), + viewUrl: vi.fn((o) => `http://comfy/view?filename=${o.filename}`), + imageFit: 'contain', + } + }) + + it('always sets className to "task-preview"', () => { + const wrap = makePreview({ status: 'pending', outputs: {} }, deps) + expect(wrap.className).toBe('task-preview') + }) + + it('pending: shows dots placeholder', () => { + const wrap = makePreview({ status: 'pending', outputs: {} }, deps) + expect(wrap.textContent).toContain('···') + }) + + it('running with progressUrl: shows img with that src', () => { + deps.progressUrl = 'blob:http://example.com/latent' + const wrap = makePreview({ status: 'running', outputs: {} }, deps) + const img = wrap.querySelector('img') + expect(img).not.toBeNull() + expect(img.src).toBe('blob:http://example.com/latent') + }) + + it('running without progressUrl: shows spinner', () => { + deps.progressUrl = null + const wrap = makePreview({ status: 'running', outputs: {} }, deps) + expect(wrap.querySelector('.pi-spin')).not.toBeNull() + expect(wrap.querySelector('img')).toBeNull() + }) + + it('completed with no output: shows check-circle icon', () => { + const wrap = makePreview({ status: 'completed', outputs: {}, promptId: 'p1' }, deps) + expect(wrap.querySelector('.pi-check-circle')).not.toBeNull() + }) + + it('completed with image output: shows img with the URL from viewUrl', () => { + deps.firstOutput.mockReturnValue({ filename: 'result.png', subfolder: '', type: 'output' }) + const wrap = makePreview({ status: 'completed', outputs: {}, promptId: 'p1' }, deps) + const imgs = wrap.querySelectorAll('img') + expect(imgs.length).toBeGreaterThan(0) + expect(imgs[0].src).toBe('http://comfy/view?filename=result.png') + }) + + it('failed with no output: shows exclamation-circle icon', () => { + const wrap = makePreview({ status: 'failed', outputs: {}, promptId: 'p1' }, deps) + expect(wrap.querySelector('.pi-exclamation-circle')).not.toBeNull() + }) + + it('cancelled with no output: shows check-circle icon (non-failed fallback)', () => { + const wrap = makePreview({ status: 'cancelled', outputs: {}, promptId: 'p1' }, deps) + expect(wrap.querySelector('.pi-check-circle')).not.toBeNull() + }) + + it('running status: does not call firstOutput (progressUrl path only)', () => { + deps.progressUrl = 'blob:http://example.com/latent' + makePreview({ status: 'running', outputs: {}, promptId: 'p1' }, deps) + expect(deps.firstOutput).not.toHaveBeenCalled() + }) + + it('pending status: does not call firstOutput', () => { + makePreview({ status: 'pending', outputs: {} }, deps) + expect(deps.firstOutput).not.toHaveBeenCalled() + }) +}) diff --git a/web/lib/outputCache.js b/web/lib/outputCache.js new file mode 100644 index 0000000..1427292 --- /dev/null +++ b/web/lib/outputCache.js @@ -0,0 +1,78 @@ +// ─── Output Cache ───────────────────────────────────────────────────────────── + +export const OUTPUT_CACHE_KEY = 'queueSidebar.lastOutput' +export const OUTPUT_CACHE_MAX = 200 + +/** + * Persist an output object for a given promptId into localStorage. + * Evicts the oldest entry when the cache exceeds OUTPUT_CACHE_MAX. + * All JSON errors are silently caught. + * + * @param {string} promptId + * @param {object} output + */ +export function saveOutputCache(promptId, output) { + if (!promptId) return + try { + const raw = localStorage.getItem(OUTPUT_CACHE_KEY) + let cache = {} + try { + if (raw) cache = JSON.parse(raw) + } catch { + cache = {} + } + cache[promptId] = output + while (Object.keys(cache).length > OUTPUT_CACHE_MAX) { + delete cache[Object.keys(cache)[0]] + } + localStorage.setItem(OUTPUT_CACHE_KEY, JSON.stringify(cache)) + } catch { + // silent — storage failure must not crash the page + } +} + +/** + * Load the cached output for a given promptId. + * Returns null if not found or if JSON parsing fails. + * + * @param {string} promptId + * @returns {object|null} + */ +export function loadOutputCache(promptId) { + if (!promptId) return null + try { + const raw = localStorage.getItem(OUTPUT_CACHE_KEY) + if (!raw) return null + const cache = JSON.parse(raw) + return cache[promptId] ?? null + } catch { + return null + } +} + +// Keys checked in priority order when scanning node outputs +const OUTPUT_KEYS = ['images', 'gifs', 'video', 'audio'] + +/** + * Find the first media item from task outputs. + * Checks the localStorage cache first; falls back to iterating output values. + * + * @param {object} outputs - map of nodeId → node output object + * @param {string} promptId + * @returns {object|null} - a single media item with a `filename` property, or null + */ +export function firstOutput(outputs = {}, promptId) { + const cached = loadOutputCache(promptId) + if (cached !== null) return cached + + for (const nodeOutput of Object.values(outputs)) { + for (const key of OUTPUT_KEYS) { + if (!(key in nodeOutput)) continue + const val = nodeOutput[key] + const item = Array.isArray(val) ? val[0] : val + if (item && item.filename) return item + } + } + + return null +} diff --git a/web/lib/preview.js b/web/lib/preview.js index 48bf46b..47789dd 100644 --- a/web/lib/preview.js +++ b/web/lib/preview.js @@ -47,7 +47,7 @@ export function renderVideoPreview(wrap, url, imageFit) { */ export function renderOutputPreview(wrap, task, deps) { const { firstOutput, viewUrl, imageFit } = deps - const output = firstOutput(task.outputs) + const output = firstOutput(task.outputs, task.promptId) if (!output) { const icon = task.status === 'failed' ? 'pi-exclamation-circle' : 'pi-check-circle' const color = task.status === 'failed' ? STATUS_COLOR.failed : 'var(--p-text-muted-color,#888)' diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index 1137ca9..cfb9b6f 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -11,6 +11,7 @@ import { getComfyLocale, hookQueuePrompt, reorderQueueTab, updateTabBadge, normalizeQueue, normalizeHistoryItem, } from './lib/comfyAdapter.js' +import { firstOutput, saveOutputCache } from './lib/outputCache.js' // saveOutputCache wired in onExecuted (see below) // ─── i18n ────────────────────────────────────────────────────────────────────── // Translations are loaded from web/locales/.json at startup. @@ -53,6 +54,11 @@ const state = { // ─── Data helpers ───────────────────────────────────────────────────────────── +function clearProgressUrl() { + if (state.progressUrl?.startsWith('blob:')) URL.revokeObjectURL(state.progressUrl) + state.progressUrl = null +} + function viewUrl(result) { const p = new URLSearchParams({ filename: result.filename, @@ -62,18 +68,6 @@ function viewUrl(result) { return api.apiURL(`/view?${p}`) } -function firstOutput(outputs = {}) { - for (const nodeOutputs of Object.values(outputs)) { - for (const key of ['images', 'gifs', 'video', 'audio']) { - const val = nodeOutputs[key] - if (!val) continue - const item = Array.isArray(val) ? val[0] : val - if (item?.filename) return item - } - } - return null -} - // ─── Data fetching ──────────────────────────────────────────────────────────── async function fetchQueue() { @@ -114,7 +108,7 @@ async function refresh() { function galleryItems() { return state.history .map((task) => { - const output = firstOutput(task.outputs) + const output = firstOutput(task.outputs, task.promptId) if (!output) return null const type = mediaType(output.filename) if (type !== 'image' && type !== 'video') return null @@ -177,7 +171,7 @@ function makeCard(task) { card.appendChild(overlay) card.addEventListener('click', () => { - const output = firstOutput(task.outputs) + const output = firstOutput(task.outputs, task.promptId) if (!output) return const type = mediaType(output.filename) if (type !== 'image' && type !== 'video') return @@ -281,10 +275,7 @@ function onStatus() { } function onExecutionStart({ detail }) { - if (state.progressUrl) { - URL.revokeObjectURL(state.progressUrl) - state.progressUrl = null - } + clearProgressUrl() // Immediately move the task from pending → running using the prompt_id from the WS // event, without waiting for a /queue API fetch. This ensures the badge and card // appear instantly — including for fast or fully-cached workflows where the task @@ -304,12 +295,29 @@ function onExecutionStart({ detail }) { function onProgressPreview({ detail }) { if (state.running.length === 0) return - const prev = state.progressUrl - if (prev) URL.revokeObjectURL(prev) + clearProgressUrl() state.progressUrl = URL.createObjectURL(detail) render() } +function onExecuted({ detail }) { + const prompt_id = detail?.prompt_id + const output = detail?.output + if (!prompt_id || !state.running.some(t => t.promptId === prompt_id)) return + for (const key of ['images', 'gifs', 'video', 'audio']) { + const val = output?.[key] + if (!val || (Array.isArray(val) && val.length === 0)) continue + const item = Array.isArray(val) ? val[0] : val + if (item?.filename) { + clearProgressUrl() + state.progressUrl = viewUrl(item) + saveOutputCache(prompt_id, item) + render() + break + } + } +} + // ─── Badge style injection ───────────────────────────────────────────────────── function injectBadgeStyle() { @@ -356,6 +364,7 @@ app.registerExtension({ api.addEventListener('status', onStatus) api.addEventListener('execution_start', onExecutionStart) api.addEventListener('b_preview', onProgressPreview) + api.addEventListener('executed', onExecuted) app.extensionManager.registerSidebarTab({ id: 'queue', @@ -370,10 +379,7 @@ app.registerExtension({ }, destroy() { - if (state.progressUrl) { - URL.revokeObjectURL(state.progressUrl) - state.progressUrl = null - } + clearProgressUrl() gridEl = null scrollEl = null },