mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +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'
|
'cursor-default'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
:preview-url="job.iconImageUrl"
|
:preview-url="job.livePreviewUrl ?? job.iconImageUrl"
|
||||||
:preview-alt="job.title"
|
:preview-alt="job.title"
|
||||||
:icon-name="job.iconName"
|
:icon-name="job.iconName"
|
||||||
:icon-class="getJobIconClass(job)"
|
: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: {
|
let workflowStoreMock: {
|
||||||
activeWorkflow: null | { activeState?: { id?: string } }
|
activeWorkflow: null | { activeState?: { id?: string } }
|
||||||
}
|
}
|
||||||
@@ -186,6 +205,10 @@ const resetStores = () => {
|
|||||||
executionStore.activePromptId = null
|
executionStore.activePromptId = null
|
||||||
executionStore.executingNode = null
|
executionStore.executingNode = null
|
||||||
|
|
||||||
|
const jobPreviewStore = ensureJobPreviewStore()
|
||||||
|
jobPreviewStore.previewsByPromptId = {}
|
||||||
|
jobPreviewStore.isPreviewEnabled = true
|
||||||
|
|
||||||
const workflowStore = ensureWorkflowStore()
|
const workflowStore = ensureWorkflowStore()
|
||||||
workflowStore.activeWorkflow = null
|
workflowStore.activeWorkflow = null
|
||||||
|
|
||||||
@@ -437,6 +460,44 @@ describe('useJobList', () => {
|
|||||||
expect(otherJob.computeHours).toBeCloseTo(1)
|
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 () => {
|
it('derives current node name from execution store fallbacks', async () => {
|
||||||
const instance = initComposable()
|
const instance = initComposable()
|
||||||
await flush()
|
await flush()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { st } from '@/i18n'
|
|||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
|
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||||
import { useQueueStore } from '@/stores/queueStore'
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||||
import type { JobState } from '@/types/queue'
|
import type { JobState } from '@/types/queue'
|
||||||
@@ -38,6 +39,7 @@ export type JobListItem = {
|
|||||||
state: JobState
|
state: JobState
|
||||||
iconName?: string
|
iconName?: string
|
||||||
iconImageUrl?: string
|
iconImageUrl?: string
|
||||||
|
livePreviewUrl?: string
|
||||||
showClear?: boolean
|
showClear?: boolean
|
||||||
taskRef?: TaskItemImpl
|
taskRef?: TaskItemImpl
|
||||||
progressTotalPercent?: number
|
progressTotalPercent?: number
|
||||||
@@ -96,6 +98,7 @@ export function useJobList() {
|
|||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
|
const jobPreviewStore = useJobPreviewStore()
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
|
|
||||||
const seenPendingIds = ref<Set<string>>(new Set())
|
const seenPendingIds = ref<Set<string>>(new Set())
|
||||||
@@ -256,6 +259,11 @@ export function useJobList() {
|
|||||||
String(task.promptId ?? '') ===
|
String(task.promptId ?? '') ===
|
||||||
String(executionStore.activePromptId ?? '')
|
String(executionStore.activePromptId ?? '')
|
||||||
const showAddedHint = shouldShowAddedHint(task, state)
|
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, {
|
const display = buildJobDisplay(task, state, {
|
||||||
t,
|
t,
|
||||||
@@ -276,6 +284,7 @@ export function useJobList() {
|
|||||||
state,
|
state,
|
||||||
iconName: display.iconName,
|
iconName: display.iconName,
|
||||||
iconImageUrl: display.iconImageUrl,
|
iconImageUrl: display.iconImageUrl,
|
||||||
|
livePreviewUrl,
|
||||||
showClear: display.showClear,
|
showClear: display.showClear,
|
||||||
taskRef: task,
|
taskRef: task,
|
||||||
progressTotalPercent:
|
progressTotalPercent:
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<div class="relative aspect-square overflow-hidden rounded-lg">
|
<div class="relative aspect-square overflow-hidden rounded-lg">
|
||||||
<!-- Running state with preview image -->
|
<!-- Running state with preview image -->
|
||||||
<img
|
<img
|
||||||
v-if="isRunning && job.iconImageUrl"
|
v-if="isRunning && previewUrl"
|
||||||
:src="job.iconImageUrl"
|
:src="previewUrl"
|
||||||
:alt="statusText"
|
:alt="statusText"
|
||||||
class="size-full object-cover"
|
class="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -76,6 +76,7 @@ const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
|
|||||||
|
|
||||||
const statusText = computed(() => job.title)
|
const statusText = computed(() => job.title)
|
||||||
const progressPercent = computed(() => job.progressTotalPercent)
|
const progressPercent = computed(() => job.progressTotalPercent)
|
||||||
|
const previewUrl = computed(() => job.livePreviewUrl ?? job.iconImageUrl)
|
||||||
|
|
||||||
const isQueued = computed(
|
const isQueued = computed(
|
||||||
() => job.state === 'pending' || job.state === 'initialization'
|
() => job.state === 'pending' || job.state === 'initialization'
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
|||||||
import { useExtensionStore } from '@/stores/extensionStore'
|
import { useExtensionStore } from '@/stores/extensionStore'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
|
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||||
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
||||||
import { useModelStore } from '@/stores/modelStore'
|
import { useModelStore } from '@/stores/modelStore'
|
||||||
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
@@ -695,9 +696,10 @@ export class ComfyApp {
|
|||||||
|
|
||||||
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
|
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
|
||||||
// Enhanced preview with explicit node context
|
// Enhanced preview with explicit node context
|
||||||
const { blob, displayNodeId } = detail
|
const { blob, displayNodeId, promptId } = detail
|
||||||
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
|
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
|
||||||
useNodeOutputStore()
|
useNodeOutputStore()
|
||||||
|
useJobPreviewStore().setPreviewFromBlob(promptId, blob)
|
||||||
// Ensure clean up if `executing` event is missed.
|
// Ensure clean up if `executing` event is missed.
|
||||||
revokePreviewsByExecutionId(displayNodeId)
|
revokePreviewsByExecutionId(displayNodeId)
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
|
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||||
@@ -454,6 +455,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
const map = { ...nodeProgressStatesByPrompt.value }
|
const map = { ...nodeProgressStatesByPrompt.value }
|
||||||
delete map[promptId]
|
delete map[promptId]
|
||||||
nodeProgressStatesByPrompt.value = map
|
nodeProgressStatesByPrompt.value = map
|
||||||
|
useJobPreviewStore().clearPreview(String(promptId))
|
||||||
}
|
}
|
||||||
if (activePromptId.value) {
|
if (activePromptId.value) {
|
||||||
delete queuedPrompts.value[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