mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
feat: App mode - add execution status messages (#10369)
## Summary Adds custom status messages that are shown under the previews in order to provide additional progress feedback to the user Nodes matching the words: Save, Preview -> Saving Load, Loader -> Loading Encode -> Encoding Decode -> Decoding Compile, Conditioning, Merge, -> Processing Upscale, Resize -> Resizing ToVideo -> Generating video Specific nodes: KSampler, KSamplerAdvanced, SamplerCustom, SamplerCustomAdvanced -> Generating Video Slice, GetVideoComponents, CreateVideo -> Processing video TrainLoraNode -> Training ## Changes - **What**: - add specific node lookups for non-easily matchable patterns - add regex based matching for common patterns - show on both latent preview & skeleton preview - allow app mode workflow authors to override status with custom property `Execution Message` (no UI for doing this) ## Review Focus This is purely pattern/lookup based, in future we could update the backend node schema to allow nodes to define their own status key. ## Screenshots (if applicable) <img width="757" height="461" alt="image" src="https://github.com/user-attachments/assets/2b32cc54-c4e7-4aeb-912d-b39ac8428be7" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10369-feat-App-mode-add-execution-status-messages-32a6d73d3650814e8ca2da5eb33f3b65) by [Unito](https://www.unito.io) --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -3706,5 +3706,18 @@
|
||||
"footer": "ComfyUI stays free and open source. Cloud is optional.",
|
||||
"continueLocally": "Continue Locally",
|
||||
"exploreCloud": "Try Cloud for Free"
|
||||
},
|
||||
"execution": {
|
||||
"generating": "Generating…",
|
||||
"saving": "Saving…",
|
||||
"loading": "Loading…",
|
||||
"encoding": "Encoding…",
|
||||
"decoding": "Decoding…",
|
||||
"processing": "Processing…",
|
||||
"resizing": "Resizing…",
|
||||
"generatingVideo": "Generating video…",
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,29 @@
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { executionStatusMessage } = useExecutionStatus()
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { src } = defineProps<{
|
||||
const { src, showSize = true } = defineProps<{
|
||||
src: string
|
||||
mobile?: boolean
|
||||
label?: string
|
||||
showSize?: boolean
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
const width = ref('')
|
||||
const height = ref('')
|
||||
const width = ref<number | null>(null)
|
||||
const height = ref<number | null>(null)
|
||||
|
||||
function onImageLoad() {
|
||||
if (!imageRef.value || !showSize) return
|
||||
width.value = imageRef.value.naturalWidth
|
||||
height.value = imageRef.value.naturalHeight
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane
|
||||
@@ -27,13 +37,7 @@ const height = ref('')
|
||||
:src
|
||||
v-bind="slotProps"
|
||||
class="size-full object-contain"
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
</ZoomPane>
|
||||
<img
|
||||
@@ -41,15 +45,15 @@ const height = ref('')
|
||||
ref="imageRef"
|
||||
class="grow object-contain contain-size"
|
||||
:src
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<span class="self-center md:z-10">
|
||||
<span
|
||||
v-if="executionStatusMessage"
|
||||
class="animate-pulse self-center text-muted md:z-10"
|
||||
>
|
||||
{{ executionStatusMessage }}
|
||||
</span>
|
||||
<span v-else-if="width && height" class="self-center md:z-10">
|
||||
{{ `${width} x ${height}` }}
|
||||
<template v-if="label"> | {{ label }}</template>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
|
||||
const { executionStatusMessage } = useExecutionStatus()
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
<div
|
||||
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
|
||||
/>
|
||||
<div
|
||||
class="sz-full flex min-h-0 flex-1 flex-col items-center justify-center gap-3"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div
|
||||
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="executionStatusMessage"
|
||||
class="animate-pulse text-sm text-muted"
|
||||
>
|
||||
{{ executionStatusMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -136,6 +136,7 @@ async function rerun(e: Event) {
|
||||
v-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="latentPreview"
|
||||
:show-size="false"
|
||||
/>
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import { getExecutionStatusMessage } from './getExecutionStatusMessage'
|
||||
|
||||
// Pass-through t so we can assert the i18n key
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('getExecutionStatusMessage', () => {
|
||||
describe('custom messages', () => {
|
||||
it('returns custom message from properties when set', () => {
|
||||
expect(
|
||||
getExecutionStatusMessage(t, 'KSampler', null, {
|
||||
'Execution Message': 'custom status'
|
||||
})
|
||||
).toBe('custom status')
|
||||
})
|
||||
|
||||
it('ignores empty or whitespace-only custom message', () => {
|
||||
expect(
|
||||
getExecutionStatusMessage(t, 'KSampler', null, {
|
||||
'Execution Message': ' '
|
||||
})
|
||||
).toBe('execution.generating')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API nodes', () => {
|
||||
it('returns processing for API nodes', () => {
|
||||
const apiDef = { api_node: true } as ComfyNodeDefImpl
|
||||
expect(getExecutionStatusMessage(t, 'SomeApiNode', apiDef)).toBe(
|
||||
'execution.processing'
|
||||
)
|
||||
})
|
||||
|
||||
it('statusMap takes precedence over api_node flag', () => {
|
||||
const apiDef = { api_node: true } as ComfyNodeDefImpl
|
||||
expect(getExecutionStatusMessage(t, 'KSampler', apiDef)).toBe(
|
||||
'execution.generating'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node type matching', () => {
|
||||
it('does not match partial PascalCase words', () => {
|
||||
expect(getExecutionStatusMessage(t, 'Loads')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches identifier mid-string at PascalCase boundary', () => {
|
||||
expect(getExecutionStatusMessage(t, 'CompositeSaveImage')).toBe(
|
||||
'execution.saving'
|
||||
)
|
||||
})
|
||||
|
||||
it('matches identifier followed by non-letter characters', () => {
|
||||
expect(getExecutionStatusMessage(t, 'Save_V2')).toBe('execution.saving')
|
||||
expect(getExecutionStatusMessage(t, 'LoadImage🐍')).toBe(
|
||||
'execution.loading'
|
||||
)
|
||||
})
|
||||
|
||||
const testNodeTypes: [string, string[]][] = [
|
||||
['generating', ['KSampler', 'SamplerCustomAdvanced']],
|
||||
[
|
||||
'saving',
|
||||
['SaveImage', 'SaveAnimatedWEBP', 'PreviewImage', 'MaskPreview']
|
||||
],
|
||||
['loading', ['LoadImage', 'VAELoader', 'CheckpointLoaderSimple']],
|
||||
[
|
||||
'encoding',
|
||||
['VAEEncode', 'StableCascade_StageC_VAEEncode', 'CLIPTextEncode']
|
||||
],
|
||||
['decoding', ['VAEDecode', 'VAEDecodeHunyuan3D']],
|
||||
[
|
||||
'resizing',
|
||||
['ImageUpscaleWithModel', 'LatentUpscale', 'ResizeImageMaskNode']
|
||||
],
|
||||
[
|
||||
'processing',
|
||||
['TorchCompileModel', 'SVD_img2vid_Conditioning', 'ModelMergeSimple']
|
||||
],
|
||||
['generatingVideo', ['WanImageToVideo', 'WanFunControlToVideo']],
|
||||
['processingVideo', ['Video Slice', 'CreateVideo']],
|
||||
['training', ['TrainLoraNode']]
|
||||
]
|
||||
|
||||
it.for(
|
||||
testNodeTypes.flatMap(([status, nodes]) =>
|
||||
nodes.map((node) => [status, node] as const)
|
||||
)
|
||||
)('%s ← %s', ([status, nodeType]) => {
|
||||
expect(getExecutionStatusMessage(t, nodeType)).toBe(`execution.${status}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for nodes matching no pattern', () => {
|
||||
expect(getExecutionStatusMessage(t, 'PrimitiveString')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type ExecutionStatusKey =
|
||||
| 'generating'
|
||||
| 'saving'
|
||||
| 'loading'
|
||||
| 'encoding'
|
||||
| 'decoding'
|
||||
| 'processing'
|
||||
| 'resizing'
|
||||
| 'generatingVideo'
|
||||
| 'processingVideo'
|
||||
| 'training'
|
||||
|
||||
/**
|
||||
* Specific status messages for nodes that can't be matched by PascalCase
|
||||
* identifier patterns (e.g. unconventional naming, spaces).
|
||||
*/
|
||||
const statusMap: Record<string, ExecutionStatusKey> = {
|
||||
// Video utility nodes with non-standard naming
|
||||
'Video Slice': 'processingVideo',
|
||||
GetVideoComponents: 'processingVideo',
|
||||
CreateVideo: 'processingVideo',
|
||||
|
||||
// Training
|
||||
TrainLoraNode: 'training'
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a PascalCase identifier within a node type name.
|
||||
*/
|
||||
function pascalId(...ids: string[]): RegExp {
|
||||
return new RegExp('(?:' + ids.join('|') + ')(?![a-z])')
|
||||
}
|
||||
|
||||
const identifierRules: [RegExp, ExecutionStatusKey][] = [
|
||||
[pascalId('Save', 'Preview'), 'saving'],
|
||||
[pascalId('Load', 'Loader'), 'loading'],
|
||||
[pascalId('Encode'), 'encoding'],
|
||||
[pascalId('Decode'), 'decoding'],
|
||||
[pascalId('Compile', 'Conditioning', 'Merge'), 'processing'],
|
||||
[pascalId('Upscale', 'Resize'), 'resizing'],
|
||||
[pascalId('ToVideo'), 'generatingVideo'],
|
||||
[pascalId('Sampler'), 'generating']
|
||||
]
|
||||
|
||||
export function getExecutionStatusMessage(
|
||||
t: (key: string) => string,
|
||||
nodeType: string,
|
||||
nodeDef?: ComfyNodeDefImpl | null,
|
||||
properties?: Record<string, unknown>
|
||||
): string | null {
|
||||
const customMessage = properties?.['Execution Message']
|
||||
if (typeof customMessage === 'string' && customMessage.trim()) {
|
||||
return customMessage.trim()
|
||||
}
|
||||
|
||||
if (nodeType in statusMap) return t(`execution.${statusMap[nodeType]}`)
|
||||
|
||||
for (const [pattern, key] of identifierRules) {
|
||||
if (pattern.test(nodeType)) return t(`execution.${key}`)
|
||||
}
|
||||
|
||||
if (nodeDef?.api_node) return t('execution.processing')
|
||||
|
||||
return null
|
||||
}
|
||||
41
src/renderer/extensions/linearMode/useExecutionStatus.ts
Normal file
41
src/renderer/extensions/linearMode/useExecutionStatus.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getExecutionStatusMessage } from '@/renderer/extensions/linearMode/getExecutionStatusMessage'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
executionIdToNodeLocatorId,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolveStatus(
|
||||
t: (key: string) => string,
|
||||
nodeDefStore: ReturnType<typeof useNodeDefStore>,
|
||||
executionId: string | number
|
||||
): string | null {
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!locatorId) return null
|
||||
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
const nodeType = node?.type
|
||||
if (!nodeType) return null
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType] ?? null
|
||||
return getExecutionStatusMessage(t, nodeType, nodeDef, node.properties)
|
||||
}
|
||||
|
||||
export function useExecutionStatus() {
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const executionStatusMessage = computed<string | null>(() => {
|
||||
const executionId = executionStore.executingNodeId
|
||||
if (!executionId) return null
|
||||
return resolveStatus(t, nodeDefStore, executionId) || t('execution.running')
|
||||
})
|
||||
|
||||
return { executionStatusMessage }
|
||||
}
|
||||
Reference in New Issue
Block a user