mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Add a frontend heuristic that estimates peak VRAM consumption by detecting model-loading nodes in the workflow graph and summing approximate memory costs per model category (checkpoints, LoRAs, ControlNets, VAEs, etc.). The estimate uses only the largest base model (checkpoint or diffusion_model) since ComfyUI offloads others, plus all co-resident models and a flat runtime overhead. Surfaces the estimate in three places: 1. Template publishing wizard (metadata step) — auto-detects VRAM on mount using the same graph traversal pattern as custom node detection, with a manual GB override input for fine-tuning. 2. Template marketplace cards — displays a VRAM badge in the top-left corner of template thumbnails using the existing SquareChip and CardTop slot infrastructure. 3. Workflow editor — floating indicator in the bottom-right of the graph canvas showing estimated VRAM for the current workflow. Bumps version to 1.46.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
385 lines
12 KiB
Vue
385 lines
12 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-6 p-6">
|
|
<div class="flex flex-row items-center gap-2">
|
|
<div class="form-label flex w-28 shrink-0 items-center">
|
|
<span id="tpl-title-label" class="text-muted">
|
|
{{ t('templatePublishing.steps.metadata.titleLabel') }}
|
|
</span>
|
|
</div>
|
|
<input
|
|
id="tpl-title"
|
|
v-model="ctx.template.value.title"
|
|
type="text"
|
|
class="h-8 w-[100em] rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
|
aria-labelledby="tpl-title-label"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-row items-center gap-2">
|
|
<div class="form-label flex w-28 shrink-0 items-center">
|
|
<span id="tpl-difficulty-label" class="text-muted">
|
|
{{ t('templatePublishing.steps.metadata.difficultyLabel') }}
|
|
</span>
|
|
</div>
|
|
<div
|
|
class="flex flex-row gap-4"
|
|
role="radiogroup"
|
|
aria-labelledby="tpl-difficulty-label"
|
|
>
|
|
<label
|
|
v-for="option in DIFFICULTY_OPTIONS"
|
|
:key="option.value"
|
|
:for="`tpl-difficulty-${option.value}`"
|
|
class="flex cursor-pointer items-center gap-1.5 text-sm"
|
|
>
|
|
<input
|
|
:id="`tpl-difficulty-${option.value}`"
|
|
type="radio"
|
|
name="tpl-difficulty"
|
|
:value="option.value"
|
|
:checked="ctx.template.value.difficulty === option.value"
|
|
:class="
|
|
cn(
|
|
'h-5 w-5 appearance-none rounded-full border-2 checked:bg-current checked:shadow-[inset_0_0_0_1px_white]',
|
|
option.borderClass
|
|
)
|
|
"
|
|
@change="ctx.template.value.difficulty = option.value"
|
|
/>
|
|
{{ option.text }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<FormItem
|
|
id="tpl-license"
|
|
v-model:form-value="ctx.template.value.license"
|
|
:item="licenseField"
|
|
/>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<span id="tpl-required-nodes-label" class="text-sm text-muted">
|
|
{{ t('templatePublishing.steps.metadata.requiredNodesLabel') }}
|
|
</span>
|
|
|
|
<div
|
|
v-if="detectedCustomNodes.length > 0"
|
|
aria-labelledby="tpl-required-nodes-label"
|
|
>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ t('templatePublishing.steps.metadata.requiredNodesDetected') }}
|
|
</span>
|
|
<ul class="mt-1 flex flex-col gap-1">
|
|
<li
|
|
v-for="nodeName in detectedCustomNodes"
|
|
:key="nodeName"
|
|
class="flex items-center gap-2 rounded bg-secondary-background px-2 py-1 text-sm"
|
|
>
|
|
<i
|
|
class="icon-[lucide--puzzle] h-3.5 w-3.5 text-muted-foreground"
|
|
/>
|
|
{{ nodeName }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ t('templatePublishing.steps.metadata.requiredNodesManualLabel') }}
|
|
</span>
|
|
<div class="relative mt-1">
|
|
<input
|
|
v-model="manualNodeQuery"
|
|
type="text"
|
|
class="h-8 w-56 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
|
:placeholder="
|
|
t(
|
|
'templatePublishing.steps.metadata.requiredNodesManualPlaceholder'
|
|
)
|
|
"
|
|
@focus="showNodeSuggestions = true"
|
|
@keydown.enter.prevent="addManualNode(manualNodeQuery)"
|
|
/>
|
|
<ul
|
|
v-if="showNodeSuggestions && filteredNodeSuggestions.length > 0"
|
|
class="absolute z-10 mt-1 max-h-40 w-56 overflow-auto rounded border border-border-default bg-secondary-background shadow-md"
|
|
>
|
|
<li
|
|
v-for="suggestion in filteredNodeSuggestions"
|
|
:key="suggestion"
|
|
class="cursor-pointer px-2 py-1 text-sm hover:bg-comfy-input-background"
|
|
@mousedown.prevent="addManualNode(suggestion)"
|
|
>
|
|
{{ suggestion }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div
|
|
v-if="manualNodes.length > 0"
|
|
class="mt-1 flex flex-wrap items-center gap-1"
|
|
>
|
|
<span
|
|
v-for="node in manualNodes"
|
|
:key="node"
|
|
class="inline-flex items-center gap-1 rounded-full bg-comfy-input-background px-2 py-0.5 text-xs"
|
|
>
|
|
{{ node }}
|
|
<button
|
|
type="button"
|
|
class="hover:text-danger"
|
|
:aria-label="`Remove ${node}`"
|
|
@click="removeManualNode(node)"
|
|
>
|
|
<i class="icon-[lucide--x] h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<span id="tpl-vram-label" class="text-sm text-muted">
|
|
{{ t('templatePublishing.steps.metadata.vramLabel') }}
|
|
</span>
|
|
<div class="flex items-center gap-3">
|
|
<i class="icon-[lucide--cpu] h-3.5 w-3.5 text-muted-foreground" />
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ t('templatePublishing.steps.metadata.vramAutoDetected') }}
|
|
</span>
|
|
<span class="text-sm font-medium">
|
|
{{ formatSize(autoDetectedVram) }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
id="tpl-vram-override"
|
|
v-model.number="manualVramGb"
|
|
type="number"
|
|
min="0"
|
|
step="0.5"
|
|
class="h-8 w-24 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
|
aria-labelledby="tpl-vram-label"
|
|
/>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ t('templatePublishing.steps.metadata.vramManualOverride') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, inject, onMounted, ref } from 'vue'
|
|
import { watchDebounced } from '@vueuse/core'
|
|
import { formatSize } from '@/utils/formatUtil'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import FormItem from '@/components/common/FormItem.vue'
|
|
import { estimateWorkflowVram } from '@/composables/useVramEstimation'
|
|
import type { FormItem as FormItemType } from '@/platform/settings/types'
|
|
import { app } from '@/scripts/app'
|
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
|
import { NodeSourceType } from '@/types/nodeSource'
|
|
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
import { PublishingStepperKey } from '../types'
|
|
|
|
const { t } = useI18n()
|
|
const ctx = inject(PublishingStepperKey)!
|
|
const nodeDefStore = useNodeDefStore()
|
|
|
|
const DIFFICULTY_OPTIONS = [
|
|
{
|
|
text: t('templatePublishing.steps.metadata.difficulty.beginner'),
|
|
value: 'beginner' as const,
|
|
borderClass: 'border-green-400'
|
|
},
|
|
{
|
|
text: t('templatePublishing.steps.metadata.difficulty.intermediate'),
|
|
value: 'intermediate' as const,
|
|
borderClass: 'border-amber-400'
|
|
},
|
|
{
|
|
text: t('templatePublishing.steps.metadata.difficulty.advanced'),
|
|
value: 'advanced' as const,
|
|
borderClass: 'border-red-400'
|
|
}
|
|
]
|
|
|
|
const licenseField: FormItemType = {
|
|
name: t('templatePublishing.steps.metadata.licenseLabel'),
|
|
type: 'combo',
|
|
options: [
|
|
{ text: t('templatePublishing.steps.metadata.license.mit'), value: 'mit' },
|
|
{
|
|
text: t('templatePublishing.steps.metadata.license.ccBy'),
|
|
value: 'cc-by'
|
|
},
|
|
{
|
|
text: t('templatePublishing.steps.metadata.license.ccBySa'),
|
|
value: 'cc-by-sa'
|
|
},
|
|
{
|
|
text: t('templatePublishing.steps.metadata.license.ccByNc'),
|
|
value: 'cc-by-nc'
|
|
},
|
|
{
|
|
text: t('templatePublishing.steps.metadata.license.apache'),
|
|
value: 'apache'
|
|
},
|
|
{
|
|
text: t('templatePublishing.steps.metadata.license.custom'),
|
|
value: 'custom'
|
|
}
|
|
],
|
|
attrs: { filter: true }
|
|
}
|
|
|
|
/**
|
|
* Collects unique custom node type names from the current workflow graph.
|
|
* Excludes core, essentials, and blueprint nodes.
|
|
*/
|
|
function detectCustomNodes(): string[] {
|
|
if (!app.rootGraph) return []
|
|
|
|
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
|
|
const unique = new Set(nodeTypes)
|
|
|
|
return [...unique]
|
|
.filter((type) => {
|
|
const def = nodeDefStore.nodeDefsByName[type]
|
|
if (!def) return false
|
|
return def.nodeSource.type === NodeSourceType.CustomNodes
|
|
})
|
|
.sort()
|
|
}
|
|
|
|
/**
|
|
* Extracts the custom node package ID from a `python_module` string.
|
|
*
|
|
* Custom node modules follow the pattern
|
|
* `custom_nodes.PackageName@version.submodule`, so the package ID is the
|
|
* second dot-segment with the `@version` suffix stripped.
|
|
*
|
|
* @returns The package folder name, or `undefined` when the module does not
|
|
* match the expected pattern.
|
|
*/
|
|
function extractPackageId(pythonModule: string): string | undefined {
|
|
const segments = pythonModule.split('.')
|
|
if (segments[0] !== 'custom_nodes' || !segments[1]) return undefined
|
|
return segments[1].split('@')[0]
|
|
}
|
|
|
|
/**
|
|
* Collects unique custom node package IDs from the current workflow graph.
|
|
*/
|
|
function detectCustomNodePackages(): string[] {
|
|
if (!app.rootGraph) return []
|
|
|
|
const nodeTypes = mapAllNodes(app.rootGraph, (node) => node.type)
|
|
const packages = new Set<string>()
|
|
|
|
for (const type of nodeTypes) {
|
|
const def = nodeDefStore.nodeDefsByName[type]
|
|
if (!def || def.nodeSource.type !== NodeSourceType.CustomNodes) continue
|
|
const pkgId = extractPackageId(def.python_module)
|
|
if (pkgId) packages.add(pkgId)
|
|
}
|
|
|
|
return [...packages].sort()
|
|
}
|
|
|
|
const detectedCustomNodes = ref<string[]>([])
|
|
const autoDetectedVram = ref(0)
|
|
|
|
const GB = 1_073_741_824
|
|
|
|
/**
|
|
* Manual VRAM override in GB. When set to a positive number, this
|
|
* value (converted to bytes) takes precedence over the auto-detected
|
|
* estimate for `vramRequirement`.
|
|
*/
|
|
const manualVramGb = computed({
|
|
get: () => {
|
|
const stored = ctx.template.value.vramRequirement
|
|
if (!stored || stored === autoDetectedVram.value) return undefined
|
|
return Math.round((stored / GB) * 10) / 10
|
|
},
|
|
set: (gb: number | undefined) => {
|
|
if (gb && gb > 0) {
|
|
ctx.template.value.vramRequirement = Math.round(gb * GB)
|
|
} else {
|
|
ctx.template.value.vramRequirement = autoDetectedVram.value
|
|
}
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
detectedCustomNodes.value = detectCustomNodes()
|
|
|
|
const existing = ctx.template.value.requiredNodes ?? []
|
|
if (existing.length === 0) {
|
|
ctx.template.value.requiredNodes = [...detectedCustomNodes.value]
|
|
}
|
|
|
|
const existingPackages = ctx.template.value.requiresCustomNodes ?? []
|
|
if (existingPackages.length === 0) {
|
|
ctx.template.value.requiresCustomNodes = detectCustomNodePackages()
|
|
}
|
|
|
|
autoDetectedVram.value = estimateWorkflowVram(app.rootGraph)
|
|
if (!ctx.template.value.vramRequirement) {
|
|
ctx.template.value.vramRequirement = autoDetectedVram.value
|
|
}
|
|
})
|
|
|
|
const manualNodes = computed(() => {
|
|
const all = ctx.template.value.requiredNodes ?? []
|
|
const detected = new Set(detectedCustomNodes.value)
|
|
return all.filter((n) => !detected.has(n))
|
|
})
|
|
|
|
const manualNodeQuery = ref('')
|
|
const showNodeSuggestions = ref(false)
|
|
|
|
/** All installed custom node type names for searchable suggestions. */
|
|
const allCustomNodeNames = computed(() =>
|
|
Object.values(nodeDefStore.nodeDefsByName)
|
|
.filter((def) => def.nodeSource.type === NodeSourceType.CustomNodes)
|
|
.map((def) => def.name)
|
|
.sort()
|
|
)
|
|
|
|
const filteredNodeSuggestions = computed(() => {
|
|
const query = manualNodeQuery.value.toLowerCase().trim()
|
|
if (!query) return []
|
|
const existing = new Set(ctx.template.value.requiredNodes ?? [])
|
|
return allCustomNodeNames.value.filter(
|
|
(name) => name.toLowerCase().includes(query) && !existing.has(name)
|
|
)
|
|
})
|
|
|
|
function addManualNode(name: string) {
|
|
const trimmed = name.trim()
|
|
if (!trimmed) return
|
|
const nodes = ctx.template.value.requiredNodes ?? []
|
|
if (!nodes.includes(trimmed)) {
|
|
ctx.template.value.requiredNodes = [...nodes, trimmed]
|
|
}
|
|
manualNodeQuery.value = ''
|
|
showNodeSuggestions.value = false
|
|
}
|
|
|
|
function removeManualNode(name: string) {
|
|
const nodes = ctx.template.value.requiredNodes ?? []
|
|
ctx.template.value.requiredNodes = nodes.filter((n) => n !== name)
|
|
}
|
|
|
|
watchDebounced(
|
|
() => ctx.template.value,
|
|
() => ctx.saveDraft(),
|
|
{ deep: true, debounce: 500 }
|
|
)
|
|
</script>
|