mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 14:54:12 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
62
src/stores/jobPreviewStore.ts
Normal file
62
src/stores/jobPreviewStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
27
src/utils/objectUrlUtil.ts
Normal file
27
src/utils/objectUrlUtil.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user