feat: add KSampler live previews to assets sidebar jobs (#8723)

## 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)
<img width="808" height="614" alt="image"
src="https://github.com/user-attachments/assets/37c66eb2-8c28-4eb4-bb86-5679cb77d740"
/>
<img width="775" height="345" alt="image"
src="https://github.com/user-attachments/assets/aa420642-b0d4-4ae6-b94a-e7934b5df9d6"
/>


┆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)
This commit is contained in:
Benjamin Lu
2026-02-09 10:49:27 -08:00
committed by GitHub
parent 815be49112
commit 9209badd37
7 changed files with 199 additions and 5 deletions

View File

@@ -134,6 +134,25 @@ vi.mock('@/stores/executionStore', () => ({
}
}))
let jobPreviewStoreMock: {
previewsByPromptId: Record<string, string>
isPreviewEnabled: boolean
}
const ensureJobPreviewStore = () => {
if (!jobPreviewStoreMock) {
jobPreviewStoreMock = reactive({
previewsByPromptId: {} as Record<string, string>,
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()

View File

@@ -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<Set<string>>(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:

View File

@@ -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()

View File

@@ -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]

View File

@@ -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]
}

View File

@@ -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<string, string>
export const useJobPreviewStore = defineStore('jobPreview', () => {
const settingStore = useSettingStore()
const previewsByPromptId = ref<PromptPreviewMap>({})
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
}
})

View File

@@ -0,0 +1,27 @@
const objectUrlRefCounts = new Map<string, number>()
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)
}