feat: show live previews for active jobs

This commit is contained in:
Benjamin Lu
2026-01-24 14:01:35 -08:00
parent 4ae93806c9
commit cff7e01eec
8 changed files with 200 additions and 4 deletions

56
plan.md Normal file
View File

@@ -0,0 +1,56 @@
# Implementation Plan: Active Job Previews in Assets Sidebar
## Goal
Expose mid-generation preview thumbnails (KSampler-style) in Assets sidebar active job cards and list items, honoring the existing preview setting (no new setting).
## Scope
Frontend files:
- src/components/sidebar/tabs/AssetsSidebarGridView.vue
- src/components/sidebar/tabs/AssetsSidebarListView.vue
- src/platform/assets/components/ActiveMediaAssetCard.vue
- src/platform/assets/components/AssetsListItem.vue
- Supporting composables/stores/utils as needed
Backend files:
- ../ComfyUI (local backend) settings + jobs/preview metadata
- ../cloud (cloud backend) settings defaults
## Plan
1) Investigate current preview signal paths
- Locate where live previews are received (websocket b_preview_with_metadata).
- Identify mapping from preview metadata to job/task (prompt_id, display_node_id).
- Verify existing job display data includes iconImageUrl only for completed jobs.
2) Use existing preview setting
- Gate live previews on `Comfy.Execution.PreviewMethod`.
- Treat `none` as disabled; `default`, `auto`, `latent2rgb`, `taesd` as enabled.
- No new setting or backend defaults.
3) Create a live-preview mapping store/composable
- Subscribe to websocket preview events and capture latest preview image per prompt_id.
- Use prompt_id from preview metadata to associate preview with running job.
- Store latest preview URL per job, and revoke old object URLs to avoid leaks.
- Respect the new setting: no capture/use when disabled.
4) Extend job list items with live preview URL
- In useJobList, read the live-preview store and add a new field (e.g., livePreviewUrl) to JobListItem.
- Ensure only active/running jobs and when preview setting enabled receive the preview.
- Keep iconImageUrl for completed jobs intact.
5) Wire UI components
- ActiveMediaAssetCard: prefer livePreviewUrl for running jobs; fallback to iconImageUrl.
- AssetsSidebarListView: pass livePreviewUrl to AssetsListItem for active jobs.
- AssetsListItem: allow explicit preview URL override for job rows without affecting asset rows.
6) Backend support
- ComfyUI: verify preview websocket metadata includes prompt_id (already present) and preview method is respected.
- Cloud: no settings changes expected; confirm preview metadata is available in websocket.
7) Tests
- Add/extend unit tests for job list (useJobList) to validate preview field wiring and setting gating.
- No settings default tests expected.
8) Manual verification checklist
- Start a generation; confirm active job cards/list items show live previews.
- Toggle setting off; confirm previews disappear.
- Completed jobs still show final preview where applicable.

View File

@@ -13,7 +13,7 @@
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-url="job.livePreviewUrl ?? job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"

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 live 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].livePreviewUrl).toBe('blob:preview-url')
})
it('omits live previews 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].livePreviewUrl).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'
@@ -38,6 +39,7 @@ export type JobListItem = {
state: JobState
iconName?: string
iconImageUrl?: string
livePreviewUrl?: string
showClear?: boolean
taskRef?: TaskItemImpl
progressTotalPercent?: number
@@ -96,6 +98,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 +259,11 @@ export function useJobList() {
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
const showAddedHint = shouldShowAddedHint(task, state)
const promptKey = taskIdToKey(task.promptId)
const livePreviewUrl =
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
? jobPreviewStore.previewsByPromptId[promptKey]
: undefined
const display = buildJobDisplay(task, state, {
t,
@@ -276,6 +284,7 @@ export function useJobList() {
state,
iconName: display.iconName,
iconImageUrl: display.iconImageUrl,
livePreviewUrl,
showClear: display.showClear,
taskRef: task,
progressTotalPercent:

View File

@@ -8,8 +8,8 @@
<div class="relative aspect-square overflow-hidden rounded-lg">
<!-- Running state with preview image -->
<img
v-if="isRunning && job.iconImageUrl"
:src="job.iconImageUrl"
v-if="isRunning && previewUrl"
:src="previewUrl"
:alt="statusText"
class="size-full object-cover"
/>
@@ -76,6 +76,7 @@ const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
const statusText = computed(() => job.title)
const progressPercent = computed(() => job.progressTotalPercent)
const previewUrl = computed(() => job.livePreviewUrl ?? job.iconImageUrl)
const isQueued = computed(
() => job.state === 'pending' || job.state === 'initialization'

View File

@@ -59,6 +59,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, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -695,9 +696,10 @@ 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()
useJobPreviewStore().setPreviewFromBlob(promptId, blob)
// Ensure clean up if `executing` event is missed.
revokePreviewsByExecutionId(displayNodeId)
const blobUrl = URL.createObjectURL(blob)

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(String(promptId))
}
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]

View File

@@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
type PromptPreviewMap = Record<string, string>
export const useJobPreviewStore = defineStore('jobPreview', () => {
const settingStore = useSettingStore()
const previewsByPromptId = ref<PromptPreviewMap>({})
const previewMethod = computed(() =>
settingStore.get('Comfy.Execution.PreviewMethod')
)
const isPreviewEnabled = computed(() => previewMethod.value !== 'none')
function revokePreviewUrl(url: string | undefined) {
if (!url) return
URL.revokeObjectURL(url)
}
function setPreviewUrl(promptId: string, url: string) {
const current = previewsByPromptId.value[promptId]
if (current) revokePreviewUrl(current)
previewsByPromptId.value = {
...previewsByPromptId.value,
[promptId]: url
}
}
function setPreviewFromBlob(promptId: string | undefined, blob: Blob) {
if (!promptId || !isPreviewEnabled.value) return
const url = URL.createObjectURL(blob)
setPreviewUrl(promptId, url)
}
function clearPreview(promptId: string | undefined) {
if (!promptId) return
const current = previewsByPromptId.value[promptId]
if (!current) return
revokePreviewUrl(current)
const next = { ...previewsByPromptId.value }
delete next[promptId]
previewsByPromptId.value = next
}
function clearAllPreviews() {
Object.values(previewsByPromptId.value).forEach((url) =>
revokePreviewUrl(url)
)
previewsByPromptId.value = {}
}
watch(isPreviewEnabled, (enabled) => {
if (!enabled) clearAllPreviews()
})
return {
previewsByPromptId,
isPreviewEnabled,
setPreviewFromBlob,
clearPreview,
clearAllPreviews
}
})