mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 16:17:58 +00:00
Compare commits
4 Commits
ever-prese
...
accumulate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab04e6c2c | ||
|
|
ea5ffcc66e | ||
|
|
07dab97aed | ||
|
|
f83daa6f3b |
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
@@ -8,7 +8,6 @@ import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -211,9 +210,6 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
<Button class="ml-auto" @click="appModeStore.exitBuilder">
|
||||
{{ t('linearMode.builder.exit') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
|
||||
43
src/components/builder/BuilderExitButton.vue
Normal file
43
src/components/builder/BuilderExitButton.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<Button size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 'Escape' &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
dialogStore.dialogStack.length === 0 &&
|
||||
isBuilderMode.value
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onExitBuilder()
|
||||
}
|
||||
})
|
||||
|
||||
function onExitBuilder() {
|
||||
void appModeStore.exitBuilder()
|
||||
}
|
||||
</script>
|
||||
73
src/components/builder/BuilderMenu.vue
Normal file
73
src/components/builder/BuilderMenu.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<Popover :show-arrow="false" class="min-w-56 p-3">
|
||||
<template #button>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'absolute left-4 top-[calc(var(--workflow-tabs-height)+16px)] z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-3 shadow-interface transition-colors border-none',
|
||||
'bg-secondary-background hover:bg-secondary-background-hover',
|
||||
'data-[state=open]:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('linearMode.appModeToolbar.appBuilder') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none',
|
||||
hasOutputs
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
:disabled="!hasOutputs"
|
||||
@click="onSave(close)"
|
||||
>
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
{{ t('builderMenu.saveApp') }}
|
||||
</button>
|
||||
<div class="my-1 border-t border-border-default" />
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none hover:bg-secondary-background-hover"
|
||||
@click="onExitBuilder(close)"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] size-4" />
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</button>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { setSaving } = useBuilderSave()
|
||||
|
||||
function onSave(close: () => void) {
|
||||
setSaving(true)
|
||||
close()
|
||||
}
|
||||
|
||||
function onExitBuilder(close: () => void) {
|
||||
void appModeStore.exitBuilder()
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
|
||||
:aria-label="t('builderToolbar.label')"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -3349,5 +3349,9 @@
|
||||
"saveSuccessAppPrompt": "Would you like to view it now?",
|
||||
"saveSuccessGraphMessage": "'{name}' has been saved. It will open as a node graph by default.",
|
||||
"viewApp": "View app"
|
||||
},
|
||||
"builderMenu": {
|
||||
"saveApp": "Save app",
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const activeJobIdRef = ref<string | null>(null)
|
||||
const previewsRef = ref<Record<string, string>>({})
|
||||
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
|
||||
const isAppModeRef = ref(true)
|
||||
|
||||
const { apiTarget } = vi.hoisted(() => ({
|
||||
@@ -30,7 +30,7 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
|
||||
vi.mock('@/stores/jobPreviewStore', () => ({
|
||||
useJobPreviewStore: () => ({
|
||||
get previewsByPromptId() {
|
||||
get nodePreviewsByPromptId() {
|
||||
return previewsRef.value
|
||||
}
|
||||
})
|
||||
@@ -63,12 +63,13 @@ function makeExecutedDetail(
|
||||
promptId: string,
|
||||
images: Array<Record<string, string>> = [
|
||||
{ filename: 'out.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
],
|
||||
nodeId = '1'
|
||||
): ExecutedWsMessage {
|
||||
return {
|
||||
prompt_id: promptId,
|
||||
node: '1',
|
||||
display_node: '1',
|
||||
node: nodeId,
|
||||
display_node: nodeId,
|
||||
output: { images }
|
||||
} as ExecutedWsMessage
|
||||
}
|
||||
@@ -329,7 +330,7 @@ describe('linearOutputStore', () => {
|
||||
expect(store.selectedId).toBeNull()
|
||||
})
|
||||
|
||||
it('transitions to latent via previewsByPromptId watcher', async () => {
|
||||
it('transitions to latent via previews watcher', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
@@ -341,7 +342,9 @@ describe('linearOutputStore', () => {
|
||||
expect(store.inProgressItems[0].state).toBe('skeleton')
|
||||
|
||||
// Simulate jobPreviewStore update
|
||||
previewsRef.value = { 'job-1': 'blob:preview-1' }
|
||||
previewsRef.value = {
|
||||
'job-1': { url: 'blob:preview-1', nodeId: 'node-1' }
|
||||
}
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
@@ -487,6 +490,104 @@ describe('linearOutputStore', () => {
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('discards latent previews for already-executed nodes', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Node 1 sends latent then executes
|
||||
store.onLatentPreview('job-1', 'blob:node1-latent', '1')
|
||||
vi.advanceTimersByTime(16)
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
||||
|
||||
// Stale latent for node 1 arrives after it already executed
|
||||
store.onLatentPreview('job-1', 'blob:node1-stale', '1')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Should not create a new latent item for the executed node
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('accepts latent previews for new nodes after prior node executed', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Node 1 executes
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
||||
|
||||
// Node 2 sends latent preview — should be accepted
|
||||
store.onLatentPreview('job-1', 'blob:node2-latent', '2')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(1)
|
||||
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:node2-latent')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels pending RAF when a node executes', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Latent preview scheduled in RAF
|
||||
store.onLatentPreview('job-1', 'blob:node1-latent')
|
||||
// Node executes before RAF fires — should cancel it
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Only the image item, no latent
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('discards latent previews arriving after job completion', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Latent preview scheduled in RAF before job completes
|
||||
store.onLatentPreview('job-1', 'blob:late')
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
// RAF fires after completion — should be cancelled
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// No new latent items should have been created
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('discards latent previews for completed job after RAF', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
// Late preview arrives after job already completed
|
||||
store.onLatentPreview('job-1', 'blob:very-late')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ignores executed events for other jobs', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const isFollowing = ref(true)
|
||||
const trackedJobId = ref<string | null>(null)
|
||||
const pendingResolve = ref(new Set<string>())
|
||||
const executedNodeIds = new Set<string>()
|
||||
|
||||
let nextSeq = 0
|
||||
|
||||
@@ -40,6 +41,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const currentSkeletonId = shallowRef<string | null>(null)
|
||||
|
||||
function onJobStart(jobId: string) {
|
||||
executedNodeIds.clear()
|
||||
const item: InProgressItem = {
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
@@ -53,7 +55,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
|
||||
let raf: number | null = null
|
||||
function onLatentPreview(jobId: string, url: string) {
|
||||
function onLatentPreview(jobId: string, url: string, nodeId?: string) {
|
||||
if (nodeId && executedNodeIds.has(nodeId)) return
|
||||
|
||||
// Issue in Firefox where it doesnt seem to always re-render, wrapping in RAF fixes it
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = requestAnimationFrame(() => {
|
||||
@@ -89,6 +93,11 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
|
||||
function onNodeExecuted(jobId: string, detail: ExecutedWsMessage) {
|
||||
const nodeId = String(detail.display_node || detail.node)
|
||||
executedNodeIds.add(nodeId)
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
||||
if (newOutputs.length === 0) return
|
||||
|
||||
@@ -134,7 +143,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
|
||||
function onJobComplete(jobId: string) {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
currentSkeletonId.value = null
|
||||
if (trackedJobId.value === jobId) {
|
||||
trackedJobId.value = null
|
||||
}
|
||||
|
||||
const hasImages = inProgressItems.value.some(
|
||||
(i) => i.jobId === jobId && i.state === 'image'
|
||||
@@ -209,6 +225,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
executedNodeIds.clear()
|
||||
inProgressItems.value = []
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
@@ -231,13 +248,13 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
)
|
||||
|
||||
watch(
|
||||
() => jobPreviewStore.previewsByPromptId,
|
||||
() => jobPreviewStore.nodePreviewsByPromptId,
|
||||
(previews) => {
|
||||
if (!isAppMode.value) return
|
||||
const jobId = executionStore.activeJobId
|
||||
if (!jobId) return
|
||||
const url = previews[jobId]
|
||||
if (url) onLatentPreview(jobId, url)
|
||||
const preview = previews[jobId]
|
||||
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
@@ -717,11 +717,15 @@ export class ComfyApp {
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const executionId = String(detail.display_node || detail.node)
|
||||
|
||||
const node = getNodeByExecutionId(this.rootGraph, executionId)
|
||||
const accumulate = node?.widgets?.find(
|
||||
(w) => w.name === 'accumulate'
|
||||
)?.value
|
||||
|
||||
nodeOutputStore.setNodeOutputsByExecutionId(executionId, detail.output, {
|
||||
merge: detail.merge
|
||||
merge: detail.merge || !!accumulate
|
||||
})
|
||||
|
||||
const node = getNodeByExecutionId(this.rootGraph, executionId)
|
||||
if (node && node.onExecuted) {
|
||||
node.onExecuted(detail.output)
|
||||
}
|
||||
@@ -761,7 +765,7 @@ export class ComfyApp {
|
||||
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
|
||||
useNodeOutputStore()
|
||||
const blobUrl = createSharedObjectUrl(blob)
|
||||
useJobPreviewStore().setPreviewUrl(jobId, blobUrl)
|
||||
useJobPreviewStore().setPreviewUrl(jobId, blobUrl, displayNodeId)
|
||||
// Ensure clean up if `executing` event is missed.
|
||||
revokePreviewsByExecutionId(displayNodeId)
|
||||
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
|
||||
|
||||
@@ -2,11 +2,9 @@ import { defineStore } from 'pinia'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { t } from '@/i18n'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
@@ -69,14 +67,6 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
)
|
||||
|
||||
async function exitBuilder() {
|
||||
if (
|
||||
!(await useDialogService().confirm({
|
||||
title: t('linearMode.builder.exitConfirmTitle'),
|
||||
message: t('linearMode.builder.exitConfirmMessage')
|
||||
}))
|
||||
)
|
||||
return
|
||||
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) workflow.dirtyLinearData = null
|
||||
resetSelectedToWorkflow()
|
||||
|
||||
102
src/stores/jobPreviewStore.test.ts
Normal file
102
src/stores/jobPreviewStore.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { releaseSharedObjectUrl } from '@/utils/objectUrlUtil'
|
||||
|
||||
const previewMethodRef = ref('latent2rgb')
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'Comfy.Execution.PreviewMethod') return previewMethodRef.value
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/objectUrlUtil', () => ({
|
||||
retainSharedObjectUrl: vi.fn(),
|
||||
releaseSharedObjectUrl: vi.fn()
|
||||
}))
|
||||
|
||||
describe('jobPreviewStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
previewMethodRef.value = 'latent2rgb'
|
||||
})
|
||||
|
||||
it('stores preview with nodeId', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('prompt-1', 'blob:url-1', 'node-5')
|
||||
|
||||
expect(store.nodePreviewsByPromptId['prompt-1']).toEqual({
|
||||
url: 'blob:url-1',
|
||||
nodeId: 'node-5'
|
||||
})
|
||||
})
|
||||
|
||||
it('stores preview without nodeId', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('prompt-1', 'blob:url-1')
|
||||
|
||||
expect(store.nodePreviewsByPromptId['prompt-1']).toEqual({
|
||||
url: 'blob:url-1',
|
||||
nodeId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('derives previewsByPromptId as url-only map', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
store.setPreviewUrl('p2', 'blob:b', 'node-2')
|
||||
|
||||
expect(store.previewsByPromptId).toEqual({
|
||||
p1: 'blob:a',
|
||||
p2: 'blob:b'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a single preview', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
store.setPreviewUrl('p2', 'blob:b', 'node-2')
|
||||
|
||||
store.clearPreview('p1')
|
||||
|
||||
expect(store.nodePreviewsByPromptId['p1']).toBeUndefined()
|
||||
expect(store.nodePreviewsByPromptId['p2']).toBeDefined()
|
||||
expect(store.previewsByPromptId).toEqual({ p2: 'blob:b' })
|
||||
})
|
||||
|
||||
it('clears all previews', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
store.setPreviewUrl('p2', 'blob:b', 'node-2')
|
||||
|
||||
store.clearAllPreviews()
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
expect(store.previewsByPromptId).toEqual({})
|
||||
})
|
||||
|
||||
it('skips duplicate url', () => {
|
||||
const store = useJobPreviewStore()
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
expect(releaseSharedObjectUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores setPreviewUrl when previews are disabled', () => {
|
||||
previewMethodRef.value = 'none'
|
||||
const store = useJobPreviewStore()
|
||||
|
||||
store.setPreviewUrl('p1', 'blob:a', 'node-1')
|
||||
|
||||
expect(store.nodePreviewsByPromptId).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -8,44 +8,59 @@ import {
|
||||
} from '@/utils/objectUrlUtil'
|
||||
|
||||
type PromptPreviewMap = Record<string, string>
|
||||
interface NodePromptPreview {
|
||||
url: string
|
||||
nodeId?: string
|
||||
}
|
||||
|
||||
export const useJobPreviewStore = defineStore('jobPreview', () => {
|
||||
const settingStore = useSettingStore()
|
||||
const previewsByPromptId = ref<PromptPreviewMap>({})
|
||||
const readonlyPreviewsByPromptId = readonly(previewsByPromptId)
|
||||
const nodePreviewsByPromptId = ref<Record<string, NodePromptPreview>>({})
|
||||
|
||||
const previewMethod = computed(() =>
|
||||
settingStore.get('Comfy.Execution.PreviewMethod')
|
||||
)
|
||||
const isPreviewEnabled = computed(() => previewMethod.value !== 'none')
|
||||
|
||||
function setPreviewUrl(promptId: string | undefined, url: string) {
|
||||
const previewsByPromptId = computed(() => {
|
||||
const result: PromptPreviewMap = {}
|
||||
for (const [k, v] of Object.entries(nodePreviewsByPromptId.value)) {
|
||||
result[k] = v.url
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function setPreviewUrl(
|
||||
promptId: string | undefined,
|
||||
url: string,
|
||||
nodeId?: string
|
||||
) {
|
||||
if (!promptId || !isPreviewEnabled.value) return
|
||||
const current = previewsByPromptId.value[promptId]
|
||||
if (current === url) return
|
||||
if (current) releaseSharedObjectUrl(current)
|
||||
const current = nodePreviewsByPromptId.value[promptId]
|
||||
if (current?.url === url) return
|
||||
if (current) releaseSharedObjectUrl(current.url)
|
||||
retainSharedObjectUrl(url)
|
||||
previewsByPromptId.value = {
|
||||
...previewsByPromptId.value,
|
||||
[promptId]: url
|
||||
nodePreviewsByPromptId.value = {
|
||||
...nodePreviewsByPromptId.value,
|
||||
[promptId]: { url, nodeId }
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreview(promptId: string | undefined) {
|
||||
if (!promptId) return
|
||||
const current = previewsByPromptId.value[promptId]
|
||||
const current = nodePreviewsByPromptId.value[promptId]
|
||||
if (!current) return
|
||||
releaseSharedObjectUrl(current)
|
||||
const next = { ...previewsByPromptId.value }
|
||||
releaseSharedObjectUrl(current.url)
|
||||
const next = { ...nodePreviewsByPromptId.value }
|
||||
delete next[promptId]
|
||||
previewsByPromptId.value = next
|
||||
nodePreviewsByPromptId.value = next
|
||||
}
|
||||
|
||||
function clearAllPreviews() {
|
||||
Object.values(previewsByPromptId.value).forEach((url) => {
|
||||
for (const { url } of Object.values(nodePreviewsByPromptId.value)) {
|
||||
releaseSharedObjectUrl(url)
|
||||
})
|
||||
previewsByPromptId.value = {}
|
||||
}
|
||||
nodePreviewsByPromptId.value = {}
|
||||
}
|
||||
|
||||
watch(isPreviewEnabled, (enabled) => {
|
||||
@@ -53,7 +68,8 @@ export const useJobPreviewStore = defineStore('jobPreview', () => {
|
||||
})
|
||||
|
||||
return {
|
||||
previewsByPromptId: readonlyPreviewsByPromptId,
|
||||
nodePreviewsByPromptId: readonly(nodePreviewsByPromptId),
|
||||
previewsByPromptId,
|
||||
isPreviewEnabled,
|
||||
setPreviewUrl,
|
||||
clearPreview,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { NodeSearchService } from '@/services/nodeSearchService'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import {
|
||||
NodeSourceType,
|
||||
CORE_NODE_MODULES,
|
||||
getEssentialsCategory,
|
||||
getNodeSource
|
||||
} from '@/types/nodeSource'
|
||||
@@ -92,6 +92,7 @@ export class ComfyNodeDefImpl
|
||||
readonly essentials_category?: string
|
||||
/** Whether the blueprint is a global/installed blueprint (not user-created). */
|
||||
readonly isGlobal?: boolean
|
||||
readonly isCoreNode: boolean
|
||||
|
||||
// V2 fields
|
||||
readonly inputs: Record<string, InputSpecV2>
|
||||
@@ -169,6 +170,9 @@ export class ComfyNodeDefImpl
|
||||
obj.essentials_category
|
||||
)
|
||||
this.isGlobal = obj.isGlobal
|
||||
this.isCoreNode = CORE_NODE_MODULES.includes(
|
||||
this.python_module.split('.')[0]
|
||||
)
|
||||
|
||||
// Initialize V2 fields
|
||||
const defV2 = transformNodeDefV1ToV2(obj)
|
||||
@@ -198,10 +202,6 @@ export class ComfyNodeDefImpl
|
||||
return [scores[0], -nodeFrequency, ...scores.slice(1)]
|
||||
}
|
||||
|
||||
get isCoreNode(): boolean {
|
||||
return this.nodeSource.type === NodeSourceType.Core
|
||||
}
|
||||
|
||||
get nodeLifeCycleBadgeText(): string {
|
||||
if (this.deprecated) return '[DEPR]'
|
||||
if (this.experimental) return '[BETA]'
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum NodeSourceType {
|
||||
Essentials = 'essentials',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
export const CORE_NODE_MODULES = ['nodes', 'comfy_extras', 'comfy_api_nodes']
|
||||
|
||||
export type NodeSource = {
|
||||
type: NodeSourceType
|
||||
@@ -68,9 +69,7 @@ export const getNodeSource = (
|
||||
displayText: displayName,
|
||||
badgeText: displayName
|
||||
}
|
||||
} else if (
|
||||
['nodes', 'comfy_extras', 'comfy_api_nodes'].includes(modules[0])
|
||||
) {
|
||||
} else if (CORE_NODE_MODULES.includes(modules[0])) {
|
||||
return {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'comfy-core',
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
<GraphCanvas @ready="onGraphReady" />
|
||||
</div>
|
||||
<LinearView v-if="linearMode" />
|
||||
<BuilderToolbar v-if="isBuilderMode" />
|
||||
<template v-if="isBuilderMode">
|
||||
<BuilderToolbar />
|
||||
<BuilderMenu />
|
||||
<BuilderExitButton />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<GlobalToast />
|
||||
@@ -87,6 +91,8 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import BuilderExitButton from '@/components/builder/BuilderExitButton.vue'
|
||||
import BuilderMenu from '@/components/builder/BuilderMenu.vue'
|
||||
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
|
||||
|
||||
Reference in New Issue
Block a user