From 9209badd37c940930cd662eae1e0e33d521e7146 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 9 Feb 2026 10:49:27 -0800 Subject: [PATCH] feat: add KSampler live previews to assets sidebar jobs (#8723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Show live KSampler previews on active job cards/list items in the Assets sidebar, while preserving existing fallback behavior. ## Changes - **What**: - Added a prompt-scoped job preview store (`jobPreviewStore`) gated by `Comfy.Execution.PreviewMethod`. - Wired `b_preview_with_metadata` handling to map previews by `promptId`. - Extended queue job view model with `livePreviewUrl` and consumed it in both sidebar list and grid active job UIs. - Cleared prompt previews on execution reset. - Added ref-counted shared blob URL lifecycle utility (`objectUrlUtil`) and updated preview stores to retain/release shared URLs so each preview event creates one object URL. - Added/updated unit coverage in `useJobList.test.ts` for preview enable/disable mapping. ## Review Focus - Object URL lifecycle correctness across node previews and job previews (retain/release behavior). - Preview gating behavior when `Comfy.Execution.PreviewMethod` is `none`. - Active job UI fallback behavior (`livePreviewUrl` -> `iconImageUrl`). ## Screenshots (if applicable) image image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8723-feat-add-KSampler-live-previews-to-assets-sidebar-jobs-3006d73d365081aeb81dd8279bf99f94) by [Unito](https://www.unito.io) --- src/composables/queue/useJobList.test.ts | 61 +++++++++++++++++++++++ src/composables/queue/useJobList.ts | 9 +++- src/scripts/app.ts | 11 ++++- src/stores/executionStore.ts | 2 + src/stores/imagePreviewStore.ts | 32 +++++++++++- src/stores/jobPreviewStore.ts | 62 ++++++++++++++++++++++++ src/utils/objectUrlUtil.ts | 27 +++++++++++ 7 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 src/stores/jobPreviewStore.ts create mode 100644 src/utils/objectUrlUtil.ts diff --git a/src/composables/queue/useJobList.test.ts b/src/composables/queue/useJobList.test.ts index 0aaaa892c4..684ffa6364 100644 --- a/src/composables/queue/useJobList.test.ts +++ b/src/composables/queue/useJobList.test.ts @@ -134,6 +134,25 @@ vi.mock('@/stores/executionStore', () => ({ } })) +let jobPreviewStoreMock: { + previewsByPromptId: Record + isPreviewEnabled: boolean +} +const ensureJobPreviewStore = () => { + if (!jobPreviewStoreMock) { + jobPreviewStoreMock = reactive({ + previewsByPromptId: {} as Record, + isPreviewEnabled: true + }) + } + return jobPreviewStoreMock +} +vi.mock('@/stores/jobPreviewStore', () => ({ + useJobPreviewStore: () => { + return ensureJobPreviewStore() + } +})) + let workflowStoreMock: { activeWorkflow: null | { activeState?: { id?: string } } } @@ -186,6 +205,10 @@ const resetStores = () => { executionStore.activePromptId = null executionStore.executingNode = null + const jobPreviewStore = ensureJobPreviewStore() + jobPreviewStore.previewsByPromptId = {} + jobPreviewStore.isPreviewEnabled = true + const workflowStore = ensureWorkflowStore() workflowStore.activeWorkflow = null @@ -437,6 +460,44 @@ describe('useJobList', () => { expect(otherJob.computeHours).toBeCloseTo(1) }) + it('assigns preview urls for running jobs when previews enabled', async () => { + queueStoreMock.runningTasks = [ + createTask({ + promptId: 'live-preview', + queueIndex: 1, + mockState: 'running' + }) + ] + jobPreviewStoreMock.previewsByPromptId = { + 'live-preview': 'blob:preview-url' + } + jobPreviewStoreMock.isPreviewEnabled = true + + const { jobItems } = initComposable() + await flush() + + expect(jobItems.value[0].iconImageUrl).toBe('blob:preview-url') + }) + + it('omits preview urls when previews are disabled', async () => { + queueStoreMock.runningTasks = [ + createTask({ + promptId: 'disabled-preview', + queueIndex: 1, + mockState: 'running' + }) + ] + jobPreviewStoreMock.previewsByPromptId = { + 'disabled-preview': 'blob:preview-url' + } + jobPreviewStoreMock.isPreviewEnabled = false + + const { jobItems } = initComposable() + await flush() + + expect(jobItems.value[0].iconImageUrl).toBeUndefined() + }) + it('derives current node name from execution store fallbacks', async () => { const instance = initComposable() await flush() diff --git a/src/composables/queue/useJobList.ts b/src/composables/queue/useJobList.ts index 715778adbc..8eda4fca50 100644 --- a/src/composables/queue/useJobList.ts +++ b/src/composables/queue/useJobList.ts @@ -7,6 +7,7 @@ import { st } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useExecutionStore } from '@/stores/executionStore' +import { useJobPreviewStore } from '@/stores/jobPreviewStore' import { useQueueStore } from '@/stores/queueStore' import type { TaskItemImpl } from '@/stores/queueStore' import type { JobState } from '@/types/queue' @@ -96,6 +97,7 @@ export function useJobList() { const { t, locale } = useI18n() const queueStore = useQueueStore() const executionStore = useExecutionStore() + const jobPreviewStore = useJobPreviewStore() const workflowStore = useWorkflowStore() const seenPendingIds = ref>(new Set()) @@ -256,6 +258,11 @@ export function useJobList() { String(task.promptId ?? '') === String(executionStore.activePromptId ?? '') const showAddedHint = shouldShowAddedHint(task, state) + const promptKey = taskIdToKey(task.promptId) + const promptPreviewUrl = + state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey + ? jobPreviewStore.previewsByPromptId[promptKey] + : undefined const display = buildJobDisplay(task, state, { t, @@ -275,7 +282,7 @@ export function useJobList() { meta: display.secondary, state, iconName: display.iconName, - iconImageUrl: display.iconImageUrl, + iconImageUrl: promptPreviewUrl ?? display.iconImageUrl, showClear: display.showClear, taskRef: task, progressTotalPercent: diff --git a/src/scripts/app.ts b/src/scripts/app.ts index f40acf3a19..e750d24adc 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -61,6 +61,7 @@ import { useExecutionStore } from '@/stores/executionStore' import { useExtensionStore } from '@/stores/extensionStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' +import { useJobPreviewStore } from '@/stores/jobPreviewStore' import { KeyComboImpl } from '@/platform/keybindings/keyCombo' import { useKeybindingStore } from '@/platform/keybindings/keybindingStore' import { useModelStore } from '@/stores/modelStore' @@ -86,6 +87,10 @@ import { fixLinkInputSlots, isImageNode } from '@/utils/litegraphUtil' +import { + createSharedObjectUrl, + releaseSharedObjectUrl +} from '@/utils/objectUrlUtil' import { findLegacyRerouteNodes, noNativeReroutes @@ -701,12 +706,13 @@ export class ComfyApp { api.addEventListener('b_preview_with_metadata', ({ detail }) => { // Enhanced preview with explicit node context - const { blob, displayNodeId } = detail + const { blob, displayNodeId, promptId } = detail const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } = useNodeOutputStore() + const blobUrl = createSharedObjectUrl(blob) + useJobPreviewStore().setPreviewUrl(promptId, blobUrl) // Ensure clean up if `executing` event is missed. revokePreviewsByExecutionId(displayNodeId) - const blobUrl = URL.createObjectURL(blob) // Preview cleanup is handled in progress_state event to support multiple concurrent previews const nodeParents = displayNodeId.split(':') for (let i = 1; i <= nodeParents.length; i++) { @@ -714,6 +720,7 @@ export class ComfyApp { blobUrl ]) } + releaseSharedObjectUrl(blobUrl) }) api.init() diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index dda732af58..c3d4b33157 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -30,6 +30,7 @@ import type { import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { useNodeOutputStore } from '@/stores/imagePreviewStore' +import { useJobPreviewStore } from '@/stores/jobPreviewStore' import type { NodeLocatorId } from '@/types/nodeIdentification' import { createNodeLocatorId } from '@/types/nodeIdentification' import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil' @@ -454,6 +455,7 @@ export const useExecutionStore = defineStore('execution', () => { const map = { ...nodeProgressStatesByPrompt.value } delete map[promptId] nodeProgressStatesByPrompt.value = map + useJobPreviewStore().clearPreview(promptId) } if (activePromptId.value) { delete queuedPrompts.value[activePromptId.value] diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index 2dd13407c2..95c56a5211 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -15,6 +15,10 @@ import { useExecutionStore } from '@/stores/executionStore' import type { NodeLocatorId } from '@/types/nodeIdentification' import { parseFilePath } from '@/utils/formatUtil' import { isVideoNode } from '@/utils/litegraphUtil' +import { + releaseSharedObjectUrl, + retainSharedObjectUrl +} from '@/utils/objectUrlUtil' const PREVIEW_REVOKE_DELAY_MS = 400 @@ -216,10 +220,19 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { ) { const nodeLocatorId = executionIdToNodeLocatorId(executionId) if (!nodeLocatorId) return + const existingPreviews = app.nodePreviewImages[nodeLocatorId] if (scheduledRevoke[nodeLocatorId]) { scheduledRevoke[nodeLocatorId].stop() delete scheduledRevoke[nodeLocatorId] } + if (existingPreviews?.[Symbol.iterator]) { + for (const url of existingPreviews) { + releaseSharedObjectUrl(url) + } + } + for (const url of previewImages) { + retainSharedObjectUrl(url) + } latestPreview.value = previewImages app.nodePreviewImages[nodeLocatorId] = previewImages nodePreviewImages.value[nodeLocatorId] = previewImages @@ -237,10 +250,19 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { previewImages: string[] ) { const nodeLocatorId = nodeIdToNodeLocatorId(nodeId) + const existingPreviews = app.nodePreviewImages[nodeLocatorId] if (scheduledRevoke[nodeLocatorId]) { scheduledRevoke[nodeLocatorId].stop() delete scheduledRevoke[nodeLocatorId] } + if (existingPreviews?.[Symbol.iterator]) { + for (const url of existingPreviews) { + releaseSharedObjectUrl(url) + } + } + for (const url of previewImages) { + retainSharedObjectUrl(url) + } app.nodePreviewImages[nodeLocatorId] = previewImages nodePreviewImages.value[nodeLocatorId] = previewImages } @@ -270,7 +292,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { if (!previews?.[Symbol.iterator]) return for (const url of previews) { - URL.revokeObjectURL(url) + releaseSharedObjectUrl(url) } delete app.nodePreviewImages[nodeLocatorId] @@ -287,7 +309,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { if (!previews?.[Symbol.iterator]) continue for (const url of previews) { - URL.revokeObjectURL(url) + releaseSharedObjectUrl(url) } } app.nodePreviewImages = {} @@ -326,6 +348,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { // Clear preview images if (app.nodePreviewImages[nodeLocatorId]) { + const previews = app.nodePreviewImages[nodeLocatorId] + if (previews?.[Symbol.iterator]) { + for (const url of previews) { + releaseSharedObjectUrl(url) + } + } delete app.nodePreviewImages[nodeLocatorId] delete nodePreviewImages.value[nodeLocatorId] } diff --git a/src/stores/jobPreviewStore.ts b/src/stores/jobPreviewStore.ts new file mode 100644 index 0000000000..ce0b83a64a --- /dev/null +++ b/src/stores/jobPreviewStore.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia' +import { computed, readonly, ref, watch } from 'vue' + +import { useSettingStore } from '@/platform/settings/settingStore' +import { + releaseSharedObjectUrl, + retainSharedObjectUrl +} from '@/utils/objectUrlUtil' + +type PromptPreviewMap = Record + +export const useJobPreviewStore = defineStore('jobPreview', () => { + const settingStore = useSettingStore() + const previewsByPromptId = ref({}) + const readonlyPreviewsByPromptId = readonly(previewsByPromptId) + + const previewMethod = computed(() => + settingStore.get('Comfy.Execution.PreviewMethod') + ) + const isPreviewEnabled = computed(() => previewMethod.value !== 'none') + + function setPreviewUrl(promptId: string | undefined, url: string) { + if (!promptId || !isPreviewEnabled.value) return + const current = previewsByPromptId.value[promptId] + if (current === url) return + if (current) releaseSharedObjectUrl(current) + retainSharedObjectUrl(url) + previewsByPromptId.value = { + ...previewsByPromptId.value, + [promptId]: url + } + } + + function clearPreview(promptId: string | undefined) { + if (!promptId) return + const current = previewsByPromptId.value[promptId] + if (!current) return + releaseSharedObjectUrl(current) + const next = { ...previewsByPromptId.value } + delete next[promptId] + previewsByPromptId.value = next + } + + function clearAllPreviews() { + Object.values(previewsByPromptId.value).forEach((url) => { + releaseSharedObjectUrl(url) + }) + previewsByPromptId.value = {} + } + + watch(isPreviewEnabled, (enabled) => { + if (!enabled) clearAllPreviews() + }) + + return { + previewsByPromptId: readonlyPreviewsByPromptId, + isPreviewEnabled, + setPreviewUrl, + clearPreview, + clearAllPreviews + } +}) diff --git a/src/utils/objectUrlUtil.ts b/src/utils/objectUrlUtil.ts new file mode 100644 index 0000000000..b934997aed --- /dev/null +++ b/src/utils/objectUrlUtil.ts @@ -0,0 +1,27 @@ +const objectUrlRefCounts = new Map() + +const isBlobUrl = (url: string) => url.startsWith('blob:') + +export function createSharedObjectUrl(blob: Blob): string { + const url = URL.createObjectURL(blob) + objectUrlRefCounts.set(url, 1) + return url +} + +export function retainSharedObjectUrl(url: string | undefined): void { + if (!url || !isBlobUrl(url)) return + objectUrlRefCounts.set(url, (objectUrlRefCounts.get(url) ?? 0) + 1) +} + +export function releaseSharedObjectUrl(url: string | undefined): void { + if (!url || !isBlobUrl(url)) return + + const currentCount = objectUrlRefCounts.get(url) + if (currentCount === undefined || currentCount <= 1) { + objectUrlRefCounts.delete(url) + URL.revokeObjectURL(url) + return + } + + objectUrlRefCounts.set(url, currentCount - 1) +}