Merge branch 'main' into webcam-capture

This commit is contained in:
Johnpaul
2025-11-25 01:53:09 +01:00
214 changed files with 6549 additions and 4582 deletions

View File

@@ -10,7 +10,6 @@
severity="primary"
size="small"
:model="queueModeMenuItems"
:disabled="hasMissingNodes"
data-testid="queue-button"
@click="queuePrompt"
>
@@ -32,46 +31,12 @@
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions flex flex-nowrap">
<Button
v-tooltip.bottom="{
value: $t('menu.interrupt'),
showDelay: 600
}"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
text
:aria-label="$t('menu.interrupt')"
@click="() => commandStore.execute('Comfy.Interrupt')"
/>
<Button
v-tooltip.bottom="{
value: $t('sideToolbar.queueTab.clearPendingTasks'),
showDelay: 600
}"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
text
:aria-label="$t('sideToolbar.queueTab.clearPendingTasks')"
@click="
() => {
if (queueCountStore.count.value > 1) {
commandStore.execute('Comfy.ClearPendingTasks')
}
queueMode = 'disabled'
}
"
/>
</ButtonGroup>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import type { MenuItem } from 'primevue/menuitem'
import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue'
@@ -80,17 +45,13 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import {
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes()
@@ -145,11 +106,6 @@ const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup.value)
)
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(
() => queueCountStore.count.value > 1 || queueMode.value !== 'disabled'
)
const iconClass = computed(() => {
if (hasMissingNodes.value) {
return 'icon-[lucide--triangle-alert]'

View File

@@ -24,7 +24,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void
onClick?: (event: MouseEvent) => void
}
defineOptions({

View File

@@ -47,7 +47,7 @@ const {
} = defineProps<IconTextButtonProps>()
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border
? getBorderButtonTypeClasses(type)

View File

@@ -68,4 +68,8 @@ const toggle = (event: Event) => {
const hide = () => {
popover.value?.hide()
}
defineExpose({
hide
})
</script>

View File

@@ -92,7 +92,7 @@
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down]" />
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
</div>

View File

@@ -1,6 +1,7 @@
<template>
<div
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->

View File

@@ -4,6 +4,7 @@
<InputText
ref="inputRef"
v-model="inputValue"
:placeholder
autofocus
@keyup.enter="onConfirm"
@focus="selectAllText"
@@ -28,6 +29,7 @@ const props = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(props.defaultValue)

View File

@@ -17,7 +17,7 @@
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-base-background text-base-foreground',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
selectedCount > 0
@@ -83,7 +83,7 @@
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
tabindex="0"
:tabindex="0"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
@@ -127,7 +127,7 @@
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-muted-foreground">
<span class="text-sm">
{{ label }}
</span>
<span
@@ -140,7 +140,7 @@
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" />
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->

View File

@@ -1,6 +1,6 @@
<template>
<div :class="wrapperStyle" @click="focusInput">
<i class="icon-[lucide--search] text-muted" />
<i class="icon-[lucide--search] text-muted-foreground" />
<InputText
ref="input"
v-model="internalSearchQuery"
@@ -73,7 +73,7 @@ onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => {
const baseClasses =
'relative flex w-full items-center gap-2 bg-base-background cursor-text'
'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
if (showBorder) {
return cn(

View File

@@ -20,7 +20,7 @@
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-lg',
'bg-base-background text-base-foreground',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border',
@@ -84,7 +84,7 @@
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm text-neutral-500">
<div class="flex items-center gap-2 text-sm">
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -100,7 +100,7 @@
<!-- Trigger caret -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" />
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Option row -->

View File

@@ -26,6 +26,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
getEffectiveBrushSize,
getEffectiveHardness
} from '@/composables/maskeditor/brushUtils'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
@@ -36,11 +40,14 @@ const { containerRef } = defineProps<{
const store = useMaskEditorStore()
const brushOpacity = computed(() => {
return store.brushVisible ? '1' : '0'
return store.brushVisible ? 1 : 0
})
const brushRadius = computed(() => {
return store.brushSettings.size * store.zoomRatio
const size = store.brushSettings.size
const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
return effectiveSize * store.zoomRatio
})
const brushSize = computed(() => {
@@ -78,19 +85,26 @@ const gradientVisible = computed(() => {
})
const gradientBackground = computed(() => {
const size = store.brushSettings.size
const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
const effectiveHardness = getEffectiveHardness(size, hardness, effectiveSize)
if (hardness === 1) {
if (effectiveHardness === 1) {
return 'rgba(255, 0, 0, 0.5)'
}
const midStop = hardness * 100
const midStop = effectiveHardness * 100
const outerStop = 100
// Add an intermediate stop to approximate the squared falloff
// At 50% of the fade region, squared falloff is 0.25 (relative to max)
const fadeMidStop = midStop + (outerStop - midStop) * 0.5
return `radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, 0.25) ${midStop}%,
rgba(255, 0, 0, 0.5) ${midStop}%,
rgba(255, 0, 0, 0.125) ${fadeMidStop}%,
rgba(255, 0, 0, 0) ${outerStop}%
)`
})

View File

@@ -55,7 +55,7 @@
<SliderControl
:label="t('maskEditor.thickness')"
:min="1"
:max="100"
:max="500"
:step="1"
:model-value="store.brushSettings.size"
@update:model-value="onThicknessChange"
@@ -80,12 +80,12 @@
/>
<SliderControl
:label="t('maskEditor.smoothingPrecision')"
label="Stepsize"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.smoothingPrecision"
@update:model-value="onSmoothingPrecisionChange"
:model-value="store.brushSettings.stepSize"
@update:model-value="onStepSizeChange"
/>
</div>
</template>
@@ -119,8 +119,8 @@ const onHardnessChange = (value: number) => {
store.setBrushHardness(value)
}
const onSmoothingPrecisionChange = (value: number) => {
store.setBrushSmoothingPrecision(value)
const onStepSizeChange = (value: number) => {
store.setBrushStepSize(value)
}
const resetToDefault = () => {

View File

@@ -12,19 +12,28 @@
>
<canvas
ref="imgCanvasRef"
class="absolute top-0 left-0 w-full h-full"
class="absolute top-0 left-0 w-full h-full z-0"
@contextmenu.prevent
/>
<canvas
ref="rgbCanvasRef"
class="absolute top-0 left-0 w-full h-full"
class="absolute top-0 left-0 w-full h-full z-10"
@contextmenu.prevent
/>
<canvas
ref="maskCanvasRef"
class="absolute top-0 left-0 w-full h-full"
class="absolute top-0 left-0 w-full h-full z-30"
@contextmenu.prevent
/>
<!-- GPU Preview Canvas -->
<canvas
ref="gpuCanvasRef"
class="absolute top-0 left-0 w-full h-full pointer-events-none"
:class="{
'z-20': store.activeLayer === 'rgb',
'z-40': store.activeLayer === 'mask'
}"
/>
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
</div>
@@ -87,6 +96,7 @@ const canvasContainerRef = ref<HTMLDivElement>()
const imgCanvasRef = ref<HTMLCanvasElement>()
const maskCanvasRef = ref<HTMLCanvasElement>()
const rgbCanvasRef = ref<HTMLCanvasElement>()
const gpuCanvasRef = ref<HTMLCanvasElement>()
const canvasBackgroundRef = ref<HTMLDivElement>()
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
@@ -97,7 +107,7 @@ const initialized = ref(false)
const keyboard = useKeyboard()
const panZoom = usePanAndZoom()
let toolManager: ReturnType<typeof useToolManager> | null = null
const toolManager = useToolManager(keyboard, panZoom)
let resizeObserver: ResizeObserver | null = null
@@ -135,8 +145,6 @@ const initUI = async () => {
try {
await loader.loadFromNode(node)
toolManager = useToolManager(keyboard, panZoom)
const imageLoader = useImageLoader()
const image = await imageLoader.loadImages()
@@ -149,6 +157,18 @@ const initUI = async () => {
store.canvasHistory.saveInitialState()
// Initialize GPU resources
if (toolManager.brushDrawing) {
await toolManager.brushDrawing.initGPUResources()
if (gpuCanvasRef.value && toolManager.brushDrawing.initPreviewCanvas) {
// Match preview canvas resolution to mask canvas
gpuCanvasRef.value.width = maskCanvasRef.value.width
gpuCanvasRef.value.height = maskCanvasRef.value.height
toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
}
}
initialized.value = true
} catch (error) {
console.error('[MaskEditorContent] Initialization failed:', error)
@@ -172,7 +192,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
toolManager?.brushDrawing.saveBrushSettings()
toolManager.brushDrawing.saveBrushSettings()
keyboard?.removeListeners()

View File

@@ -102,6 +102,7 @@ const onInvert = () => {
const onClear = () => {
canvasTools.clearMask()
store.triggerClear()
}
const handleSave = async () => {

View File

@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
-->
<template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview">
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
}
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);

View File

@@ -1,8 +1,10 @@
<template>
<button
type="button"
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
<IconButton
type="secondary"
size="fit-content"
class="group w-full justify-between gap-3 rounded-lg p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel"
@click="emit('click', $event)"
>
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
@@ -76,10 +78,11 @@
>
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span>
</button>
</IconButton>
</template>
<script setup lang="ts">
import IconButton from '@/components/button/IconButton.vue'
import type {
CompletionSummary,
CompletionSummaryMode
@@ -96,4 +99,8 @@ type Props = {
const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => []
})
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
</script>

View File

@@ -42,17 +42,19 @@
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<button
<IconButton
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</button>
</IconButton>
</div>
<div class="flex items-center gap-2">
@@ -62,26 +64,28 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<button
<IconButton
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</button>
</IconButton>
</div>
</div>
<button
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
<TextButton
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
@click="$emit('viewAllJobs')"
>
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</button>
/>
</div>
</div>
</template>
@@ -90,6 +94,8 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{

View File

@@ -8,17 +8,20 @@
/>
<div class="flex items-center justify-between px-3">
<button
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
<IconTextButton
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</button>
<template #icon>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
</template>
</IconTextButton>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
@@ -28,16 +31,18 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<button
<IconButton
v-if="queuedCount > 0"
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
type="secondary"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/>
</button>
</IconButton>
</div>
</div>
@@ -75,6 +80,8 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type {
JobGroup,
JobListItem,

View File

@@ -18,16 +18,18 @@
</span>
</div>
<div class="flex items-center gap-1">
<button
<IconButton
v-tooltip.top="moreTooltipConfig"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100"
type="transparent"
size="sm"
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</button>
</IconButton>
<Popover
ref="morePopoverRef"
:dismissable="true"
@@ -45,18 +47,19 @@
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</button>
<template #icon>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</div>
</Popover>
</div>
@@ -69,6 +72,8 @@ import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{

View File

@@ -8,13 +8,15 @@
<p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p>
<button
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100"
<IconButton
type="transparent"
size="sm"
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] block size-4 leading-none" />
</button>
</IconButton>
</header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
@@ -30,21 +32,19 @@
<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<button
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary"
:aria-label="t('g.cancel')"
<TextButton
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
type="transparent"
:label="t('g.cancel')"
@click="onCancel"
>
{{ t('g.cancel') }}
</button>
<button
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60"
:aria-label="t('g.clear')"
/>
<TextButton
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
type="secondary"
:label="t('g.clear')"
:disabled="isClearing"
@click="onConfirm"
>
{{ t('g.clear') }}
</button>
/>
</div>
</footer>
</section>
@@ -54,6 +54,8 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'

View File

@@ -20,21 +20,24 @@
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<button
<IconTextButton
v-else
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface"
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
type="transparent"
:label="entry.label"
:aria-label="entry.label"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</button>
<template #icon>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
</template>
</IconTextButton>
</template>
</div>
</Popover>
@@ -44,6 +47,7 @@
import Popover from 'primevue/popover'
import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()

View File

@@ -20,17 +20,18 @@
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
>
<span class="block min-w-0 truncate">{{ row.value }}</span>
<button
<IconButton
v-if="row.canCopy"
type="button"
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90"
type="transparent"
size="sm"
class="ml-2 size-6 bg-transparent hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyJobId"
>
<i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/>
</button>
</IconButton>
</div>
</template>
</div>
@@ -60,25 +61,31 @@
{{ t('queue.jobDetails.errorMessage') }}
</div>
<div class="flex items-center justify-between gap-4">
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="copyAriaLabel"
:aria-label="copyAriaLabel"
icon-position="right"
@click.stop="copyErrorMessage"
>
<span>{{ copyAriaLabel }}</span>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</button>
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
<template #icon>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</template>
</IconTextButton>
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="t('queue.jobDetails.report')"
icon-position="right"
@click.stop="reportJobError"
>
<span>{{ t('queue.jobDetails.report') }}</span>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</button>
<template #icon>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</template>
</IconTextButton>
</div>
<div
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
@@ -94,6 +101,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'

View File

@@ -2,26 +2,26 @@
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<button
<TextButton
v-for="tab in visibleJobTabs"
:key="tab"
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90"
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
:class="[
selectedJobTab === tab
? 'bg-secondary-background text-text-primary'
: 'bg-transparent text-text-secondary'
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
]"
:label="tabLabel(tab)"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</button>
/>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<button
<IconButton
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
@@ -32,7 +32,7 @@
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</button>
</IconButton>
<Popover
v-if="showWorkflowFilter"
ref="filterPopoverRef"
@@ -51,46 +51,48 @@
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<span class="ml-auto inline-flex items-center">
<template #icon>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</template>
</IconTextButton>
<div class="mx-2 mt-1 h-px" />
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<span class="ml-auto inline-flex items-center">
<template #icon>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</template>
</IconTextButton>
</div>
</Popover>
<button
<IconButton
v-tooltip.top="sortTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
@@ -101,7 +103,7 @@
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</button>
</IconButton>
<Popover
ref="sortPopoverRef"
:dismissable="true"
@@ -120,19 +122,21 @@
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="sortLabel(mode)"
:aria-label="sortLabel(mode)"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<span class="ml-auto inline-flex items-center">
<template #icon>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</template>
</IconTextButton>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
@@ -149,6 +153,9 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -108,45 +108,47 @@
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<button
<IconButton
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</button>
<button
</IconButton>
<IconButton
v-else-if="props.state !== 'completed' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
>
<i class="icon-[lucide--x] size-4" />
</button>
<button
</IconButton>
<TextButton
v-else-if="props.state === 'completed'"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
:label="t('menuLabels.View')"
:aria-label="t('menuLabels.View')"
@click.stop="emit('view')"
>
<span>{{ t('menuLabels.View') }}</span>
</button>
<button
/>
<IconButton
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</button>
</IconButton>
</div>
<div v-else key="secondary" class="pr-2">
<slot name="secondary">{{ props.rightText }}</slot>
@@ -161,6 +163,8 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -44,6 +44,7 @@
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
</nav>
@@ -56,6 +57,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'

View File

@@ -0,0 +1,39 @@
<template>
<SidebarIcon
:label="$t('g.settings')"
:tooltip="tooltipText"
@click="showSettingsDialog"
>
<template #icon>
<i class="icon-[lucide--settings]" />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import SidebarIcon from './SidebarIcon.vue'
const { t } = useI18n()
const { getCommand, formatKeySequence } = useCommandStore()
const command = getCommand('Comfy.ShowSettingsDialog')
const tooltipText = computed(
() => `${t('g.settings')} (${formatKeySequence(command)})`
)
/**
* Toggle keyboard shortcuts panel and track UI button click.
*/
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
})
}
</script>

View File

@@ -85,10 +85,13 @@
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-popover-id="openPopoverId"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@popover-opened="openPopoverId = item.id"
@popover-closed="openPopoverId = null"
/>
</template>
</VirtualGrid>
@@ -199,6 +202,9 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
// Track which asset's popover is open (for single-instance popover management)
const openPopoverId = ref<string | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
@@ -208,7 +214,7 @@ const shouldShowDeleteButton = computed(() => {
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0
return typeof count === 'number' && count > 0 ? count : 1
}
const shouldShowOutputCount = (item: AssetItem): boolean => {

View File

@@ -1,289 +0,0 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Button
v-tooltip.bottom="$t(`sideToolbar.queueTab.${imageFit}ImagePreview`)"
:icon="
imageFit === 'cover'
? 'pi pi-arrow-down-left-and-arrow-up-right-to-center'
: 'pi pi-arrow-up-right-and-arrow-down-left-from-center'
"
text
severity="secondary"
class="toggle-expanded-button"
@click="toggleImageFit"
/>
<Button
v-if="isInFolderView"
v-tooltip.bottom="$t('sideToolbar.queueTab.backToAllTasks')"
icon="pi pi-arrow-left"
text
severity="secondary"
class="back-button"
@click="exitFolderView"
/>
<template v-else>
<Button
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
:icon="isExpanded ? 'pi pi-images' : 'pi pi-image'"
text
severity="secondary"
class="toggle-expanded-button"
@click="toggleExpanded"
/>
<Button
v-if="queueStore.hasPendingTasks"
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
severity="danger"
text
@click="() => commandStore.execute('Comfy.ClearPendingTasks')"
/>
<Button
icon="pi pi-trash"
text
severity="primary"
class="clear-all-button"
@click="confirmRemoveAll($event)"
/>
</template>
</template>
<template #body>
<VirtualGrid
v-if="allTasks?.length"
:items="allTasks"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
gap: '0.5rem'
}"
>
<template #item="{ item }">
<TaskItem
:task="item"
:is-flat-task="isExpanded || isInFolderView"
@contextmenu="handleContextMenu"
@preview="handlePreview"
@task-output-length-clicked="enterFolderView($event)"
/>
</template>
</VirtualGrid>
<div v-else-if="queueStore.isLoading">
<ProgressSpinner
style="width: 50px; left: 50%; transform: translateX(-50%)"
/>
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="$t('g.noTasksFound')"
:message="$t('g.noTasksFoundMessage')"
/>
</div>
</template>
</SidebarTabTemplate>
<ConfirmPopup />
<ContextMenu ref="menu" :model="menuItems" />
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="allGalleryItems"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import ProgressSpinner from 'primevue/progressspinner'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import { computed, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import { useQueueStore } from '@/stores/queueStore'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
import ResultGallery from './queue/ResultGallery.vue'
import TaskItem from './queue/TaskItem.vue'
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
const isExpanded = ref(false)
const galleryActiveIndex = ref(-1)
const allGalleryItems = shallowRef<ResultItemImpl[]>([])
// Folder view: only show outputs from a single selected task.
const folderTask = ref<TaskItemImpl | null>(null)
const isInFolderView = computed(() => folderTask.value !== null)
const imageFit = computed<string>(() => settingStore.get(IMAGE_FIT))
const allTasks = computed(() =>
isInFolderView.value
? folderTask.value
? folderTask.value.flatten()
: []
: isExpanded.value
? queueStore.flatTasks
: queueStore.tasks
)
const updateGalleryItems = () => {
allGalleryItems.value = allTasks.value.flatMap((task: TaskItemImpl) => {
const previewOutput = task.previewOutput
return previewOutput ? [previewOutput] : []
})
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
}
const removeTask = async (task: TaskItemImpl) => {
if (task.isRunning) {
await api.interrupt(task.promptId)
}
await queueStore.delete(task)
}
const removeAllTasks = async () => {
await queueStore.clear()
}
const confirmRemoveAll = (event: Event) => {
confirm.require({
target: event.currentTarget as HTMLElement,
message: 'Do you want to delete all tasks?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
await removeAllTasks()
toast.add({
severity: 'info',
summary: 'Confirmed',
detail: 'Tasks deleted',
life: 3000
})
}
})
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
{
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: isCloud
? !menuTargetTask.value?.isHistory
: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
},
visible: !!menuTargetNode.value
}
]
if (menuTargetTask.value?.previewOutput?.mediaType === 'images') {
items.push({
label: t('g.setAsBackground'),
icon: 'pi pi-image',
command: () => {
const url = menuTargetTask.value?.previewOutput?.url
if (url) {
void settingStore.set('Comfy.Canvas.BackgroundImage', url)
}
}
})
}
return items
})
const handleContextMenu = ({
task,
event,
node
}: {
task: TaskItemImpl
event: Event
node: ComfyNode | null
}) => {
menuTargetTask.value = task
menuTargetNode.value = node
menu.value?.show(event)
}
const handlePreview = (task: TaskItemImpl) => {
updateGalleryItems()
galleryActiveIndex.value = allGalleryItems.value.findIndex(
(item) => item.url === task.previewOutput?.url
)
}
const enterFolderView = (task: TaskItemImpl) => {
folderTask.value = task
}
const exitFolderView = () => {
folderTask.value = null
}
const toggleImageFit = async () => {
await settingStore.set(
IMAGE_FIT,
imageFit.value === 'cover' ? 'contain' : 'cover'
)
}
watch(allTasks, () => {
const isGalleryOpen = galleryActiveIndex.value !== -1
if (!isGalleryOpen) return
const prevLength = allGalleryItems.value.length
updateGalleryItems()
const lengthChange = allGalleryItems.value.length - prevLength
if (!lengthChange) return
const newIndex = galleryActiveIndex.value + lengthChange
galleryActiveIndex.value = Math.max(0, newIndex)
})
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div
ref="resultContainer"
class="result-container"
@click="handlePreviewClick"
>
<ComfyImage
v-if="result.isImage"
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
:alt="result.filename"
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<ResultAudio v-else-if="result.isAudio" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file" />
<span>{{ result.mediaType }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const emit = defineEmits<{
(e: 'preview', result: ResultItemImpl): void
}>()
const resultContainer = ref<HTMLElement | null>(null)
const settingStore = useSettingStore()
const imageFit = computed<string>(() =>
settingStore.get('Comfy.Queue.ImageFit')
)
const handlePreviewClick = () => {
if (props.result.supportsPreview) {
emit('preview', props.result)
}
}
onMounted(() => {
if (props.result.mediaType === 'images') {
resultContainer.value?.querySelectorAll('img').forEach((img) => {
img.draggable = true
})
}
})
</script>
<style scoped>
.result-container {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: transform 0.2s ease;
}
.result-container:hover {
transform: scale(1.02);
}
</style>

View File

@@ -1,271 +0,0 @@
<template>
<div class="task-item" @contextmenu="handleContextMenu">
<div class="task-result-preview">
<template
v-if="
task.displayStatus === TaskItemDisplayStatus.Completed ||
cancelledWithResults
"
>
<ResultItem
v-if="flatOutputs.length && coverResult"
:result="coverResult"
@preview="handlePreview"
/>
</template>
<template v-if="task.displayStatus === TaskItemDisplayStatus.Running">
<i v-if="!progressPreviewBlobUrl" class="pi pi-spin pi-spinner" />
<img
v-else
:src="progressPreviewBlobUrl"
class="progress-preview-img"
/>
</template>
<span v-else-if="task.displayStatus === TaskItemDisplayStatus.Pending"
>...</span
>
<i
v-else-if="cancelledWithoutResults"
class="pi pi-exclamation-triangle"
/>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Failed"
class="pi pi-exclamation-circle"
/>
</div>
<div class="task-item-details">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
<Button
class="task-node-link"
:label="`${node?.type} (#${node?.id})`"
link
size="small"
@click="
() => {
if (!node) return
litegraphService.goToNode(node.id)
}
"
/>
</Tag>
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)" />
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
</span>
<span v-if="isFlatTask" class="task-prompt-id">
{{ task.promptId.split('-')[0] }}
</span>
</Tag>
</div>
<div class="tag-wrapper">
<Button
v-if="task.isHistory && flatOutputs.length > 1"
outlined
@click="handleOutputLengthClick"
>
<span style="font-weight: 700">{{ flatOutputs.length }}</span>
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { useLitegraphService } from '@/services/litegraphService'
import { TaskItemDisplayStatus } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import ResultItem from './ResultItem.vue'
const props = defineProps<{
task: TaskItemImpl
isFlatTask: boolean
}>()
const litegraphService = useLitegraphService()
const flatOutputs = props.task.flatOutputs
const coverResult = flatOutputs.length
? props.task.previewOutput || flatOutputs[0]
: null
// Using `==` instead of `===` because NodeId can be a string or a number
const node: ComfyNode | null =
flatOutputs.length && props.task.workflow
? (props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult?.nodeId
) ?? null)
: null
const progressPreviewBlobUrl = ref('')
const emit = defineEmits<{
(
e: 'contextmenu',
value: { task: TaskItemImpl; event: MouseEvent; node: ComfyNode | null }
): void
(e: 'preview', value: TaskItemImpl): void
(e: 'task-output-length-clicked', value: TaskItemImpl): void
}>()
onMounted(() => {
api.addEventListener('b_preview', onProgressPreviewReceived)
})
onUnmounted(() => {
if (progressPreviewBlobUrl.value) {
URL.revokeObjectURL(progressPreviewBlobUrl.value)
}
api.removeEventListener('b_preview', onProgressPreviewReceived)
})
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e, node })
}
const handlePreview = () => {
emit('preview', props.task)
}
const handleOutputLengthClick = () => {
emit('task-output-length-clicked', props.task)
}
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warn'
}
}
const taskStatusText = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'Pending'
case TaskItemDisplayStatus.Running:
return '<i class="pi pi-spin pi-spinner" style="font-weight: bold"></i> Running'
case TaskItemDisplayStatus.Completed:
return '<i class="pi pi-check" style="font-weight: bold"></i>'
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
return 'Cancelled'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
const onProgressPreviewReceived = async ({ detail }: CustomEvent) => {
if (props.task.displayStatus === TaskItemDisplayStatus.Running) {
if (progressPreviewBlobUrl.value) {
URL.revokeObjectURL(progressPreviewBlobUrl.value)
}
progressPreviewBlobUrl.value = URL.createObjectURL(detail)
}
}
const cancelledWithResults = computed(() => {
return (
props.task.displayStatus === TaskItemDisplayStatus.Cancelled &&
flatOutputs.length
)
})
const cancelledWithoutResults = computed(() => {
return (
props.task.displayStatus === TaskItemDisplayStatus.Cancelled &&
flatOutputs.length === 0
)
})
</script>
<style scoped>
.task-result-preview {
aspect-ratio: 1 / 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.task-result-preview i,
.task-result-preview span {
font-size: 2rem;
}
.task-item {
display: flex;
flex-direction: column;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.task-item-details {
position: absolute;
top: 0.5rem;
padding: 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
z-index: 1;
pointer-events: none; /* Allow clicks to pass through this div */
}
/* Make individual controls clickable again by restoring pointer events */
.task-item-details .tag-wrapper,
.task-item-details button {
pointer-events: auto;
}
.task-node-link {
padding: 2px;
}
/* In dark mode, transparent background color for tags is not ideal for tags that
are floating on top of images. */
.tag-wrapper {
background-color: var(--p-primary-contrast-color);
border-radius: 6px;
display: inline-flex;
}
.node-name-tag {
word-break: break-all;
}
.status-tag-group {
display: flex;
flex-direction: column;
}
.progress-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
</style>

View File

@@ -181,7 +181,6 @@ Composables for sidebar functionality:
|------------|-------------|
| `useModelLibrarySidebarTab` | Manages the model library sidebar tab |
| `useNodeLibrarySidebarTab` | Manages the node library sidebar tab |
| `useQueueSidebarTab` | Manages the queue sidebar tab |
| `useWorkflowsSidebarTab` | Manages the workflows sidebar tab |
### Tree

View File

@@ -13,6 +13,7 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
}
export interface VueNodeData {
id: string
id: NodeId
title: string
type: string
mode: number
@@ -78,10 +79,64 @@ export interface GraphNodeManager {
cleanup(): void
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const nodeDefStore = useNodeDefStore()
return function (widget) {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}
}
export function isValidWidgetValue(value: unknown): value is WidgetValue {
return (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'object'
)
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
const nodeDefStore = useNodeDefStore()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
@@ -147,45 +202,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
linked: input.link != null
})
})
return (
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}) ?? []
)
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
})
const nodeType =

View File

@@ -3,6 +3,7 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,15 +19,18 @@ function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
let hasShownMigrationToast = false
useRenderModeSetting(
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
shouldRenderVueNodes
)
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { resampleSegment } from './splineUtils'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('Shift+Click Drawing Logic', () => {
it('should generate equidistant points across connected segments', () => {
const spacing = 4
let remainder = spacing // Simulate start point already painted
const outputPoints: Point[] = []
// Define points: A -> B -> C
// A(0,0) -> B(10,0) -> C(20,0)
// Total length 20. Spacing 4.
// Expected points at x = 4, 8, 12, 16, 20
const pA = { x: 0, y: 0 }
const pB = { x: 10, y: 0 }
const pC = { x: 20, y: 0 }
// Segment 1: A -> B
const result1 = resampleSegment([pA, pB], spacing, remainder)
outputPoints.push(...result1.points)
remainder = result1.remainder
// Verify intermediate state
// Length 10. Spacing 4. Start offset 4.
// Points at 4, 8. Next at 12.
// Remainder = 12 - 10 = 2.
expect(result1.points.length).toBe(2)
expect(result1.points[0].x).toBeCloseTo(4)
expect(result1.points[1].x).toBeCloseTo(8)
expect(remainder).toBeCloseTo(2)
// Segment 2: B -> C
const result2 = resampleSegment([pB, pC], spacing, remainder)
outputPoints.push(...result2.points)
remainder = result2.remainder
// Verify final state
// Start offset 2. Points at 2, 6, 10 (relative to B).
// Absolute x: 12, 16, 20.
expect(result2.points.length).toBe(3)
expect(result2.points[0].x).toBeCloseTo(12)
expect(result2.points[1].x).toBeCloseTo(16)
expect(result2.points[2].x).toBeCloseTo(20)
// Verify all distances
// Note: The first point is at distance `spacing` from start (0,0)
// Subsequent points are `spacing` apart.
let prevX = 0
for (const p of outputPoints) {
const dist = p.x - prevX
expect(dist).toBeCloseTo(spacing)
prevX = p.x
}
})
it('should handle segments shorter than spacing', () => {
const spacing = 10
let remainder = spacing // Simulate start point already painted
// A(0,0) -> B(5,0) -> C(15,0)
const pA = { x: 0, y: 0 }
const pB = { x: 5, y: 0 }
const pC = { x: 15, y: 0 }
// Segment 1: A -> B (Length 5)
// Spacing 10. No points should be generated.
// Remainder should be 5 (next point needs 5 more units).
const result1 = resampleSegment([pA, pB], spacing, remainder)
expect(result1.points.length).toBe(0)
expect(result1.remainder).toBeCloseTo(5)
remainder = result1.remainder
// Segment 2: B -> C (Length 10)
// Start offset 5. First point at 5 (relative to B).
// Absolute x = 10.
// Next point at 15 (relative to B). Segment ends at 10.
// Remainder = 15 - 10 = 5.
const result2 = resampleSegment([pB, pC], spacing, remainder)
expect(result2.points.length).toBe(1)
expect(result2.points[0].x).toBeCloseTo(10)
expect(result2.remainder).toBeCloseTo(5)
})
})

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { StrokeProcessor } from './StrokeProcessor'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('StrokeProcessor', () => {
it('should generate equidistant points from irregular input', () => {
const spacing = 10
const processor = new StrokeProcessor(spacing)
const outputPoints: Point[] = []
// Simulate a horizontal line drawn with irregular speed
// Points: (0,0) -> (5,0) -> (25,0) -> (30,0) -> (100,0)
const inputPoints: Point[] = [
{ x: 0, y: 0 },
{ x: 5, y: 0 }, // dist 5
{ x: 25, y: 0 }, // dist 20
{ x: 30, y: 0 }, // dist 5
{ x: 100, y: 0 } // dist 70
]
for (const p of inputPoints) {
outputPoints.push(...processor.addPoint(p))
}
outputPoints.push(...processor.endStroke())
// Verify we have points
expect(outputPoints.length).toBeGreaterThan(0)
// Verify spacing
// Note: The first few points might be affected by the start condition,
// but the middle section should be perfectly spaced.
// Also, Catmull-Rom splines don't necessarily pass through control points in a straight line
// if the points are collinear, they should be straight.
// Let's check distances between consecutive points
const distances: number[] = []
for (let i = 1; i < outputPoints.length; i++) {
const dx = outputPoints[i].x - outputPoints[i - 1].x
const dy = outputPoints[i].y - outputPoints[i - 1].y
distances.push(Math.hypot(dx, dy))
}
// Check that distances are close to spacing
// We allow a small epsilon because of floating point and spline approximation
// Filter out the very last segment which might be shorter (remainder)
// But wait, our logic doesn't output the last point if it's not a full spacing step?
// resampleSegment outputs points at [start + spacing, start + 2*spacing, ...]
// It does NOT output the end point of the segment.
// So all distances between output points should be exactly `spacing`.
// EXCEPT possibly if the spline curvature makes the straight-line distance slightly different
// from the arc length. But for a straight line input, it should be exact.
// However, catmull-rom with collinear points IS a straight line.
// Let's log the distances for debugging if test fails
// console.log('Distances:', distances)
// All distances should be approximately equal to spacing
// We might have a gap between segments if the logic isn't perfect,
// but within a segment it's guaranteed by resampleSegment.
// The critical part is the transition between segments.
for (let i = 0; i < distances.length; i++) {
const d = distances[i]
if (Math.abs(d - spacing) > 0.5) {
console.log(
`Distance mismatch at index ${i}: ${d} (expected ${spacing})`
)
console.log(`Point ${i}:`, outputPoints[i])
console.log(`Point ${i + 1}:`, outputPoints[i + 1])
}
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a simple 3-point stroke', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 0, y: 0 }))
points.push(...processor.addPoint({ x: 10, y: 0 }))
points.push(...processor.addPoint({ x: 20, y: 0 }))
points.push(...processor.endStroke())
expect(points.length).toBeGreaterThan(0)
// Check distances
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const d = Math.hypot(dx, dy)
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a single point click', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 100, y: 100 }))
points.push(...processor.endStroke())
expect(points.length).toBe(1)
expect(points[0]).toEqual({ x: 100, y: 100 })
})
})

View File

@@ -0,0 +1,115 @@
import type { Point } from '@/extensions/core/maskeditor/types'
import { catmullRomSpline, resampleSegment } from './splineUtils'
export class StrokeProcessor {
private controlPoints: Point[] = []
private remainder: number = 0
private spacing: number
private isFirstPoint: boolean = true
private hasProcessedSegment: boolean = false
constructor(spacing: number) {
this.spacing = spacing
}
/**
* Adds a point to the stroke and returns any new equidistant points generated.
* Maintain a sliding window of 4 control points for spline generation
*/
public addPoint(point: Point): Point[] {
// Initialize buffer with the first point
if (this.isFirstPoint) {
this.controlPoints.push(point) // p0: phantom start point
this.controlPoints.push(point) // p1: actual start point
this.isFirstPoint = false
return [] // Wait for more points to form a segment
}
this.controlPoints.push(point)
// Require 4 points for a spline segment
if (this.controlPoints.length < 4) {
return []
}
// Generate segment p1->p2
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = this.controlPoints[3]
const newPoints = this.processSegment(p0, p1, p2, p3)
// Slide window
this.controlPoints.shift()
return newPoints
}
/**
* End stroke and flush remaining segments
*/
public endStroke(): Point[] {
if (this.controlPoints.length < 2) {
// Insufficient points for a segment
return []
}
// Process remaining segments by duplicating the last point
const newPoints: Point[] = []
// Flush the buffer by processing the final segment
while (this.controlPoints.length >= 3) {
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = p2 // Duplicate last point as phantom end
const points = this.processSegment(p0, p1, p2, p3)
newPoints.push(...points)
this.controlPoints.shift()
}
// Handle single point click
if (!this.hasProcessedSegment && this.controlPoints.length >= 2) {
// Process zero-length segment for single point
const p = this.controlPoints[1]
const points = this.processSegment(p, p, p, p)
newPoints.push(...points)
}
return newPoints
}
private processSegment(p0: Point, p1: Point, p2: Point, p3: Point): Point[] {
this.hasProcessedSegment = true
// Generate dense points for the segment
const densePoints: Point[] = []
// Adaptive sampling based on segment length
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
// Use 1 sample per pixel, but at least 5 samples to ensure smoothness for short segments
// and cap at a reasonable maximum if needed (though not strictly necessary with density)
const samples = Math.max(5, Math.ceil(dist))
for (let i = 0; i < samples; i++) {
const t = i / samples
densePoints.push(catmullRomSpline(p0, p1, p2, p3, t))
}
// Add segment end point
densePoints.push(p2)
// Resample points with carried-over remainder
const { points, remainder } = resampleSegment(
densePoints,
this.spacing,
this.remainder
)
this.remainder = remainder
return points
}
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
describe('brushUtils', () => {
describe('getEffectiveBrushSize', () => {
it('should return original size when hardness is 1.0', () => {
const size = 100
const hardness = 1.0
expect(getEffectiveBrushSize(size, hardness)).toBe(100)
})
it('should return 1.5x size when hardness is 0.0', () => {
const size = 100
const hardness = 0.0
expect(getEffectiveBrushSize(size, hardness)).toBe(150)
})
it('should interpolate linearly', () => {
const size = 100
const hardness = 0.5
// Scale should be 1.0 + 0.5 * 0.5 = 1.25
expect(getEffectiveBrushSize(size, hardness)).toBe(125)
})
})
describe('getEffectiveHardness', () => {
it('should return same hardness if effective size matches size', () => {
const size = 100
const hardness = 0.8
const effectiveSize = 100
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.8)
})
it('should scale hardness down as effective size increases', () => {
const size = 100
const hardness = 0.5
// Effective size at 0.5 hardness is 125
const effectiveSize = 125
// Hard core radius = 50. New hardness = 50 / 125 = 0.4
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.4)
})
it('should return 0 if effective size is 0', () => {
expect(getEffectiveHardness(100, 0.5, 0)).toBe(0)
})
})
})

View File

@@ -0,0 +1,34 @@
/**
* Calculates the effective brush size based on the base size and hardness.
* As hardness decreases, the effective size increases to allow for a softer falloff.
*
* @param size - The base radius of the brush
* @param hardness - The hardness of the brush (0.0 to 1.0)
* @returns The effective radius of the brush
*/
export function getEffectiveBrushSize(size: number, hardness: number): number {
// Scale factor for maximum softness
const MAX_SCALE = 1.5
const scale = 1.0 + (1.0 - hardness) * (MAX_SCALE - 1.0)
return size * scale
}
/**
* Calculates the effective hardness to maintain the visual "hard core" of the brush.
* Since the effective size is larger, we need to adjust the hardness value so that
* the inner hard circle remains at the same physical radius as the original size * hardness.
*
* @param size - The base radius of the brush
* @param hardness - The base hardness of the brush
* @param effectiveSize - The effective radius (calculated by getEffectiveBrushSize)
* @returns The adjusted hardness value (0.0 to 1.0)
*/
export function getEffectiveHardness(
size: number,
hardness: number,
effectiveSize: number
): number {
if (effectiveSize <= 0) return 0
// Adjust hardness to maintain the physical radius of the hard core
return (size * hardness) / effectiveSize
}

View File

@@ -0,0 +1,805 @@
import * as d from 'typegpu/data'
import { StrokePoint } from './gpuSchema'
import {
brushFragment,
brushVertex,
blitShader,
compositeShader,
readbackShader
} from './brushShaders'
// ... (rest of the file)
const QUAD_VERTS = new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1])
const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3])
const UNIFORM_SIZE = 48 // Uniform buffer size aligned to 16 bytes
const STROKE_STRIDE = d.sizeOf(StrokePoint) // 16
const MAX_STROKES = 10000
export class GPUBrushRenderer {
private device: GPUDevice
// Buffers
private quadVertexBuffer: GPUBuffer
private indexBuffer: GPUBuffer
private instanceBuffer: GPUBuffer
private uniformBuffer: GPUBuffer
// Pipelines
private renderPipeline: GPURenderPipeline // Standard alpha blending pipeline
private accumulatePipeline: GPURenderPipeline // SourceOver blending pipeline for stroke accumulation
private blitPipeline: GPURenderPipeline
private compositePipeline: GPURenderPipeline // Composite pipeline that applies opacity
private compositePipelinePreview: GPURenderPipeline // Pipeline for rendering to the preview canvas
private erasePipeline: GPURenderPipeline // Pipeline for erasing (Destination Out)
private erasePipelinePreview: GPURenderPipeline // Eraser pipeline for the preview canvas
readbackPipeline: GPUComputePipeline // Compute pipeline for texture readback
// Bind Group Layouts
private uniformBindGroupLayout: GPUBindGroupLayout
private textureBindGroupLayout: GPUBindGroupLayout
// Shared Bind Groups
private mainUniformBindGroup: GPUBindGroup
// Textures
private currentStrokeTexture: GPUTexture | null = null
private currentStrokeView: GPUTextureView | null = null
// Cached Bind Groups
private compositeTextureBindGroup: GPUBindGroup | null = null
private previewTextureBindGroup: GPUBindGroup | null = null
// Removed separate uniform bind groups as we will use mainUniformBindGroup
private lastReadbackTexture: GPUTexture | null = null
private lastReadbackBuffer: GPUBuffer | null = null
private readbackBindGroup: GPUBindGroup | null = null
private lastBackgroundTexture: GPUTexture | null = null
private backgroundBindGroup: GPUBindGroup | null = null
constructor(
device: GPUDevice,
presentationFormat: GPUTextureFormat = 'rgba8unorm'
) {
this.device = device
// --- 1. Initialize Buffers ---
this.quadVertexBuffer = device.createBuffer({
size: QUAD_VERTS.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
new Float32Array(this.quadVertexBuffer.getMappedRange()).set(QUAD_VERTS)
this.quadVertexBuffer.unmap()
this.indexBuffer = device.createBuffer({
size: QUAD_INDICES.byteLength,
usage: GPUBufferUsage.INDEX,
mappedAtCreation: true
})
new Uint16Array(this.indexBuffer.getMappedRange()).set(QUAD_INDICES)
this.indexBuffer.unmap()
this.instanceBuffer = device.createBuffer({
size: MAX_STROKES * STROKE_STRIDE,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
this.uniformBuffer = device.createBuffer({
size: UNIFORM_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
})
// --- 2. Brush Shader (Drawing) ---
const brushModuleV = device.createShaderModule({ code: brushVertex })
const brushModuleF = device.createShaderModule({ code: brushFragment })
// Create explicit bind group layouts
this.uniformBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' }
}
]
})
this.textureBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
texture: {} // default is float, 2d
}
]
})
this.mainUniformBindGroup = device.createBindGroup({
layout: this.uniformBindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }]
})
const renderPipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.uniformBindGroupLayout]
})
// Standard Render Pipeline (Alpha Blend)
this.renderPipeline = device.createRenderPipeline({
layout: renderPipelineLayout,
vertex: {
module: brushModuleV,
entryPoint: 'vs',
buffers: [
{
arrayStride: 8,
stepMode: 'vertex',
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] // Quad vertex attributes
},
{
arrayStride: 16,
stepMode: 'instance',
attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32x2' }, // Instance attributes: position
{ shaderLocation: 2, offset: 8, format: 'float32' }, // size
{ shaderLocation: 3, offset: 12, format: 'float32' } // pressure
]
}
]
},
fragment: {
module: brushModuleF,
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Accumulate strokes using SourceOver blending to ensure smooth intersections.
this.accumulatePipeline = device.createRenderPipeline({
layout: renderPipelineLayout,
vertex: {
module: brushModuleV,
entryPoint: 'vs',
buffers: [
{
arrayStride: 8,
stepMode: 'vertex',
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }]
},
{
arrayStride: 16,
stepMode: 'instance',
attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32x2' },
{ shaderLocation: 2, offset: 8, format: 'float32' },
{ shaderLocation: 3, offset: 12, format: 'float32' }
]
}
]
},
fragment: {
module: brushModuleF,
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
// Use SourceOver blending for smooth stroke intersections.
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 3. Blit Pipeline (For Preview) ---
const blitPipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.textureBindGroupLayout]
})
this.blitPipeline = device.createRenderPipeline({
layout: blitPipelineLayout,
vertex: {
module: device.createShaderModule({ code: blitShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: blitShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat, // Use the presentation format
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 4. Composite Pipeline ---
const compositePipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [
this.textureBindGroupLayout,
this.uniformBindGroupLayout
]
})
// Standard composite pipeline for offscreen textures
this.compositePipeline = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Composite pipeline for the preview canvas
this.compositePipelinePreview = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 5. Erase Pipeline (Destination Out) ---
// Standard erase pipeline for offscreen textures
this.erasePipeline = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha', // dst * (1 - src_alpha)
operation: 'add'
},
alpha: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha', // dst_alpha * (1 - src_alpha)
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Erase pipeline for the preview canvas
this.erasePipelinePreview = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 6. Readback Pipeline (Compute) ---
this.readbackPipeline = device.createComputePipeline({
layout: 'auto',
compute: {
module: device.createShaderModule({ code: readbackShader }),
entryPoint: 'main'
}
})
}
public prepareStroke(width: number, height: number) {
// Initialize or resize the accumulation texture
if (
!this.currentStrokeTexture ||
this.currentStrokeTexture.width !== width ||
this.currentStrokeTexture.height !== height
) {
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
this.currentStrokeTexture = this.device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_SRC
})
this.currentStrokeView = this.currentStrokeTexture.createView()
// Invalidate texture-dependent bind groups
this.compositeTextureBindGroup = null
this.previewTextureBindGroup = null
// Readback bind group might also be invalid if it was using the old texture
if (this.lastReadbackTexture === this.currentStrokeTexture) {
this.readbackBindGroup = null
this.lastReadbackTexture = null
}
}
// Clear the accumulation texture
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: this.currentStrokeView!,
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.end()
this.device.queue.submit([encoder.finish()])
}
public renderStrokeToAccumulator(
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
if (!this.currentStrokeView) return
// Render stroke using accumulation pipeline
this.renderStrokeInternal(
this.currentStrokeView,
this.accumulatePipeline,
points,
settings
)
}
public compositeStroke(
targetView: GPUTextureView,
settings: {
opacity: number
color: [number, number, number]
hardness: number // Required for uniform buffer layout
screenSize: [number, number]
brushShape: number
isErasing?: boolean
}
) {
if (!this.currentStrokeTexture) return
// Update uniforms for the composite pass
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.screenSize[0]
f32[7] = settings.screenSize[1]
u32[8] = settings.brushShape // Brush shape: 0=Circle, 1=Square
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
const encoder = this.device.createCommandEncoder()
// Choose pipeline based on operation
const pipeline = settings.isErasing
? this.erasePipeline
: this.compositePipeline
// 1. Texture Bind Group (Group 0)
if (!this.compositeTextureBindGroup) {
this.compositeTextureBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [
{ binding: 0, resource: this.currentStrokeTexture.createView() }
]
})
}
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
// It is compatible because we used the same layout
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: targetView,
loadOp: 'load',
storeOp: 'store'
}
]
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, this.compositeTextureBindGroup)
pass.setBindGroup(1, this.mainUniformBindGroup)
pass.draw(3)
pass.end()
this.device.queue.submit([encoder.finish()])
}
// Direct rendering method
public renderStroke(
targetView: GPUTextureView,
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
this.renderStrokeInternal(targetView, this.renderPipeline, points, settings)
}
private renderStrokeInternal(
targetView: GPUTextureView,
pipeline: GPURenderPipeline,
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
if (points.length === 0) return
// 1. Update Uniforms
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.width
f32[7] = settings.height
u32[8] = settings.brushShape
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
// 2. Batch Rendering
let processedPoints = 0
while (processedPoints < points.length) {
const batchSize = Math.min(points.length - processedPoints, MAX_STROKES)
const iData = new Float32Array(batchSize * 4)
for (let i = 0; i < batchSize; i++) {
const p = points[processedPoints + i]
iData[i * 4 + 0] = p.x
iData[i * 4 + 1] = p.y
iData[i * 4 + 2] = settings.size
iData[i * 4 + 3] = p.pressure
}
this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
// 3. Render Pass
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: targetView,
loadOp: 'load',
storeOp: 'store'
}
]
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, this.mainUniformBindGroup)
pass.setVertexBuffer(0, this.quadVertexBuffer)
pass.setVertexBuffer(1, this.instanceBuffer)
pass.setIndexBuffer(this.indexBuffer, 'uint16')
pass.drawIndexed(6, batchSize)
pass.end()
this.device.queue.submit([encoder.finish()])
processedPoints += batchSize
}
}
// Blit the accumulated stroke to the preview canvas
public blitToCanvas(
destinationCtx: GPUCanvasContext,
settings: {
opacity: number
color: [number, number, number]
hardness: number
screenSize: [number, number]
brushShape: number
isErasing?: boolean
},
backgroundTexture?: GPUTexture
) {
const encoder = this.device.createCommandEncoder()
const destView = destinationCtx.getCurrentTexture().createView()
if (backgroundTexture) {
// Draw background texture to allow erasing effect on existing content
if (
this.lastBackgroundTexture !== backgroundTexture ||
!this.backgroundBindGroup
) {
this.backgroundBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [{ binding: 0, resource: backgroundTexture.createView() }]
})
this.lastBackgroundTexture = backgroundTexture
}
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'clear', // Clear attachment before drawing
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.setPipeline(this.blitPipeline)
pass.setBindGroup(0, this.backgroundBindGroup)
pass.draw(3)
pass.end()
} else {
// Clear the destination texture
const clearPass = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
clearPass.end()
}
// Draw the accumulated stroke
if (this.currentStrokeTexture) {
// Update uniforms for the preview pass
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.screenSize[0]
f32[7] = settings.screenSize[1]
u32[8] = settings.brushShape
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
// Select preview pipeline based on operation
const pipeline = settings.isErasing
? this.erasePipelinePreview
: this.compositePipelinePreview
// 1. Texture Bind Group (Group 0)
if (!this.previewTextureBindGroup) {
this.previewTextureBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [
{ binding: 0, resource: this.currentStrokeTexture.createView() }
]
})
}
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
const passStroke = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'load', // Load the previous pass result
storeOp: 'store'
}
]
})
passStroke.setPipeline(pipeline)
passStroke.setBindGroup(0, this.previewTextureBindGroup)
passStroke.setBindGroup(1, this.mainUniformBindGroup)
passStroke.draw(3)
passStroke.end()
}
this.device.queue.submit([encoder.finish()])
}
// Clear the preview canvas
public clearPreview(destinationCtx: GPUCanvasContext) {
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: destinationCtx.getCurrentTexture().createView(),
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.end()
this.device.queue.submit([encoder.finish()])
}
public prepareReadback(texture: GPUTexture, outputBuffer: GPUBuffer) {
if (
this.lastReadbackTexture !== texture ||
this.lastReadbackBuffer !== outputBuffer ||
!this.readbackBindGroup
) {
this.readbackBindGroup = this.device.createBindGroup({
layout: this.readbackPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: texture.createView() },
{ binding: 1, resource: { buffer: outputBuffer } }
]
})
this.lastReadbackTexture = texture
this.lastReadbackBuffer = outputBuffer
}
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginComputePass()
pass.setPipeline(this.readbackPipeline)
pass.setBindGroup(0, this.readbackBindGroup)
const width = texture.width
const height = texture.height
// Dispatch workgroups based on texture dimensions (8x8 block size)
pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8))
pass.end()
this.device.queue.submit([encoder.finish()])
}
public destroy() {
this.quadVertexBuffer.destroy()
this.indexBuffer.destroy()
this.instanceBuffer.destroy()
this.uniformBuffer.destroy()
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
// Clear cached bind groups
this.compositeTextureBindGroup = null
this.previewTextureBindGroup = null
this.readbackBindGroup = null
this.backgroundBindGroup = null
this.lastReadbackTexture = null
this.lastReadbackBuffer = null
this.lastBackgroundTexture = null
}
}

View File

@@ -0,0 +1,171 @@
import tgpu from 'typegpu'
import * as d from 'typegpu/data'
import { BrushUniforms } from './gpuSchema'
const VertexOutput = d.struct({
position: d.builtin.position,
localUV: d.location(0, d.vec2f),
color: d.location(1, d.vec3f),
opacity: d.location(2, d.f32),
hardness: d.location(3, d.f32)
})
const brushVertexTemplate = `
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
@vertex
fn vs(
@location(0) quadPos: vec2<f32>,
@location(1) pos: vec2<f32>,
@location(2) size: f32,
@location(3) pressure: f32
) -> VertexOutput {
// Convert diameter to radius
let radius = size * pressure;
let pixelPos = pos + (quadPos * radius);
// Convert pixel coordinates to Normalized Device Coordinates (NDC)
let ndcX = (pixelPos.x / globals.screenSize.x) * 2.0 - 1.0;
let ndcY = 1.0 - ((pixelPos.y / globals.screenSize.y) * 2.0); // Flip Y axis for WebGPU coordinate system
return VertexOutput(
vec4<f32>(ndcX, ndcY, 0.0, 1.0),
quadPos,
globals.brushColor,
pressure * globals.brushOpacity,
globals.hardness
);
}
`
export const brushVertex = tgpu.resolve({
template: brushVertexTemplate,
externals: {
BrushUniforms,
VertexOutput
}
})
const brushFragmentTemplate = `
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
@fragment
fn fs(v: VertexOutput) -> @location(0) vec4<f32> {
var dist: f32;
if (globals.brushShape == 1u) {
// Calculate Chebyshev distance for square shape
dist = max(abs(v.localUV.x), abs(v.localUV.y));
} else {
// Calculate Euclidean distance for circle shape
dist = length(v.localUV);
}
if (dist > 1.0) { discard; }
// Calculate alpha with hardness and anti-aliasing
let edgeWidth = fwidth(dist);
let startFade = min(v.hardness, 1.0 - edgeWidth * 2.0);
let linearAlpha = 1.0 - smoothstep(startFade, 1.0, dist);
// Apply quadratic falloff for smoother edges
let alphaShape = pow(linearAlpha, 2.0);
// Return premultiplied alpha color
let alpha = alphaShape * v.opacity;
return vec4<f32>(v.color * alpha, alpha);
}
`
export const brushFragment = tgpu.resolve({
template: brushFragmentTemplate,
externals: {
VertexOutput,
BrushUniforms
}
})
const blitShaderTemplate = `
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(pos[vIdx], 0.0, 1.0);
}
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let c = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
// Treat texture as premultiplied to prevent double-darkening on overlaps
return c;
}
`
export const blitShader = tgpu.resolve({
template: blitShaderTemplate,
externals: {}
})
const compositeShaderTemplate = `
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(pos[vIdx], 0.0, 1.0);
}
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@group(1) @binding(0) var<uniform> globals: BrushUniforms;
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let sampled = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
// Apply global brush opacity to accumulated coverage
return sampled * globals.brushOpacity;
}
`
export const compositeShader = tgpu.resolve({
template: compositeShaderTemplate,
externals: {
BrushUniforms
}
})
const readbackShaderTemplate = `
@group(0) @binding(0) var inputTex: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> outputBuf: array<u32>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let dims = textureDimensions(inputTex);
if (id.x >= dims.x || id.y >= dims.y) { return; }
let color = textureLoad(inputTex, vec2<i32>(id.xy), 0);
var r = color.r;
var g = color.g;
var b = color.b;
let a = color.a;
if (a > 0.0) {
r = r / a;
g = g / a;
b = b / a;
}
let ir = u32(clamp(r * 255.0, 0.0, 255.0));
let ig = u32(clamp(g * 255.0, 0.0, 255.0));
let ib = u32(clamp(b * 255.0, 0.0, 255.0));
let ia = u32(clamp(a * 255.0, 0.0, 255.0));
// Pack RGBA channels into a single u32 (Little Endian)
let packed = ir | (ig << 8u) | (ib << 16u) | (ia << 24u);
let index = id.y * dims.x + id.x;
outputBuf[index] = packed;
}
`
export const readbackShader = tgpu.resolve({
template: readbackShaderTemplate,
externals: {}
})

View File

@@ -0,0 +1,17 @@
import * as d from 'typegpu/data'
// Global brush uniforms
export const BrushUniforms = d.struct({
brushColor: d.vec3f,
brushOpacity: d.f32,
hardness: d.f32,
screenSize: d.vec2f,
brushShape: d.u32 // 0: Circle, 1: Square
})
// Per-point instance data
export const StrokePoint = d.struct({
pos: d.location(0, d.vec2f), // Center position
size: d.location(1, d.f32), // Brush radius
pressure: d.location(2, d.f32) // Pressure value (0.0 - 1.0)
})

View File

@@ -0,0 +1,126 @@
import type { Point } from '@/extensions/core/maskeditor/types'
/**
* Evaluates a Catmull-Rom spline at parameter t between p1 and p2
* @param p0 Previous control point
* @param p1 Start point of the curve segment
* @param p2 End point of the curve segment
* @param p3 Next control point
* @param t Parameter in range [0, 1]
* @returns Interpolated point on the curve
*/
export function catmullRomSpline(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
t: number
): Point {
// Centripetal Catmull-Rom Spline (alpha = 0.5) to prevent loops and overshoots
const alpha = 0.5
const getT = (t: number, p0: Point, p1: Point) => {
const d = Math.hypot(p1.x - p0.x, p1.y - p0.y)
return t + Math.pow(d, alpha)
}
const t0 = 0
const t1 = getT(t0, p0, p1)
const t2 = getT(t1, p1, p2)
const t3 = getT(t2, p2, p3)
// Map normalized t to parameter range
const tInterp = t1 + (t2 - t1) * t
// Safe interpolation for coincident points
const interp = (
pA: Point,
pB: Point,
tA: number,
tB: number,
t: number
): Point => {
if (Math.abs(tB - tA) < 0.0001) return pA
const k = (t - tA) / (tB - tA)
return add(mul(pA, 1 - k), mul(pB, k))
}
// Barry-Goldman pyramidal interpolation
const A1 = interp(p0, p1, t0, t1, tInterp)
const A2 = interp(p1, p2, t1, t2, tInterp)
const A3 = interp(p2, p3, t2, t3, tInterp)
const B1 = interp(A1, A2, t0, t2, tInterp)
const B2 = interp(A2, A3, t1, t3, tInterp)
const C = interp(B1, B2, t1, t2, tInterp)
return C
}
function add(p1: Point, p2: Point): Point {
return { x: p1.x + p2.x, y: p1.y + p2.y }
}
function mul(p: Point, s: number): Point {
return { x: p.x * s, y: p.y * s }
}
/**
* Resamples a curve segment with a starting offset (remainder from previous segment).
* Returns the resampled points and the new remainder distance.
*
* @param points Points defining the curve segment
* @param spacing Desired spacing between points
* @param startOffset Distance to travel before placing the first point (remainder)
* @returns Object containing points and new remainder
*/
export function resampleSegment(
points: Point[],
spacing: number,
startOffset: number
): { points: Point[]; remainder: number } {
if (points.length === 0) return { points: [], remainder: startOffset }
const result: Point[] = []
let currentDist = 0
let nextSampleDist = startOffset
// Iterate through segment points
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i]
const p2 = points[i + 1]
const dx = p2.x - p1.x
const dy = p2.y - p1.y
const segmentLen = Math.hypot(dx, dy)
// Handle zero-length segments
if (segmentLen < 0.0001) {
while (nextSampleDist <= currentDist) {
result.push(p1)
nextSampleDist += spacing
}
continue
}
// Generate samples within the segment
while (nextSampleDist <= currentDist + segmentLen) {
const t = (nextSampleDist - currentDist) / segmentLen
// Interpolate
const x = p1.x + t * dx
const y = p1.y + t * dy
result.push({ x, y })
nextSampleDist += spacing
}
currentDist += segmentLen
}
// Calculate remainder distance for the next segment
const remainder = nextSampleDist - currentDist
return { points: result, remainder }
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
const states = ref<{ mask: ImageData; rgb: ImageData }[]>([])
const states = ref<
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -53,7 +55,10 @@ export function useCanvasHistory(maxStates = 20) {
initialized.value = true
}
const saveState = () => {
const saveState = (
providedMaskData?: ImageData | ImageBitmap,
providedRgbData?: ImageData | ImageBitmap
) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
@@ -68,23 +73,32 @@ export function useCanvasHistory(maxStates = 20) {
states.value = states.value.slice(0, currentStateIndex.value + 1)
const maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const rgbState = rgbCtx.getImageData(
0,
0,
rgbCanvas.width,
rgbCanvas.height
)
let maskState: ImageData | ImageBitmap
let rgbState: ImageData | ImageBitmap
if (providedMaskData && providedRgbData) {
maskState = providedMaskData
rgbState = providedRgbData
} else {
maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
}
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value++
if (states.value.length > maxStates) {
states.value.shift()
const removed = states.value.shift()
// Cleanup ImageBitmaps to avoid memory leaks
if (removed) {
if (removed.mask instanceof ImageBitmap) removed.mask.close()
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
}
currentStateIndex.value--
}
}
@@ -109,16 +123,35 @@ export function useCanvasHistory(maxStates = 20) {
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: { mask: ImageData; rgb: ImageData }) => {
const restoreState = (state: {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
}) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
maskCtx.putImageData(state.mask, 0, 0)
rgbCtx.putImageData(state.rgb, 0, 0)
if (state.mask instanceof ImageBitmap) {
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
maskCtx.drawImage(state.mask, 0, 0)
} else {
maskCtx.putImageData(state.mask, 0, 0)
}
if (state.rgb instanceof ImageBitmap) {
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
rgbCtx.drawImage(state.rgb, 0, 0)
} else {
rgbCtx.putImageData(state.rgb, 0, 0)
}
}
const clearStates = () => {
// Cleanup bitmaps
states.value.forEach((state) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
})
states.value = []
currentStateIndex.value = -1
initialized.value = false
@@ -127,6 +160,7 @@ export function useCanvasHistory(maxStates = 20) {
return {
canUndo,
canRedo,
currentStateIndex,
saveInitialState,
saveState,
undo,

View File

@@ -0,0 +1,48 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useDialogStore } from '@/stores/dialogStore'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
export function useMaskEditor() {
const openMaskEditor = (node: LGraphNode) => {
if (!node) {
console.error('[MaskEditor] No node provided')
return
}
if (!node.imgs?.length && node.previewMediaType !== 'image') {
console.error('[MaskEditor] Node has no images')
return
}
useDialogStore().showDialog({
key: 'global-mask-editor',
headerComponent: TopBarHeader,
component: MaskEditorContent,
props: {
node
},
dialogComponentProps: {
style: 'width: 90vw; height: 90vh;',
modal: true,
maximizable: true,
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'
},
header: {
class: '!p-2'
}
}
}
})
}
return {
openMaskEditor
}
}

View File

@@ -80,21 +80,64 @@ export function useMaskEditorLoader() {
try {
validateNode(node)
const nodeImageUrl = getNodeImageUrl(node)
let nodeImageUrl = getNodeImageUrl(node)
const nodeImageRef = parseImageRef(nodeImageUrl)
let nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (
imageWidget &&
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
if (imageWidget) {
if (typeof imageWidget.value === 'string') {
widgetFilename = imageWidget.value
} else if (
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
}
// If we have a widget filename, we should prioritize it over the node image
// because the node image might be stale (e.g. from a previous save)
// while the widget value reflects the current selection.
if (widgetFilename) {
try {
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
let filename = widgetFilename
let subfolder: string | undefined = undefined
let type: string | undefined = 'input' // Default to input for widget values
// Check for type in brackets at the end
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
if (typeMatch) {
type = typeMatch[1]
filename = filename.substring(
0,
filename.length - typeMatch[0].length
)
}
// Check for subfolder (forward slash separator)
const lastSlashIndex = filename.lastIndexOf('/')
if (lastSlashIndex !== -1) {
subfolder = filename.substring(0, lastSlashIndex)
filename = filename.substring(lastSlashIndex + 1)
}
nodeImageRef = {
filename,
type,
subfolder
}
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
} catch (e) {
console.warn('Failed to parse widget filename as ref', e)
}
}

View File

@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}
},
GeminiImageNode: {
displayPrice: '$0.03 per 1K tokens'
displayPrice: '~$0.039/Image (1K)'
},
GeminiImage2Node: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!resolutionWidget) return 'Token-based'
const resolution = String(resolutionWidget.value)
if (resolution.includes('1K')) {
return '~$0.134/Image'
} else if (resolution.includes('2K')) {
return '~$0.134/Image'
} else if (resolution.includes('4K')) {
return '~$0.24/Image'
}
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
@@ -1829,6 +1848,7 @@ export const useNodePricing = () => {
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
GeminiImage2Node: ['resolution'],
// OpenAI nodes
OpenAIChatNode: ['model'],
// ByteDance

View File

@@ -0,0 +1,42 @@
import type { ComputedRef } from 'vue'
import { ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
setting: TSettingKey
vue: Settings[TSettingKey]
litegraph: Settings[TSettingKey]
}
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
config: RenderModeSettingConfig<TSettingKey>,
isVueMode: ComputedRef<boolean>
) {
const settingStore = useSettingStore()
const vueValue = ref(config.vue)
const litegraphValue = ref(config.litegraph)
const lastWasVue = ref<boolean | null>(null)
const load = async (vue: boolean) => {
if (lastWasVue.value === vue) return
if (lastWasVue.value !== null) {
const currentValue = settingStore.get(config.setting)
if (lastWasVue.value) {
vueValue.value = currentValue
} else {
litegraphValue.value = currentValue
}
}
await settingStore.set(
config.setting,
vue ? vueValue.value : litegraphValue.value
)
lastWasVue.value = vue
}
watch(isVueMode, load, { immediate: true })
}

View File

@@ -1,22 +0,0 @@
import { markRaw } from 'vue'
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useQueueSidebarTab = (): SidebarTabExtension => {
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
return {
id: 'queue',
icon: 'pi pi-history',
iconBadge: () => {
const value = queuePendingTaskCountStore.count.toString()
return value === '0' ? null : value
},
title: 'sideToolbar.queue',
tooltip: 'sideToolbar.queue',
label: 'sideToolbar.labels.queue',
component: markRaw(QueueSidebarTab),
type: 'vue'
}
}

View File

@@ -1219,6 +1219,12 @@ export function useCoreCommands(): ComfyCommand[] {
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
}
},
{
id: 'Comfy.ToggleLinear',
icon: 'pi pi-database',
label: 'toggle linear mode',
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
}
]

View File

@@ -30,12 +30,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Comfy.RefreshNodeDefinitions'
},
{
combo: {
key: 'q'
},
commandId: 'Workspace.ToggleSidebarTab.queue'
},
{
combo: {
key: 'w'

View File

@@ -1,3 +1,5 @@
import { isCloud } from '@/platform/distribution/types'
export const CORE_MENU_COMMANDS = [
[[], ['Comfy.NewBlankWorkflow']],
[[], []], // Separator after New
@@ -14,13 +16,16 @@ export const CORE_MENU_COMMANDS = [
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Edit'],
[
'Comfy.RefreshNodeDefinitions',
'Comfy.Memory.UnloadModels',
'Comfy.Memory.UnloadModelsAndExecutionCache'
...(isCloud
? []
: [
'Comfy.Memory.UnloadModels',
'Comfy.Memory.UnloadModelsAndExecutionCache'
])
]
],
[['View'], []],

View File

@@ -0,0 +1,114 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
function dynamicComboWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
appArg: ComfyApp,
widgetName?: string
) {
const { addNodeInput } = useLitegraphService()
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
const inputData = parseResult.data
const options = Object.fromEntries(
inputData[1].options.map(({ key, inputs }) => [key, inputs])
)
const subSpec: ComboInputSpec = [Object.keys(options), {}]
const { widget, minWidth, minHeight } = app.widgets['COMBO'](
node,
inputName,
subSpec,
appArg,
widgetName
)
let currentDynamicNames: string[] = []
const updateWidgets = (value?: string) => {
if (!node.widgets) throw new Error('Not Reachable')
const newSpec = value ? options[value] : undefined
//TODO: Calculate intersection for widgets that persist across options
//This would potentially allow links to be retained
for (const name of currentDynamicNames) {
const inputIndex = node.inputs.findIndex((input) => input.name === name)
if (inputIndex !== -1) node.removeInput(inputIndex)
const widgetIndex = node.widgets.findIndex(
(widget) => widget.name === name
)
if (widgetIndex === -1) continue
node.widgets[widgetIndex].value = undefined
node.widgets.splice(widgetIndex, 1)
}
currentDynamicNames = []
if (!newSpec) return
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === widget.name) + 1
const startingInputLength = node.inputs.length
if (insertionPoint === 0)
throw new Error("Dynamic widget doesn't exist on node")
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[newSpec.required, false],
[newSpec.optional, true]
]
for (const [inputType, isOptional] of inputTypes)
for (const name in inputType ?? {}) {
addNodeInput(
node,
transformInputSpecV1ToV2(inputType![name], {
name,
isOptional
})
)
currentDynamicNames.push(name)
}
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
node.inputs.length !== startingInputLength
)
//input is inputOnly, but lacks an insertion point
throw new Error('Failed to find input socket for ' + widget.name)
return
}
const addedInputs = node
.spliceInputs(startingInputLength)
.map((addedInput) => {
const existingInput = node.inputs.findIndex(
(existingInput) => addedInput.name === existingInput.name
)
return existingInput === -1
? addedInput
: node.spliceInputs(existingInput, 1)[0]
})
//assume existing inputs are in correct order
node.spliceInputs(inputInsertionPoint, 0, ...addedInputs)
node.size[1] = node.computeSize([...node.size])[1]
}
//A little hacky, but onConfigure won't work.
//It fires too late and is overly disruptive
let widgetValue = widget.value
Object.defineProperty(widget, 'value', {
get() {
return widgetValue
},
set(value) {
widgetValue = value
updateWidgets(value)
}
})
widget.value = widgetValue
return { widget, minWidth, minHeight }
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }

View File

@@ -5,10 +5,9 @@ import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import { MaskEditorDialogOld } from './maskEditorOld'
import { ClipspaceDialog } from './clipspace'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
function openMaskEditor(node: LGraphNode): void {
if (!node) {
@@ -26,32 +25,7 @@ function openMaskEditor(node: LGraphNode): void {
)
if (useNewEditor) {
// Use new refactored editor
useDialogStore().showDialog({
key: 'global-mask-editor',
headerComponent: TopBarHeader,
component: MaskEditorContent,
props: {
node
},
dialogComponentProps: {
style: 'width: 90vw; height: 90vh;',
modal: true,
maximizable: true,
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'
},
header: {
class: '!p-2'
}
}
}
})
useMaskEditor().openMaskEditor(node)
} else {
// Use old editor
ComfyApp.copyToClipspace(node)

View File

@@ -61,5 +61,5 @@ export interface Brush {
size: number
opacity: number
hardness: number
smoothingPrecision: number
stepSize: number
}

View File

@@ -17,10 +17,10 @@ useExtensionService().registerExtension({
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const showValueWidget = ComfyWidgets['STRING'](
const showValueWidget = ComfyWidgets['MARKDOWN'](
this,
'preview',
['STRING', { multiline: true }],
['MARKDOWN', {}],
app
).widget as DOMWidget<HTMLTextAreaElement, string>

View File

@@ -79,7 +79,7 @@ export type {
LGraphTriggerParam
} from './types/graphTriggers'
export type rendererType = 'LG' | 'Vue'
export type RendererType = 'LG' | 'Vue'
export interface LGraphState {
lastGroupId: number
@@ -106,7 +106,7 @@ export interface LGraphExtra extends Dictionary<unknown> {
reroutes?: SerialisableReroute[]
linkExtensions?: { id: number; parentId: number | undefined }[]
ds?: DragAndScaleState
workflowRendererVersion?: rendererType
workflowRendererVersion?: RendererType
}
export interface BaseLGraph {

View File

@@ -1771,18 +1771,19 @@ export class LGraphCanvas
}
static onMenuNodeClone(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
node: LGraphNode
): void {
const canvas = LGraphCanvas.active_canvas
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
}
static cloneNodes(nodes: Positionable[]) {
const canvas = LGraphCanvas.active_canvas
// Find top-left-most boundary
let offsetX = Infinity
@@ -1792,11 +1793,11 @@ export class LGraphCanvas
throw new TypeError(
'Invalid node encountered on clone. `pos` was null.'
)
if (item.pos[0] < offsetX) offsetX = item.pos[0]
if (item.pos[1] < offsetY) offsetY = item.pos[1]
offsetX = Math.min(offsetX, item.pos[0])
offsetY = Math.min(offsetY, item.pos[1])
}
canvas._deserializeItems(canvas._serializeItems(nodes), {
return canvas._deserializeItems(canvas._serializeItems(nodes), {
position: [offsetX + 5, offsetY + 5]
})
}

View File

@@ -835,6 +835,9 @@ export class LGraphNode
for (const w of this.widgets) {
if (!w) continue
const input = this.inputs.find((i) => i.widget?.name === w.name)
if (input?.label) w.label = input.label
if (
w.options?.property &&
this.properties[w.options.property] != undefined
@@ -845,15 +848,13 @@ export class LGraphNode
}
if (info.widgets_values) {
const widgetsWithValue = this.widgets.filter(
(w) => w.serialize !== false
const widgetsWithValue = this.widgets
.values()
.filter((w) => w.serialize !== false)
.filter((_w, idx) => idx < info.widgets_values!.length)
widgetsWithValue.forEach(
(widget, i) => (widget.value = info.widgets_values![i])
)
for (let i = 0; i < info.widgets_values.length; ++i) {
const widget = widgetsWithValue[i]
if (widget) {
widget.value = info.widgets_values[i]
}
}
}
}
@@ -881,7 +882,7 @@ export class LGraphNode
// special case for when there were errors
if (this.constructor === LGraphNode && this.last_serialization)
return this.last_serialization
return { ...this.last_serialization, mode: o.mode, pos: o.pos }
if (this.inputs)
o.inputs = this.inputs.map((input) => inputAsSerialisable(input))
@@ -1649,6 +1650,19 @@ export class LGraphNode
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)
}
spliceInputs(
startIndex: number,
deleteCount = -1,
...toAdd: INodeInputSlot[]
): INodeInputSlot[] {
if (deleteCount < 0) return this.inputs.splice(startIndex)
const ret = this.inputs.splice(startIndex, deleteCount, ...toAdd)
this.inputs.slice(startIndex).forEach((input, index) => {
const link = input.link && this.graph?.links?.get(input.link)
if (link) link.target_slot = startIndex + index
})
return ret
}
/**
* computes the minimum size of a node according to its inputs and output slots

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "التحقق من التحديثات"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "فتح مجلد العقد المخصصة"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "فتح مجلد المدخلات"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "فتح مجلد السجلات"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "فتح extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "فتح مجلد النماذج"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "فتح مجلد المخرجات"
},
"Comfy-Desktop_OpenDevTools": {
"label": "فتح أدوات المطور"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "دليل المستخدم لسطح المكتب"
},
"Comfy-Desktop_Quit": {
"label": "خروج"
},
"Comfy-Desktop_Reinstall": {
"label": "إعادة التثبيت"
},
"Comfy-Desktop_Restart": {
"label": "إعادة التشغيل"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة"
},
"Comfy_BrowseModelAssets": {
"label": "تجريبي: تصفح أصول النماذج"
},
"Comfy_BrowseTemplates": {
"label": "تصفح القوالب"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "تحويل التحديد إلى رسم فرعي"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "تحرير عناصر واجهة الرسم البياني الفرعي"
},
"Comfy_Graph_ExitSubgraph": {
"label": "الخروج من الرسم البياني الفرعي"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "تجميع العقد المحددة"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "تبديل ترقية عنصر الواجهة المحوم فوقه"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "فك التفرع الفرعي المحدد"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "عرض نافذة الإعدادات"
},
"Comfy_ToggleAssetAPI": {
"label": "تجريبي: تمكين AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "أداء اللوحة"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "تسجيل الخروج"
},
"Experimental_ToggleVueNodes": {
"label": "تجريبي: تمكين عقد Vue"
},
"Workspace_CloseWorkflow": {
"label": "إغلاق سير العمل الحالي"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "تبديل وضع التركيز"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "تبديل الشريط الجانبي للأصول",
"tooltip": "الأصول"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "تبديل الشريط الجانبي لمكتبة النماذج",
"tooltip": "مكتبة النماذج"
@@ -298,31 +281,8 @@
"label": "تبديل الشريط الجانبي لمكتبة العقد",
"tooltip": "مكتبة العقد"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "تبديل الشريط الجانبي لقائمة الانتظار",
"tooltip": "قائمة الانتظار"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "تبديل الشريط الجانبي لسير العمل",
"tooltip": "سير العمل"
},
"Comfy_BrowseModelAssets": {
"label": "تجريبي: تصفح أصول النماذج"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "تحرير عناصر واجهة الرسم البياني الفرعي"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "تبديل ترقية عنصر الواجهة المحوم فوقه"
},
"Comfy_ToggleAssetAPI": {
"label": "تجريبي: تمكين AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "تجريبي: تمكين عقد Vue"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "تبديل الشريط الجانبي للأصول",
"tooltip": "الأصول"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
"Previous Opened Workflow": "سير العمل السابق المفتوح",
"Publish": "نشر",
"Queue Panel": "لوحة الانتظار",
"Queue Prompt": "قائمة انتظار التعليمات",
"Queue Prompt (Front)": "قائمة انتظار التعليمات (أمامي)",
"Queue Selected Output Nodes": "قائمة انتظار عقد المخرجات المحددة",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "فتح سير العمل من نظام الملفات المحلي",
"queue": "قائمة الانتظار",
"queueTab": {
"backToAllTasks": "العودة إلى جميع المهام",
"clearPendingTasks": "مسح المهام المعلقة",
"containImagePreview": "ملء معاينة الصورة",
"coverImagePreview": "تكييف معاينة الصورة",
"filter": "تصفية النتائج",
"filters": {
"hideCached": "إخفاء المخزنة مؤقتًا",
"hideCanceled": "إخفاء الملغاة"
},
"showFlatList": "عرض القائمة المسطحة"
},
"templates": "القوالب",
"themeToggle": "تبديل المظهر",
"workflowTab": {

View File

@@ -221,6 +221,9 @@
"Comfy_ToggleHelpCenter": {
"label": "Help Center"
},
"Comfy_ToggleLinear": {
"label": "toggle linear mode"
},
"Comfy_ToggleTheme": {
"label": "Toggle Theme (Dark/Light)"
},
@@ -281,10 +284,6 @@
"label": "Toggle Node Library Sidebar",
"tooltip": "Node Library"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Toggle Queue Sidebar",
"tooltip": "Queue"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Toggle Workflows Sidebar",
"tooltip": "Workflows"

View File

@@ -403,6 +403,7 @@
"Copy Image": "Copy Image",
"Save Image": "Save Image",
"Rename": "Rename",
"RenameWidget": "Rename Widget",
"Copy": "Copy",
"Duplicate": "Duplicate",
"Paste": "Paste",
@@ -505,6 +506,8 @@
"cannotWrite": "Unable to write to the selected path",
"insufficientFreeSpace": "Insufficient space - minimum free space",
"isOneDrive": "OneDrive is not supported. Please install ComfyUI in another location.",
"insideAppInstallDir": "This folder is inside the ComfyUI Desktop application bundle and will be deleted during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
"insideUpdaterCache": "This folder is inside the ComfyUI updater cache, which is cleared on every update. Select a different location for your data.",
"nonDefaultDrive": "Please install ComfyUI on your system drive (eg. C:\\). Drives with different file systems may cause unpredicable issues. Models and other files can be stored on other drives after installation.",
"parentMissing": "Path does not exist - create the containing directory first",
"unhandledError": "Unknown error",
@@ -680,18 +683,6 @@
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
"queueTab": {
"showFlatList": "Show Flat List",
"backToAllTasks": "Back to All Tasks",
"containImagePreview": "Fill Image Preview",
"coverImagePreview": "Fit Image Preview",
"clearPendingTasks": "Clear Pending Tasks",
"filter": "Filter Outputs",
"filters": {
"hideCached": "Hide Cached",
"hideCanceled": "Hide Canceled"
}
},
"queueProgressOverlay": {
"title": "Queue Progress",
"total": "Total: {percent}",
@@ -1112,6 +1103,7 @@
"Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI",
"Canvas Performance": "Canvas Performance",
"Help Center": "Help Center",
"toggle linear mode": "toggle linear mode",
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
"Undo": "Undo",
"Open Sign In Dialog": "Open Sign In Dialog",
@@ -1131,7 +1123,6 @@
"Assets": "Assets",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Queue Panel": "Queue Panel",
"Workflows": "Workflows"
},
"desktopMenu": {
@@ -1196,7 +1187,8 @@
"Vue Nodes": "Vue Nodes",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"VueNodes": "Vue Nodes"
"VueNodes": "Vue Nodes",
"Nodes 2_0": "Nodes 2.0"
},
"serverConfigItems": {
"listen": {
@@ -1413,6 +1405,7 @@
"stable_cascade": "stable_cascade",
"3d_models": "3d_models",
"style_model": "style_model",
"Topaz": "Topaz",
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
@@ -1445,6 +1438,7 @@
"INT": "INT",
"LATENT": "LATENT",
"LATENT_OPERATION": "LATENT_OPERATION",
"LATENT_UPSCALE_MODEL": "LATENT_UPSCALE_MODEL",
"LOAD_3D": "LOAD_3D",
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
"LOAD3D_CAMERA": "LOAD3D_CAMERA",
@@ -1498,6 +1492,14 @@
"taskFailed": "Task failed to run.",
"cannotContinue": "Unable to continue - errors remain",
"defaultDescription": "An error occurred while running a maintenance task."
},
"unsafeMigration": {
"title": "Unsafe install location detected",
"generic": "Your current ComfyUI base path is in a location that may be deleted or modified during updates. To avoid data loss, move it to a safe folder.",
"appInstallDir": "Your base path is inside the ComfyUI Desktop application bundle. This folder may be deleted or overwritten during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
"updaterCache": "Your base path is inside the ComfyUI updater cache, which is cleared on each update. Choose a different location for your data.",
"oneDrive": "Your base path is on OneDrive, which can cause sync issues and accidental data loss. Choose a local folder that is not managed by OneDrive.",
"action": "Use the \"Base path\" maintenance task below to move ComfyUI to a safe location."
}
},
"missingModelsDialog": {
@@ -2076,7 +2078,36 @@
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Upload model",
"uploadModel": "Import model",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
"uploadModelDescription3": "Max file size: 1 GB",
"civitaiLinkLabel": "Civitai model download link",
"civitaiLinkPlaceholder": "Paste link here",
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
"confirmModelDetails": "Confirm Model Details",
"fileName": "File Name",
"fileSize": "File Size",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
"tags": "Tags",
"tagsPlaceholder": "e.g., models, checkpoint",
"tagsHelp": "Separate tags with commas",
"upload": "Import",
"uploadingModel": "Importing model...",
"uploadSuccess": "Model imported successfully!",
"uploadFailed": "Import failed",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelTypeSelectorLabel": "What type of model is this?",
"modelTypeSelectorPlaceholder": "Select model type",
"selectModelType": "Select model type",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"modelUploaded": "Model imported! 🎉",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"allModels": "All Models",
"allCategory": "All {category}",
"unknown": "Unknown",
@@ -2088,6 +2119,13 @@
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorModelTypeNotSupported": "This model type is not supported",
"errorUnknown": "An unexpected error occurred",
"errorUploadFailed": "Failed to import asset. Please try again.",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
@@ -2162,6 +2200,10 @@
"vueNodesMigrationMainMenu": {
"message": "Switch back to Nodes 2.0 anytime from the main menu."
},
"linearMode": {
"share": "Share",
"openWorkflow": "Open Workflow"
},
"missingNodes": {
"cloud": {
"title": "These nodes aren't available on Comfy Cloud yet",

View File

@@ -2001,7 +2001,29 @@
}
},
"EmptyHunyuanLatentVideo": {
"display_name": "EmptyHunyuanLatentVideo",
"display_name": "Empty HunyuanVideo 1.0 Latent",
"inputs": {
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length"
},
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptyHunyuanVideo15Latent": {
"display_name": "Empty HunyuanVideo 1.5 Latent",
"inputs": {
"width": {
"name": "width"
@@ -2061,6 +2083,11 @@
"name": "batch_size",
"tooltip": "The number of latent images in the batch."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptyLatentImage": {
@@ -2569,6 +2596,54 @@
}
}
},
"GeminiImage2Node": {
"display_name": "Nano Banana Pro (Google Gemini Image)",
"description": "Generate or edit images synchronously via Google Vertex API.",
"inputs": {
"prompt": {
"name": "prompt",
"tooltip": "Text prompt describing the image to generate or the edits to apply. Include any constraints, styles, or details the model should follow."
},
"model": {
"name": "model"
},
"seed": {
"name": "seed",
"tooltip": "When the seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used."
},
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "If set to 'auto', matches your input image's aspect ratio; if no image is provided, generates a 1:1 square."
},
"resolution": {
"name": "resolution",
"tooltip": "Target output resolution. For 2K/4K the native Gemini upscaler is used."
},
"response_modalities": {
"name": "response_modalities",
"tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response."
},
"images": {
"name": "images",
"tooltip": "Optional reference image(s). To include multiple images, use the Batch Images node (up to 14)."
},
"files": {
"name": "files",
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"GeminiImageNode": {
"display_name": "Nano Banana (Google Gemini Image)",
"description": "Edit images synchronously via Google API.",
@@ -2597,6 +2672,10 @@
"name": "aspect_ratio",
"tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares."
},
"response_modalities": {
"name": "response_modalities",
"tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response."
},
"control_after_generate": {
"name": "control after generate"
}
@@ -2794,10 +2873,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
}
}
},
@@ -2819,10 +2900,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
}
}
},
@@ -2896,6 +2979,120 @@
}
}
},
"HunyuanVideo15ImageToVideo": {
"display_name": "HunyuanVideo15ImageToVideo",
"inputs": {
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"vae": {
"name": "vae"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length"
},
"batch_size": {
"name": "batch_size"
},
"start_image": {
"name": "start_image"
},
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": null
}
}
},
"HunyuanVideo15LatentUpscaleWithModel": {
"display_name": "Hunyuan Video 15 Latent Upscale With Model",
"inputs": {
"model": {
"name": "model"
},
"samples": {
"name": "samples"
},
"upscale_method": {
"name": "upscale_method"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"crop": {
"name": "crop"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"HunyuanVideo15SuperResolution": {
"display_name": "HunyuanVideo15SuperResolution",
"inputs": {
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"latent": {
"name": "latent"
},
"noise_augmentation": {
"name": "noise_augmentation"
},
"vae": {
"name": "vae"
},
"start_image": {
"name": "start_image"
},
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": null
}
}
},
"HypernetworkLoader": {
"display_name": "HypernetworkLoader",
"inputs": {
@@ -4557,6 +4754,19 @@
}
}
},
"LatentUpscaleModelLoader": {
"display_name": "Load Latent Upscale Model",
"inputs": {
"model_name": {
"name": "model_name"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LazyCache": {
"display_name": "LazyCache",
"description": "A homebrew version of EasyCache - even 'easier' version of EasyCache to implement. Overall works worse than EasyCache, but better in some rare cases AND universal compatibility with everything in ComfyUI.",
@@ -8841,7 +9051,7 @@
}
},
"PreviewAny": {
"display_name": "Preview Any",
"display_name": "Preview as Text",
"inputs": {
"source": {
"name": "source"
@@ -11548,6 +11758,118 @@
}
}
},
"TopazImageEnhance": {
"display_name": "Topaz Image Enhance",
"description": "Industry-standard upscaling and image enhancement.",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image"
},
"prompt": {
"name": "prompt",
"tooltip": "Optional text prompt for creative upscaling guidance."
},
"subject_detection": {
"name": "subject_detection"
},
"face_enhancement": {
"name": "face_enhancement",
"tooltip": "Enhance faces (if present) during processing."
},
"face_enhancement_creativity": {
"name": "face_enhancement_creativity",
"tooltip": "Set the creativity level for face enhancement."
},
"face_enhancement_strength": {
"name": "face_enhancement_strength",
"tooltip": "Controls how sharp enhanced faces are relative to the background."
},
"crop_to_fill": {
"name": "crop_to_fill",
"tooltip": "By default, the image is letterboxed when the output aspect ratio differs. Enable to crop the image to fill the output dimensions."
},
"output_width": {
"name": "output_width",
"tooltip": "Zero value means to calculate automatically (usually it will be original size or output_height if specified)."
},
"output_height": {
"name": "output_height",
"tooltip": "Zero value means to output in the same height as original or output width."
},
"creativity": {
"name": "creativity"
},
"face_preservation": {
"name": "face_preservation",
"tooltip": "Preserve subjects' facial identity."
},
"color_preservation": {
"name": "color_preservation",
"tooltip": "Preserve the original colors."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TopazVideoEnhance": {
"display_name": "Topaz Video Enhance",
"description": "Breathe new life into video with powerful upscaling and recovery technology.",
"inputs": {
"video": {
"name": "video"
},
"upscaler_enabled": {
"name": "upscaler_enabled"
},
"upscaler_model": {
"name": "upscaler_model"
},
"upscaler_resolution": {
"name": "upscaler_resolution"
},
"upscaler_creativity": {
"name": "upscaler_creativity",
"tooltip": "Creativity level (applies only to Starlight (Astra) Creative)."
},
"interpolation_enabled": {
"name": "interpolation_enabled"
},
"interpolation_model": {
"name": "interpolation_model"
},
"interpolation_slowmo": {
"name": "interpolation_slowmo",
"tooltip": "Slow-motion factor applied to the input video. For example, 2 makes the output twice as slow and doubles the duration."
},
"interpolation_frame_rate": {
"name": "interpolation_frame_rate",
"tooltip": "Output frame rate."
},
"interpolation_duplicate": {
"name": "interpolation_duplicate",
"tooltip": "Analyze the input for duplicate frames and remove them."
},
"interpolation_duplicate_threshold": {
"name": "interpolation_duplicate_threshold",
"tooltip": "Detection sensitivity for duplicate frames."
},
"dynamic_compression_level": {
"name": "dynamic_compression_level",
"tooltip": "CQP level."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TorchCompileModel": {
"display_name": "TorchCompileModel",
"inputs": {
@@ -12162,6 +12484,11 @@
"octree_resolution": {
"name": "octree_resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeTiled": {
@@ -12586,6 +12913,11 @@
"threshold": {
"name": "threshold"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VoxelToMeshBasic": {
@@ -12597,6 +12929,11 @@
"threshold": {
"name": "threshold"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VPScheduler": {

View File

@@ -335,11 +335,11 @@
"name": "Validate workflows"
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "Auto-scale layout (Vue nodes)",
"name": "Auto-scale layout (Nodes 2.0)",
"tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
},
"Comfy_VueNodes_Enabled": {
"name": "Modern Node Design (Vue Nodes)",
"name": "Modern Node Design (Nodes 2.0)",
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
},
"Comfy_WidgetControlMode": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Buscar actualizaciones"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Abrir carpeta de nodos personalizados"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Abrir carpeta de entradas"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Abrir carpeta de registros"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Abrir extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Abrir carpeta de modelos"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Abrir carpeta de salidas"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Abrir herramientas de desarrollo"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Guía de usuario de escritorio"
},
"Comfy-Desktop_Quit": {
"label": "Salir"
},
"Comfy-Desktop_Reinstall": {
"label": "Reinstalar"
},
"Comfy-Desktop_Restart": {
"label": "Reiniciar"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Abrir visor 3D (Beta) para el nodo seleccionado"
},
"Comfy_BrowseModelAssets": {
"label": "Experimental: Explorar recursos de modelos"
},
"Comfy_BrowseTemplates": {
"label": "Explorar plantillas"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Editar widgets de subgráficos"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Salir de subgrafo"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Agrupar nodos seleccionados"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Alternar promoción del widget sobre el que se pasa el cursor"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Desempaquetar el subgrafo seleccionado"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Mostrar Diálogo de Configuraciones"
},
"Comfy_ToggleAssetAPI": {
"label": "Experimental: Habilitar AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "Rendimiento del lienzo"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "Cerrar sesión"
},
"Experimental_ToggleVueNodes": {
"label": "Experimental: Habilitar nodos Vue"
},
"Workspace_CloseWorkflow": {
"label": "Cerrar Flujo de Trabajo Actual"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Alternar barra lateral de recursos",
"tooltip": "Recursos"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Alternar Barra Lateral de Biblioteca de Modelos",
"tooltip": "Biblioteca de Modelos"
@@ -298,31 +281,8 @@
"label": "Alternar Barra Lateral de Biblioteca de Nodos",
"tooltip": "Biblioteca de Nodos"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Alternar Barra Lateral de Cola",
"tooltip": "Cola"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Alternar Barra Lateral de Flujos de Trabajo",
"tooltip": "Flujos de Trabajo"
},
"Comfy_BrowseModelAssets": {
"label": "Experimental: Explorar recursos de modelos"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Editar widgets de subgráficos"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Alternar promoción del widget sobre el que se pasa el cursor"
},
"Comfy_ToggleAssetAPI": {
"label": "Experimental: Habilitar AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "Experimental: Habilitar nodos Vue"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Alternar barra lateral de recursos",
"tooltip": "Recursos"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Anclar/Desanclar nodos seleccionados",
"Previous Opened Workflow": "Flujo de trabajo abierto anterior",
"Publish": "Publicar",
"Queue Panel": "Panel de Cola",
"Queue Prompt": "Indicador de cola",
"Queue Prompt (Front)": "Indicador de cola (Frente)",
"Queue Selected Output Nodes": "Encolar nodos de salida seleccionados",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"queue": "Cola",
"queueTab": {
"backToAllTasks": "Volver a todas las tareas",
"clearPendingTasks": "Borrar tareas pendientes",
"containImagePreview": "Llenar vista previa de la imagen",
"coverImagePreview": "Ajustar vista previa de la imagen",
"filter": "Filtrar salidas",
"filters": {
"hideCached": "Ocultar en caché",
"hideCanceled": "Ocultar cancelados"
},
"showFlatList": "Mostrar lista plana"
},
"templates": "Plantillas",
"themeToggle": "Cambiar tema",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Vérifier les mises à jour"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Ouvrir le dossier des nœuds personnalisés"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Ouvrir le dossier des entrées"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Ouvrir le dossier des journaux"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Ouvrir extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Ouvrir le dossier des modèles"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Ouvrir le dossier des sorties"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Ouvrir les outils de développement"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Guide de l'utilisateur du bureau"
},
"Comfy-Desktop_Quit": {
"label": "Quitter"
},
"Comfy-Desktop_Reinstall": {
"label": "Réinstaller"
},
"Comfy-Desktop_Restart": {
"label": "Redémarrer"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Ouvrir le visualiseur 3D (bêta) pour le nœud sélectionné"
},
"Comfy_BrowseModelAssets": {
"label": "Expérimental : Parcourir les ressources de modèles"
},
"Comfy_BrowseTemplates": {
"label": "Parcourir les modèles"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Modifier les widgets de sous-graphe"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Quitter le sous-graphe"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Grouper les nœuds sélectionnés"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Activer/désactiver la promotion du widget survolé"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Décompresser le sous-graphe sélectionné"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Afficher la boîte de dialogue des paramètres"
},
"Comfy_ToggleAssetAPI": {
"label": "Expérimental : Activer AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "Performance du canvas"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "Se déconnecter"
},
"Experimental_ToggleVueNodes": {
"label": "Expérimental : Activer les nœuds Vue"
},
"Workspace_CloseWorkflow": {
"label": "Fermer le flux de travail actuel"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Basculer le mode focus"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Afficher/Masquer la barre latérale des ressources",
"tooltip": "Ressources"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Basculer la barre latérale de la bibliothèque de modèles",
"tooltip": "Bibliothèque de modèles"
@@ -298,31 +281,8 @@
"label": "Basculer la barre latérale de la bibliothèque de nœuds",
"tooltip": "Bibliothèque de nœuds"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Basculer la barre latérale de la file d'attente",
"tooltip": "File d'attente"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Basculer la barre latérale des flux de travail",
"tooltip": "Flux de travail"
},
"Comfy_BrowseModelAssets": {
"label": "Expérimental : Parcourir les ressources de modèles"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Modifier les widgets de sous-graphe"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Activer/désactiver la promotion du widget survolé"
},
"Comfy_ToggleAssetAPI": {
"label": "Expérimental : Activer AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "Expérimental : Activer les nœuds Vue"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Afficher/Masquer la barre latérale des ressources",
"tooltip": "Ressources"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Épingler/Désépingler les nœuds sélectionnés",
"Previous Opened Workflow": "Flux de travail ouvert précédent",
"Publish": "Publier",
"Queue Panel": "Panneau de file d'attente",
"Queue Prompt": "Invite de file d'attente",
"Queue Prompt (Front)": "Invite de file d'attente (Front)",
"Queue Selected Output Nodes": "Mettre en file dattente les nœuds de sortie sélectionnés",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
"queue": "File d'attente",
"queueTab": {
"backToAllTasks": "Retour à toutes les tâches",
"clearPendingTasks": "Effacer les tâches en attente",
"containImagePreview": "Remplir l'aperçu de l'image",
"coverImagePreview": "Adapter l'aperçu de l'image",
"filter": "Filtrer les sorties",
"filters": {
"hideCached": "Masquer le cache",
"hideCanceled": "Masquer les annulations"
},
"showFlatList": "Afficher la liste plate"
},
"templates": "Modèles",
"themeToggle": "Basculer le thème",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "更新を確認する"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "カスタムノードフォルダを開く"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "入力フォルダを開く"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "ログフォルダを開く"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "extra_model_paths.yamlを開く"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "モデルフォルダを開く"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "出力フォルダを開く"
},
"Comfy-Desktop_OpenDevTools": {
"label": "DevToolsを開く"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "デスクトップユーザーガイド"
},
"Comfy-Desktop_Quit": {
"label": "終了"
},
"Comfy-Desktop_Reinstall": {
"label": "再インストール"
},
"Comfy-Desktop_Restart": {
"label": "再起動"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "選択したードの3Dビューアーベータを開く"
},
"Comfy_BrowseModelAssets": {
"label": "実験的: モデルアセットを参照"
},
"Comfy_BrowseTemplates": {
"label": "テンプレートを参照"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "サブグラフウィジェットを編集"
},
"Comfy_Graph_ExitSubgraph": {
"label": "サブグラフを終了"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "選択したノードをグループ化"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "ホバー中のウィジェットの優先表示を切り替え"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "選択したサブグラフを展開"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "設定ダイアログを表示"
},
"Comfy_ToggleAssetAPI": {
"label": "実験的: AssetAPIを有効化"
},
"Comfy_ToggleCanvasInfo": {
"label": "キャンバスパフォーマンス"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "サインアウト"
},
"Experimental_ToggleVueNodes": {
"label": "実験的: Vueードを有効化"
},
"Workspace_CloseWorkflow": {
"label": "現在のワークフローを閉じる"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "フォーカスモードの切り替え"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "アセットサイドバーの表示切り替え",
"tooltip": "アセット"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "モデルライブラリサイドバーの切り替え",
"tooltip": "モデルライブラリ"
@@ -298,31 +281,8 @@
"label": "ノードライブラリサイドバーの切り替え",
"tooltip": "ノードライブラリ"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "キューサイドバーの切り替え",
"tooltip": "キュー"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "ワークフローサイドバーの切り替え",
"tooltip": "ワークフロー"
},
"Comfy_BrowseModelAssets": {
"label": "実験的: モデルアセットを参照"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "サブグラフウィジェットを編集"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "ホバー中のウィジェットの優先表示を切り替え"
},
"Comfy_ToggleAssetAPI": {
"label": "実験的: AssetAPIを有効化"
},
"Experimental_ToggleVueNodes": {
"label": "実験的: Vueードを有効化"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "アセットサイドバーの表示切り替え",
"tooltip": "アセット"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "選択したノードのピン留め/ピン留め解除",
"Previous Opened Workflow": "前に開いたワークフロー",
"Publish": "公開",
"Queue Panel": "キューパネル",
"Queue Prompt": "キューのプロンプト",
"Queue Prompt (Front)": "キューのプロンプト (前面)",
"Queue Selected Output Nodes": "選択した出力ノードをキューに追加",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "ローカルでワークフローを開く",
"queue": "キュー",
"queueTab": {
"backToAllTasks": "すべてのタスクに戻る",
"clearPendingTasks": "保留中のタスクをクリア",
"containImagePreview": "画像プレビューを含める",
"coverImagePreview": "画像プレビューに合わせる",
"filter": "出力をフィルタ",
"filters": {
"hideCached": "キャッシュを非表示",
"hideCanceled": "キャンセル済みを非表示"
},
"showFlatList": "フラットリストを表示"
},
"templates": "テンプレート",
"themeToggle": "テーマを切り替え",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "업데이트 확인"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "커스텀 노드 폴더 열기"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "입력 폴더 열기"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "로그 폴더 열기"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "extra_model_paths.yaml 열기"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "모델 폴더 열기"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "출력 폴더 열기"
},
"Comfy-Desktop_OpenDevTools": {
"label": "DevTools 열기"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "데스크톱 사용자 가이드"
},
"Comfy-Desktop_Quit": {
"label": "종료"
},
"Comfy-Desktop_Reinstall": {
"label": "재설치"
},
"Comfy-Desktop_Restart": {
"label": "재시작"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "선택한 노드에 대해 3D 뷰어(베타) 열기"
},
"Comfy_BrowseModelAssets": {
"label": "실험적: 모델 에셋 탐색"
},
"Comfy_BrowseTemplates": {
"label": "템플릿 탐색"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "서브그래프 위젯 편집"
},
"Comfy_Graph_ExitSubgraph": {
"label": "서브그래프 나가기"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "선택한 노드 그룹화"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "호버링된 위젯 프로모션 전환"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "선택한 서브그래프 묶음 풀기"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "설정 대화상자 보기"
},
"Comfy_ToggleAssetAPI": {
"label": "실험적: AssetAPI 활성화"
},
"Comfy_ToggleCanvasInfo": {
"label": "캔버스 성능"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "로그아웃"
},
"Experimental_ToggleVueNodes": {
"label": "실험적: Vue 노드 활성화"
},
"Workspace_CloseWorkflow": {
"label": "현재 워크플로 닫기"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "포커스 모드 토글"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "에셋 사이드바 전환",
"tooltip": "에셋"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "모델 라이브러리 사이드바 토글",
"tooltip": "모델 라이브러리"
@@ -298,31 +281,8 @@
"label": "노드 라이브러리 사이드바 토글",
"tooltip": "노드 라이브러리"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "실행 큐 사이드바 토글",
"tooltip": "실행 큐"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "워크플로 사이드바 토글",
"tooltip": "워크플로"
},
"Comfy_BrowseModelAssets": {
"label": "실험적: 모델 에셋 탐색"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "서브그래프 위젯 편집"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "호버링된 위젯 프로모션 전환"
},
"Comfy_ToggleAssetAPI": {
"label": "실험적: AssetAPI 활성화"
},
"Experimental_ToggleVueNodes": {
"label": "실험적: Vue 노드 활성화"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "에셋 사이드바 전환",
"tooltip": "에셋"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "선택한 노드 고정/고정 해제",
"Previous Opened Workflow": "이전 열린 워크플로",
"Publish": "게시",
"Queue Panel": "큐 패널",
"Queue Prompt": "실행 대기열에 프롬프트 추가",
"Queue Prompt (Front)": "실행 대기열 맨 앞에 프롬프트 추가",
"Queue Selected Output Nodes": "선택한 출력 노드 대기열에 추가",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 대기열",
"queueTab": {
"backToAllTasks": "모든 작업으로 돌아가기",
"clearPendingTasks": "보류 중인 작업 지우기",
"containImagePreview": "이미지 미리보기 채우기",
"coverImagePreview": "이미지 미리보기 맞추기",
"filter": "출력 필터",
"filters": {
"hideCached": "캐시 숨기기",
"hideCanceled": "취소된 작업 숨기기"
},
"showFlatList": "평면 목록 표시"
},
"templates": "템플릿",
"themeToggle": "테마 전환",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Проверить наличие обновлений"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Открыть папку пользовательских нод"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Открыть папку входных данных"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Открыть папку логов"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Открыть extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Открыть папку моделей"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Открыть папку результатов"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Открыть инструменты разработчика"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Руководство пользователя для рабочего стола"
},
"Comfy-Desktop_Quit": {
"label": "Выйти"
},
"Comfy-Desktop_Reinstall": {
"label": "Переустановить"
},
"Comfy-Desktop_Restart": {
"label": "Перезагрузить"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Открыть 3D-просмотрщик (бета) для выбранного узла"
},
"Comfy_BrowseModelAssets": {
"label": "Экспериментально: Просмотр ресурсов моделей"
},
"Comfy_BrowseTemplates": {
"label": "Просмотр шаблонов"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Редактировать виджеты подграфов"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Выйти из подграфа"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Группировать выбранные ноды"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Переключить продвижение наведенного виджета"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Распаковать выбранный подграф"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Показать диалог настроек"
},
"Comfy_ToggleAssetAPI": {
"label": "Экспериментально: Включить AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "Производительность холста"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "Выйти"
},
"Experimental_ToggleVueNodes": {
"label": "Экспериментально: Включить Vue узлы"
},
"Workspace_CloseWorkflow": {
"label": "Закрыть текущий рабочий процесс"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Переключить режим фокуса"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Переключить боковую панель ресурсов",
"tooltip": "Ресурсы"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Переключить боковую панель библиотеки моделей",
"tooltip": "Библиотека моделей"
@@ -298,31 +281,8 @@
"label": "Переключить боковую панель библиотеки нод",
"tooltip": "Библиотека нод"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Переключить боковую панель очереди",
"tooltip": "Очередь"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Переключить боковую панель рабочих процессов",
"tooltip": "Рабочие процессы"
},
"Comfy_BrowseModelAssets": {
"label": "Экспериментально: Просмотр ресурсов моделей"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Редактировать виджеты подграфов"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Переключить продвижение наведенного виджета"
},
"Comfy_ToggleAssetAPI": {
"label": "Экспериментально: Включить AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "Экспериментально: Включить Vue узлы"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Переключить боковую панель ресурсов",
"tooltip": "Ресурсы"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Закрепить/открепить выбранные ноды",
"Previous Opened Workflow": "Предыдущий открытый рабочий процесс",
"Publish": "Опубликовать",
"Queue Panel": "Панель очереди",
"Queue Prompt": "Запрос в очереди",
"Queue Prompt (Front)": "Запрос в очереди (спереди)",
"Queue Selected Output Nodes": "Добавить выбранные выходные узлы в очередь",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
"queue": "Очередь",
"queueTab": {
"backToAllTasks": "Вернуться ко всем задачам",
"clearPendingTasks": "Очистить отложенные задачи",
"containImagePreview": "Предпросмотр заливающего изображения",
"coverImagePreview": "Предпросмотр подходящего изображения",
"filter": "Фильтровать выводы",
"filters": {
"hideCached": "Скрыть кэшированные",
"hideCanceled": "Скрыть отмененные"
},
"showFlatList": "Показать плоский список"
},
"templates": "Шаблоны",
"themeToggle": "Переключить тему",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Güncellemeleri Kontrol Et"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Özel Düğümler Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Girişler Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Kayıtlar Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "extra_model_paths.yaml dosyasını aç"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Modeller Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": ıktılar Klasörünü Aç"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Geliştirici Araçlarını Aç"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Masaüstü Kullanıcı Kılavuzu"
},
"Comfy-Desktop_Quit": {
"label": ık"
},
"Comfy-Desktop_Reinstall": {
"label": "Yeniden Yükle"
},
"Comfy-Desktop_Restart": {
"label": "Yeniden Başlat"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Seçili Düğüm için 3D Görüntüleyiciyi (Beta) Aç"
},
"Comfy_BrowseModelAssets": {
"label": "Deneysel: Model Varlıklarını Gözat"
},
"Comfy_BrowseTemplates": {
"label": "Şablonlara Gözat"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Seçimi Alt Grafiğe Dönüştür"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Alt Grafik Bileşenlerini Düzenle"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Alt Grafikten Çık"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Seçili Düğümleri Gruplandır"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Vurgulanan bileşenin önceliğini değiştir"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Seçili Alt Grafiği Aç"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Ayarlar İletişim Kutusunu Göster"
},
"Comfy_ToggleAssetAPI": {
"label": "Deneysel: AssetAPI'yi Etkinleştir"
},
"Comfy_ToggleCanvasInfo": {
"label": "Tuval Performansı"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": ıkış Yap"
},
"Experimental_ToggleVueNodes": {
"label": "Deneysel: Vue Düğümlerini Etkinleştir"
},
"Workspace_CloseWorkflow": {
"label": "Mevcut İş Akışını Kapat"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Odak Modunu Aç/Kapat"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Varlıklar Kenar Çubuğunu Aç/Kapat",
"tooltip": "Varlıklar"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Model Kütüphanesi Kenar Çubuğunu Aç/Kapat",
"tooltip": "Model Kütüphanesi"
@@ -298,31 +281,8 @@
"label": "Düğüm Kütüphanesi Kenar Çubuğunu Aç/Kapat",
"tooltip": "Düğüm Kütüphanesi"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Kuyruk Kenar Çubuğunu Aç/Kapat",
"tooltip": "Kuyruk"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "İş Akışları Kenar Çubuğunu Aç/Kapat",
"tooltip": "İş Akışları"
},
"Comfy_BrowseModelAssets": {
"label": "Deneysel: Model Varlıklarını Gözat"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Alt Grafik Bileşenlerini Düzenle"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Vurgulanan bileşenin önceliğini değiştir"
},
"Comfy_ToggleAssetAPI": {
"label": "Deneysel: AssetAPI'yi Etkinleştir"
},
"Experimental_ToggleVueNodes": {
"label": "Deneysel: Vue Düğümlerini Etkinleştir"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Varlıklar Kenar Çubuğunu Aç/Kapat",
"tooltip": "Varlıklar"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Seçili Düğümleri Sabitle/Kaldır",
"Previous Opened Workflow": "Önceki Açılan İş Akışı",
"Publish": "Yayınla",
"Queue Panel": "Kuyruk Paneli",
"Queue Prompt": "İstemi Kuyruğa Al",
"Queue Prompt (Front)": "İstemi Kuyruğa Al (Ön)",
"Queue Selected Output Nodes": "Seçili Çıktı Düğümlerini Kuyruğa Al",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Yerel dosya sisteminde iş akışını aç",
"queue": "Kuyruk",
"queueTab": {
"backToAllTasks": "Tüm Görevlere Geri Dön",
"clearPendingTasks": "Bekleyen Görevleri Temizle",
"containImagePreview": "Resim Önizlemesini Doldur",
"coverImagePreview": "Resim Önizlemesine Sığdır",
"filter": ıktıları Filtrele",
"filters": {
"hideCached": "Önbelleğe Alınanları Gizle",
"hideCanceled": "İptal Edilenleri Gizle"
},
"showFlatList": "Düz Listeyi Göster"
},
"templates": "Şablonlar",
"themeToggle": "Temayı Değiştir",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "檢查更新"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "開啟自訂節點資料夾"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "開啟輸入資料夾"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "開啟日誌資料夾"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "開啟 extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "開啟模型資料夾"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "開啟輸出資料夾"
},
"Comfy-Desktop_OpenDevTools": {
"label": "開啟開發者工具"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "桌面版使用指南"
},
"Comfy-Desktop_Quit": {
"label": "退出"
},
"Comfy-Desktop_Reinstall": {
"label": "重新安裝"
},
"Comfy-Desktop_Restart": {
"label": "重新啟動"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "為選取的節點開啟 3D 檢視器Beta"
},
"Comfy_BrowseModelAssets": {
"label": "實驗性:瀏覽模型資源"
},
"Comfy_BrowseTemplates": {
"label": "瀏覽範本"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "將選取內容轉換為子圖"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "編輯子圖表小工具"
},
"Comfy_Graph_ExitSubgraph": {
"label": "離開子圖"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "群組所選節點"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切換懸停小工具的提升"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "解開所選子圖"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "顯示設定對話框"
},
"Comfy_ToggleAssetAPI": {
"label": "實驗性:啟用 AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "畫布效能"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "登出"
},
"Experimental_ToggleVueNodes": {
"label": "實驗性:啟用 Vue 節點"
},
"Workspace_CloseWorkflow": {
"label": "關閉當前工作流程"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "切換專注模式"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切換資源側邊欄",
"tooltip": "資源"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "切換模型庫側邊欄",
"tooltip": "模型庫"
@@ -298,31 +281,8 @@
"label": "切換節點庫側邊欄",
"tooltip": "節點庫"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "切換佇列側邊欄",
"tooltip": "佇列"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "切換工作流程側邊欄",
"tooltip": "工作流程"
},
"Comfy_BrowseModelAssets": {
"label": "實驗性:瀏覽模型資源"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "編輯子圖表小工具"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切換懸停小工具的提升"
},
"Comfy_ToggleAssetAPI": {
"label": "實驗性:啟用 AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "實驗性:啟用 Vue 節點"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切換資源側邊欄",
"tooltip": "資源"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "釘選/取消釘選選取節點",
"Previous Opened Workflow": "上一個已開啟的工作流程",
"Publish": "發佈",
"Queue Panel": "佇列面板",
"Queue Prompt": "加入提示至佇列",
"Queue Prompt (Front)": "將提示加入佇列前端",
"Queue Selected Output Nodes": "將選取的輸出節點加入佇列",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "在本機檔案系統中開啟工作流程",
"queue": "佇列",
"queueTab": {
"backToAllTasks": "返回所有任務",
"clearPendingTasks": "清除待處理任務",
"containImagePreview": "填滿圖片預覽",
"coverImagePreview": "適合圖片預覽",
"filter": "篩選輸出",
"filters": {
"hideCached": "隱藏快取",
"hideCanceled": "隱藏已取消"
},
"showFlatList": "顯示平面清單"
},
"templates": "範本",
"themeToggle": "切換主題",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "检查更新"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "打开自定义节点文件夹"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "打开输入文件夹"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "打开日志文件夹"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "打开 extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "打开模型文件夹"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "打开输出文件夹"
},
"Comfy-Desktop_OpenDevTools": {
"label": "打开开发者工具"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "桌面用户指南"
},
"Comfy-Desktop_Quit": {
"label": "退出"
},
"Comfy-Desktop_Reinstall": {
"label": "重新安装"
},
"Comfy-Desktop_Restart": {
"label": "重启"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "为所选节点开启 3D 浏览器Beta 版)"
},
"Comfy_BrowseModelAssets": {
"label": "实验性:浏览模型资源"
},
"Comfy_BrowseTemplates": {
"label": "浏览模板"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "编辑子图组件"
},
"Comfy_Graph_ExitSubgraph": {
"label": "退出子图"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "添加框到选中节点"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切换悬停小部件的推广"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "解开所选子图"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "显示设置对话框"
},
"Comfy_ToggleAssetAPI": {
"label": "实验性:启用 AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "画布性能"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "退出登录"
},
"Experimental_ToggleVueNodes": {
"label": "实验性:启用 Vue 节点"
},
"Workspace_CloseWorkflow": {
"label": "关闭当前工作流"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切换资产侧边栏",
"tooltip": "资产"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "切换模型库侧边栏",
"tooltip": "模型库"
@@ -298,31 +281,8 @@
"label": "切换节点库侧边栏",
"tooltip": "节点库"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "切换执行队列侧边栏",
"tooltip": "执行队列"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "切换工作流侧边栏",
"tooltip": "工作流"
},
"Comfy_BrowseModelAssets": {
"label": "实验性:浏览模型资源"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "编辑子图组件"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切换悬停小部件的推广"
},
"Comfy_ToggleAssetAPI": {
"label": "实验性:启用 AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "实验性:启用 Vue 节点"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切换资产侧边栏",
"tooltip": "资产"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "固定/取消固定选定节点",
"Previous Opened Workflow": "上一个打开的工作流",
"Publish": "发布",
"Queue Panel": "队列面板",
"Queue Prompt": "执行提示词",
"Queue Prompt (Front)": "执行提示词 (优先执行)",
"Queue Selected Output Nodes": "将所选输出节点加入队列",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "在本地文件系统中打开工作流",
"queue": "队列",
"queueTab": {
"backToAllTasks": "返回",
"clearPendingTasks": "清除待处理任务",
"containImagePreview": "填充图像预览",
"coverImagePreview": "适应图像预览",
"filter": "过滤输出",
"filters": {
"hideCached": "隐藏缓存",
"hideCanceled": "隐藏已取消"
},
"showFlatList": "平铺结果"
},
"templates": "模板",
"themeToggle": "切换主题",
"workflowTab": {

View File

@@ -38,7 +38,7 @@
:on-click="handleUploadClick"
>
<template #icon>
<i class="icon-[lucide--upload]" />
<i class="icon-[lucide--package-plus]" />
</template>
</IconTextButton>
</div>
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useDialogStore } from '@/stores/dialogStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -92,6 +95,7 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function handleUploadClick() {
// Will be implemented in the future commit
dialogStore.showDialog({
key: 'upload-model',
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await execute()
}
}
})
}
</script>

View File

@@ -71,9 +71,10 @@
</div>
<div v-tooltip.top="$t('mediaAsset.actions.more')">
<MoreButton
ref="moreButtonRef"
size="sm"
@menu-opened="isMenuOpen = true"
@menu-closed="isMenuOpen = false"
@menu-opened="handleMenuOpened"
@menu-closed="handleMenuClosed"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
@@ -139,7 +140,7 @@
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { useElementHover, whenever } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
@@ -189,7 +190,8 @@ const {
selected,
showOutputCount,
outputCount,
showDeleteButton
showDeleteButton,
openPopoverId
} = defineProps<{
asset?: AssetItem
loading?: boolean
@@ -197,15 +199,19 @@ const {
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
openPopoverId?: string | null
}>()
const emit = defineEmits<{
zoom: [asset: AssetItem]
'output-count-click': []
'asset-deleted': []
'popover-opened': []
'popover-closed': []
}>()
const cardContainerRef = ref<HTMLElement>()
const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
@@ -339,4 +345,22 @@ const handleOutputCountClick = () => {
const handleAssetDelete = () => {
emit('asset-deleted')
}
const handleMenuOpened = () => {
isMenuOpen.value = true
emit('popover-opened')
}
const handleMenuClosed = () => {
isMenuOpen.value = false
emit('popover-closed')
}
// Close this popover when another opens
whenever(
() => openPopoverId && openPopoverId !== asset?.id && isMenuOpen.value,
() => {
moreButtonRef.value?.hide()
}
)
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex flex-col gap-4">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<p class="text-sm mt-0">
{{ metadata?.name || metadata?.filename }}
</p>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted">
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<SingleSelect
v-model="selectedModelType"
:label="
isLoading
? $t('g.loading')
: $t('assetBrowser.modelTypeSelectorPlaceholder')
"
:options="modelTypes"
:disabled="isLoading"
/>
<div class="flex items-center gap-2 text-sm text-muted">
<i class="icon-[lucide--info]" />
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const props = defineProps<{
modelValue: string | undefined
metadata: AssetMetadata | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
}>()
const { modelTypes, isLoading } = useModelTypes()
const selectedModelType = computed({
get: () => props.modelValue ?? null,
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"
v-model="wizardData.url"
:error="uploadError"
/>
<!-- Step 2: Confirm Metadata -->
<UploadModelConfirmation
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
/>
<!-- Step 3: Upload Progress -->
<UploadModelProgress
v-else-if="currentStep === 3"
:status="uploadStatus"
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
/>
<!-- Navigation Footer -->
<UploadModelFooter
:current-step="currentStep"
:is-fetching-metadata="isFetchingMetadata"
:is-uploading="isUploading"
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@close="handleClose"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const emit = defineEmits<{
'upload-success': []
}>()
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
wizardData,
selectedModelType,
canFetchMetadata,
canUploadModel,
fetchMetadata,
uploadModel,
goToPreviousStep
} = useUploadModelWizard(modelTypes)
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const success = await uploadModel()
if (success) {
emit('upload-success')
}
}
function handleClose() {
dialogStore.closeDialog({ key: 'upload-model' })
}
onMounted(() => {
fetchModelTypes()
})
</script>
<style scoped>
.upload-model-dialog {
width: 90vw;
max-width: 800px;
min-height: 400px;
}
@media (min-width: 640px) {
.upload-model-dialog {
width: auto;
min-width: 600px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex items-center gap-3 px-4 py-2 font-bold">
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
>
{{ $t('g.beta') }}
</span>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex justify-end gap-2">
<TextButton
v-if="currentStep !== 1 && currentStep !== 3"
:label="$t('g.back')"
type="secondary"
size="md"
:disabled="isFetchingMetadata || isUploading"
@click="emit('back')"
/>
<span v-else />
<IconTextButton
v-if="currentStep === 1"
:label="$t('g.continue')"
type="primary"
size="md"
:disabled="!canFetchMetadata || isFetchingMetadata"
@click="emit('fetchMetadata')"
>
<template #icon>
<i
v-if="isFetchingMetadata"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<IconTextButton
v-else-if="currentStep === 2"
:label="$t('assetBrowser.upload')"
type="primary"
size="md"
:disabled="!canUploadModel || isUploading"
@click="emit('upload')"
>
<template #icon>
<i
v-if="isUploading"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<TextButton
v-else-if="currentStep === 3 && uploadStatus === 'success'"
:label="$t('assetBrowser.finish')"
type="primary"
size="md"
@click="emit('close')"
/>
</div>
</template>
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
}>()
const emit = defineEmits<{
(e: 'back'): void
(e: 'fetchMetadata'): void
(e: 'upload'): void
(e: 'close'): void
}>()
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-1 flex-col gap-6">
<!-- Uploading State -->
<div
v-if="status === 'uploading'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
/>
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadingModel') }}
</p>
</div>
</div>
<!-- Success State -->
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<p class="text-sm text-muted m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
</p>
</div>
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-sm m-0">
{{ metadata?.name || metadata?.filename }}
</p>
<p class="text-sm text-muted m-0">
{{ modelType }}
</p>
</div>
</div>
</div>
<!-- Error State -->
<div
v-else-if="status === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
<p v-if="error" class="text-sm text-muted mb-0">
{{ error }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata: AssetMetadata | null
modelType: string | undefined
}>()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
</ul>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted mb-0">
{{ $t('assetBrowser.civitaiLinkLabel') }}
</label>
<InputText
v-model="url"
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-xs text-muted">
{{ $t('assetBrowser.civitaiLinkExample') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
const props = defineProps<{
modelValue: string
error?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const url = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,73 @@
import { createSharedComposable, useAsyncState } from '@vueuse/core'
import { api } from '@/scripts/api'
/**
* Format folder name to display name
* Converts "upscale_models" -> "Upscale Models"
* Converts "loras" -> "LoRAs"
*/
function formatDisplayName(folderName: string): string {
// Special cases for acronyms and proper nouns
const specialCases: Record<string, string> = {
loras: 'LoRAs',
ipadapter: 'IP-Adapter',
sams: 'SAMs',
clip_vision: 'CLIP Vision',
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
animatediff_models: 'AnimateDiff Models',
vae: 'VAE',
sam2: 'SAM 2',
controlnet: 'ControlNet',
gligen: 'GLIGEN'
}
if (specialCases[folderName]) {
return specialCases[folderName]
}
return folderName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
interface ModelTypeOption {
name: string // Display name
value: string // Actual tag value
}
/**
* Composable for fetching and managing model types from the API
* Uses shared state to ensure data is only fetched once
*/
export const useModelTypes = createSharedComposable(() => {
const {
state: modelTypes,
isLoading,
error,
execute: fetchModelTypes
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
return response.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name
}))
},
[] as ModelTypeOption[],
{
immediate: false,
onError: (err) => {
console.error('Failed to fetch model types:', err)
}
}
)
return {
modelTypes,
isLoading,
error,
fetchModelTypes
}
})

View File

@@ -0,0 +1,184 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
interface WizardData {
url: string
metadata: AssetMetadata | null
name: string
tags: string[]
}
interface ModelTypeOption {
name: string
value: string
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
const uploadError = ref('')
const wizardData = ref<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
const selectedModelType = ref<string | undefined>(undefined)
// Clear error when URL changes
watch(
() => wizardData.value.url,
() => {
uploadError.value = ''
}
)
// Validation
const canFetchMetadata = computed(() => {
return wizardData.value.url.trim().length > 0
})
const canUploadModel = computed(() => {
return !!selectedModelType.value
})
function isCivitaiUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase()
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
} catch {
return false
}
}
async function fetchMetadata() {
if (!canFetchMetadata.value) return
// Clean and normalize URL
let cleanedUrl = wizardData.value.url.trim()
try {
cleanedUrl = new URL(encodeURI(cleanedUrl)).toString()
} catch {
// If URL parsing fails, just use the trimmed input
}
wizardData.value.url = cleanedUrl
if (!isCivitaiUrl(wizardData.value.url)) {
uploadError.value = st(
'assetBrowser.onlyCivitaiUrlsSupported',
'Only Civitai URLs are supported'
)
return
}
isFetchingMetadata.value = true
try {
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
wizardData.value.metadata = metadata
// Pre-fill name from metadata
wizardData.value.name = metadata.filename || metadata.name || ''
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
// Try to detect model type from tags
const typeTag = metadata.tags.find((tag) =>
modelTypes.value.some((type) => type.value === tag)
)
if (typeTag) {
selectedModelType.value = typeTag
}
}
currentStep.value = 2
} catch (error) {
console.error('Failed to retrieve metadata:', error)
uploadError.value =
error instanceof Error
? error.message
: st(
'assetBrowser.uploadModelFailedToRetrieveMetadata',
'Failed to retrieve metadata. Please check the link and try again.'
)
currentStep.value = 1
} finally {
isFetchingMetadata.value = false
}
}
async function uploadModel() {
if (!canUploadModel.value) return
isUploading.value = true
uploadStatus.value = 'uploading'
try {
const tags = selectedModelType.value
? ['models', selectedModelType.value]
: ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||
'model'
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
tags,
user_metadata: {
source: 'civitai',
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
})
uploadStatus.value = 'success'
currentStep.value = 3
return true
} catch (error) {
console.error('Failed to upload asset:', error)
uploadStatus.value = 'error'
uploadError.value =
error instanceof Error ? error.message : 'Failed to upload model'
currentStep.value = 3
return false
} finally {
isUploading.value = false
}
}
function goToPreviousStep() {
if (currentStep.value > 1) {
currentStep.value = currentStep.value - 1
}
}
return {
// State
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
wizardData,
selectedModelType,
// Computed
canFetchMetadata,
canUploadModel,
// Actions
fetchMetadata,
uploadModel,
goToPreviousStep
}
}

View File

@@ -33,6 +33,29 @@ const zModelFile = z.object({
pathIndex: z.number()
})
const zValidationError = z.object({
code: z.string(),
message: z.string(),
field: z.string()
})
const zValidationResult = z.object({
is_valid: z.boolean(),
errors: z.array(zValidationError).optional(),
warnings: z.array(zValidationError).optional()
})
const zAssetMetadata = z.object({
content_length: z.number(),
final_url: z.string(),
content_type: z.string().optional(),
filename: z.string().optional(),
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
validation: zValidationResult.optional()
})
// Filename validation schema
export const assetFilenameSchema = z
.string()
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>

View File

@@ -1,8 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
AssetResponse,
ModelFile,
ModelFolder
@@ -10,6 +12,36 @@ import type {
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
/**
* Maps CivitAI validation error codes to localized error messages
*/
function getLocalizedErrorMessage(errorCode: string): string {
const errorMessages: Record<string, string> = {
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
FORMAT_NOT_ALLOWED: st(
'assetBrowser.errorFormatNotAllowed',
'Format not allowed'
),
UNSAFE_PICKLE_SCAN: st(
'assetBrowser.errorUnsafePickleScan',
'Unsafe pickle scan'
),
UNSAFE_VIRUS_SCAN: st(
'assetBrowser.errorUnsafeVirusScan',
'Unsafe virus scan'
),
MODEL_TYPE_NOT_SUPPORTED: st(
'assetBrowser.errorModelTypeNotSupported',
'Model type not supported'
)
}
return (
errorMessages[errorCode] ||
st('assetBrowser.errorUnknown', 'Unknown error') ||
'Unknown error'
)
}
const ASSETS_ENDPOINT = '/assets'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
@@ -249,6 +281,77 @@ function createAssetService() {
}
}
/**
* Retrieves metadata from a download URL without downloading the file
*
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
* @returns Promise with metadata including content_length, final_url, filename, etc.
* @throws Error if metadata retrieval fails
*/
async function getAssetMetadata(url: string): Promise<AssetMetadata> {
const encodedUrl = encodeURIComponent(url)
const res = await api.fetchApi(
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
)
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
)
}
const data: AssetMetadata = await res.json()
if (data.validation?.is_valid === false) {
throw new Error(
getLocalizedErrorMessage(
data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR'
)
)
}
return data
}
/**
* Uploads an asset by providing a URL to download from
*
* @param params - Upload parameters
* @param params.url - HTTP/HTTPS URL to download from
* @param params.name - Display name (determines extension)
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @param params.preview_id - Optional UUID for preview asset
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromUrl(params: {
url: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
preview_id?: string
}): Promise<AssetItem & { created_new: boolean }> {
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -256,7 +359,9 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
deleteAsset
deleteAsset,
getAssetMetadata,
uploadAssetFromUrl
}
}

View File

@@ -107,10 +107,17 @@ const {
const authActions = useFirebaseAuthActions()
// Get max sortOrder from settings in a group
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => a.label.localeCompare(b.label))
.sort((a, b) => {
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
})
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group).sort((a, b) => {

View File

@@ -1082,24 +1082,28 @@ export const CORE_SETTINGS: SettingParams[] = [
},
/**
* Vue Node System Settings
* Nodes 2.0 Settings
*/
{
id: 'Comfy.VueNodes.Enabled',
name: 'Modern Node Design (Vue Nodes)',
category: ['Comfy', 'Nodes 2.0', 'VueNodesEnabled'],
name: 'Modern Node Design (Nodes 2.0)',
type: 'boolean',
tooltip:
'Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering.',
defaultValue: false,
sortOrder: 100,
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.VueNodes.AutoScaleLayout',
name: 'Auto-scale layout (Vue nodes)',
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
name: 'Auto-scale layout (Nodes 2.0)',
tooltip:
'Automatically scale node positions when switching to Vue rendering to prevent overlap',
type: 'boolean',
sortOrder: 50,
experimental: true,
defaultValue: true,
versionAdded: '1.30.3'

View File

@@ -38,8 +38,9 @@ function onChange(
}
// Backward compatibility with old settings dialog.
// Some extensions still listens event emitted by the old settings dialog.
// @ts-expect-error 'setting' is possibly 'undefined'.ts(18048)
app.ui.settings.dispatchChange(setting.id, newValue, oldValue)
if (setting) {
app.ui.settings.dispatchChange(setting.id, newValue, oldValue)
}
}
export const useSettingStore = defineStore('setting', () => {

View File

@@ -13,6 +13,7 @@ import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -329,6 +330,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
tabActivationHistory.value.shift()
}
useCanvasStore().linearMode = !!loadedWorkflow.activeState.extra?.linearMode
return loadedWorkflow
}

View File

@@ -40,6 +40,8 @@ export const useCanvasStore = defineStore('canvas', () => {
// Reactive scale percentage that syncs with app.canvas.ds.scale
const appScalePercentage = ref(100)
const linearMode = ref(false)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
undefined
@@ -138,6 +140,7 @@ export const useCanvasStore = defineStore('canvas', () => {
groupSelected,
rerouteSelected,
appScalePercentage,
linearMode,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,

View File

@@ -1,31 +0,0 @@
import type { InjectionKey } from 'vue'
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
/**
* Lightweight, injectable transform state used by layout-aware components.
*
* Consumers use this interface to convert coordinates between LiteGraph's
* canvas space and the DOM's screen space, access the current pan/zoom
* (camera), and perform basic viewport culling checks.
*
* Coordinate mapping:
* - screen = (canvas + offset) * scale
* - canvas = screen / scale - offset
*
* The full implementation and additional helpers live in
* `useTransformState()`. This interface deliberately exposes only the
* minimal surface needed outside that composable.
*
* @example
* const state = inject(TransformStateKey)!
* const screen = state.canvasToScreen({ x: 100, y: 50 })
*/
export interface TransformState
extends Pick<
ReturnType<typeof useTransformState>,
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
> {}
export const TransformStateKey: InjectionKey<TransformState> =
Symbol('transformState')

View File

@@ -17,10 +17,9 @@
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import { computed, provide } from 'vue'
import { computed } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -32,14 +31,7 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>()
const {
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
const { camera, transformStyle, syncWithCanvas } = useTransformState()
const { isLOD } = useLOD(camera)
@@ -48,13 +40,6 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 512
})
provide(TransformStateKey, {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
const emit = defineEmits<{
transformUpdate: []
}>()

Some files were not shown because too many files have changed in this diff Show More