From 0483630f82abb9e06c8a10135ff1f87cbf5296cc Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 16 Sep 2025 17:34:04 -0700 Subject: [PATCH] Show sampling previews on Vue nodes (#5579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: simplify preview state provider - Remove unnecessary event listeners and manual syncing - Use computed() to directly reference app.nodePreviewImages - Eliminate data duplication and any types - Rely on Vue's reactivity for automatic updates - Follow established patterns from execution state provider * feat: optimize Vue node preview image display with reactive store - Move preview display logic from inline ternaries to computed properties - Add useNodePreviewState composable for preview state management - Implement reactive store approach using Pinia storeToRefs - Use VueUse useTimeoutFn for modern timeout management instead of window.setTimeout - Add v-memo optimization for preview image template rendering - Maintain proper sync between app.nodePreviewImages and reactive store state 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: update props usage for Vue 3.5 destructured props syntax * [refactor] improve code style and architecture based on review feedback - Replace inject pattern with direct store access in useNodePreviewState - Use optional chaining for more concise conditional checks - Use modern Array.at(-1) for accessing last element - Remove provide/inject for nodePreviewImages in favor of direct store refs - Update preview image styling: remove rounded borders, use flexible height - Simplify scheduleRevoke function with optional chaining Co-authored-by: DrJKL * [cleanup] remove unused NodePreviewImagesKey injection key Addresses knip unused export warning after switching from provide/inject to direct store access pattern. * [test] add mock for useNodePreviewState in LGraphNode test Fixes test failure after adding preview functionality to LGraphNode component. * [fix] update workflowStore import path after rebase Updates import to new location: @/platform/workflow/management/stores/workflowStore --------- Co-authored-by: Claude Co-authored-by: DrJKL --- .../vueNodes/components/LGraphNode.vue | 21 ++++++++ .../vueNodes/preview/useNodePreviewState.ts | 51 +++++++++++++++++++ src/stores/imagePreviewStore.ts | 42 +++++++++++++-- .../vueNodes/components/LGraphNode.spec.ts | 7 +++ 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index c66d7cabe..c2596a1b4 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -114,6 +114,18 @@ :lod-level="lodLevel" :image-urls="nodeImageUrls" /> + +
+ preview +
@@ -138,6 +150,7 @@ import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD' +import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState' import { ExecutedWsMessage } from '@/schemas/apiSchema' import { app } from '@/scripts/app' import { useExecutionStore } from '@/stores/executionStore' @@ -312,6 +325,14 @@ const separatorClasses = 'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full' const progressClasses = 'h-2 bg-primary-500 transition-all duration-300' +const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState( + nodeData.id, + { + isMinimalLOD, + isCollapsed + } +) + // Common condition computations to avoid repetition const shouldShowWidgets = computed( () => shouldRenderWidgets.value && nodeData.widgets?.length diff --git a/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts b/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts new file mode 100644 index 000000000..427cf20c0 --- /dev/null +++ b/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts @@ -0,0 +1,51 @@ +import { storeToRefs } from 'pinia' +import { type Ref, computed } from 'vue' + +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' + +export const useNodePreviewState = ( + nodeId: string, + options?: { + isMinimalLOD?: Ref + isCollapsed?: Ref + } +) => { + const workflowStore = useWorkflowStore() + const { nodePreviewImages } = storeToRefs(useNodeOutputStore()) + + const locatorId = computed(() => workflowStore.nodeIdToNodeLocatorId(nodeId)) + + const previewUrls = computed(() => { + const key = locatorId.value + if (!key) return undefined + const urls = nodePreviewImages.value[key] + return urls?.length ? urls : undefined + }) + + const hasPreview = computed(() => !!previewUrls.value?.length) + + const latestPreviewUrl = computed(() => { + const urls = previewUrls.value + return urls?.length ? urls.at(-1) : '' + }) + + const shouldShowPreviewImg = computed(() => { + if (!options?.isMinimalLOD || !options?.isCollapsed) { + return hasPreview.value + } + return ( + !options.isMinimalLOD.value && + !options.isCollapsed.value && + hasPreview.value + ) + }) + + return { + locatorId, + previewUrls, + hasPreview, + latestPreviewUrl, + shouldShowPreviewImg + } +} diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index 15920c1bd..4fc3adb80 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -1,3 +1,4 @@ +import { useTimeoutFn } from '@vueuse/core' import { defineStore } from 'pinia' import { ref } from 'vue' @@ -19,6 +20,8 @@ import type { NodeLocatorId } from '@/types/nodeIdentification' import { parseFilePath } from '@/utils/formatUtil' import { isVideoNode } from '@/utils/litegraphUtil' +const PREVIEW_REVOKE_DELAY_MS = 400 + const createOutputs = ( filenames: string[], type: ResultItemType, @@ -40,9 +43,26 @@ interface SetOutputOptions { export const useNodeOutputStore = defineStore('nodeOutput', () => { const { nodeIdToNodeLocatorId } = useWorkflowStore() const { executionIdToNodeLocatorId } = useExecutionStore() + const scheduledRevoke: Record void }> = {} + + function scheduleRevoke(locator: NodeLocatorId, cb: () => void) { + scheduledRevoke[locator]?.stop() + + const { stop } = useTimeoutFn(() => { + delete scheduledRevoke[locator] + cb() + }, PREVIEW_REVOKE_DELAY_MS) + + scheduledRevoke[locator] = { stop } + } const nodeOutputs = ref>({}) + // Reactive state for node preview images - mirrors app.nodePreviewImages + const nodePreviewImages = ref>( + app.nodePreviewImages || {} + ) + function getNodeOutputs( node: LGraphNode ): ExecutedWsMessage['output'] | undefined { @@ -196,8 +216,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { ) { const nodeLocatorId = executionIdToNodeLocatorId(executionId) if (!nodeLocatorId) return - + if (scheduledRevoke[nodeLocatorId]) { + scheduledRevoke[nodeLocatorId].stop() + delete scheduledRevoke[nodeLocatorId] + } app.nodePreviewImages[nodeLocatorId] = previewImages + nodePreviewImages.value[nodeLocatorId] = previewImages } /** @@ -212,7 +236,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { previewImages: string[] ) { const nodeLocatorId = nodeIdToNodeLocatorId(nodeId) + if (scheduledRevoke[nodeLocatorId]) { + scheduledRevoke[nodeLocatorId].stop() + delete scheduledRevoke[nodeLocatorId] + } app.nodePreviewImages[nodeLocatorId] = previewImages + nodePreviewImages.value[nodeLocatorId] = previewImages } /** @@ -224,8 +253,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { function revokePreviewsByExecutionId(executionId: string) { const nodeLocatorId = executionIdToNodeLocatorId(executionId) if (!nodeLocatorId) return - - revokePreviewsByLocatorId(nodeLocatorId) + scheduleRevoke(nodeLocatorId, () => + revokePreviewsByLocatorId(nodeLocatorId) + ) } /** @@ -243,6 +273,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { } delete app.nodePreviewImages[nodeLocatorId] + delete nodePreviewImages.value[nodeLocatorId] } /** @@ -259,6 +290,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { } } app.nodePreviewImages = {} + nodePreviewImages.value = {} } /** @@ -293,6 +325,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { // Clear preview images if (app.nodePreviewImages[nodeLocatorId]) { delete app.nodePreviewImages[nodeLocatorId] + delete nodePreviewImages.value[nodeLocatorId] } return hadOutputs @@ -318,6 +351,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { removeNodeOutputs, // State - nodeOutputs + nodeOutputs, + nodePreviewImages } }) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts index 6e34e2450..07c0a3081 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.spec.ts @@ -56,6 +56,13 @@ vi.mock( }) ) +vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({ + useNodePreviewState: vi.fn(() => ({ + latestPreviewUrl: computed(() => ''), + shouldShowPreviewImg: computed(() => false) + })) +})) + const i18n = createI18n({ legacy: false, locale: 'en',