diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..e7d9f72e3 --- /dev/null +++ b/plan.md @@ -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. diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.vue b/src/components/sidebar/tabs/AssetsSidebarListView.vue index 6f454bb07..c9b1256ca 100644 --- a/src/components/sidebar/tabs/AssetsSidebarListView.vue +++ b/src/components/sidebar/tabs/AssetsSidebarListView.vue @@ -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)" diff --git a/src/composables/queue/useJobList.test.ts b/src/composables/queue/useJobList.test.ts index 0aaaa892c..9a1c2f3ca 100644 --- a/src/composables/queue/useJobList.test.ts +++ b/src/composables/queue/useJobList.test.ts @@ -134,6 +134,25 @@ vi.mock('@/stores/executionStore', () => ({ } })) +let jobPreviewStoreMock: { + previewsByPromptId: Record + isPreviewEnabled: boolean +} +const ensureJobPreviewStore = () => { + if (!jobPreviewStoreMock) { + jobPreviewStoreMock = reactive({ + previewsByPromptId: {} as Record, + 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() diff --git a/src/composables/queue/useJobList.ts b/src/composables/queue/useJobList.ts index 715778adb..60f1ab568 100644 --- a/src/composables/queue/useJobList.ts +++ b/src/composables/queue/useJobList.ts @@ -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>(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: diff --git a/src/platform/assets/components/ActiveMediaAssetCard.vue b/src/platform/assets/components/ActiveMediaAssetCard.vue index 8121d9bc3..2a3966a9c 100644 --- a/src/platform/assets/components/ActiveMediaAssetCard.vue +++ b/src/platform/assets/components/ActiveMediaAssetCard.vue @@ -8,8 +8,8 @@
@@ -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' diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 0a794ab9d..dcb22ac27 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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) diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index dda732af5..55ff81150 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -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] diff --git a/src/stores/jobPreviewStore.ts b/src/stores/jobPreviewStore.ts new file mode 100644 index 000000000..6aa110c8f --- /dev/null +++ b/src/stores/jobPreviewStore.ts @@ -0,0 +1,65 @@ +import { defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +import { useSettingStore } from '@/platform/settings/settingStore' + +type PromptPreviewMap = Record + +export const useJobPreviewStore = defineStore('jobPreview', () => { + const settingStore = useSettingStore() + const previewsByPromptId = ref({}) + + 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 + } +})