mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feat: show live previews for active jobs
This commit is contained in:
56
plan.md
Normal file
56
plan.md
Normal 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.
|
||||
@@ -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)"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
65
src/stores/jobPreviewStore.ts
Normal file
65
src/stores/jobPreviewStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user