[backport core/1.42] feat: App mode - enable mask editor (#10443)

Backport of #9876 to `core/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10443-backport-core-1-42-feat-App-mode-enable-mask-editor-32d6d73d3650818b997ec26ff490f7de)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
This commit is contained in:
Comfy Org PR Bot
2026-03-25 00:28:52 +09:00
committed by GitHub
parent c2b0f94190
commit 560622924b
6 changed files with 145 additions and 61 deletions

View File

@@ -10,6 +10,8 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
@@ -17,6 +19,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -38,6 +41,7 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
@@ -97,21 +101,27 @@ const mappedSelections = computed((): WidgetEntry[] => {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
const imageUrl = buildImageUrl()
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
imageUrl,
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
}
}

View File

@@ -5,6 +5,19 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
export function extractWidgetStringValue(value: unknown): string | undefined {
if (typeof value === 'string') return value
if (
value &&
typeof value === 'object' &&
'filename' in value &&
typeof value.filename === 'string'
)
return value.filename
return undefined
}
// Private image utility functions
interface ImageLayerFilenames {
@@ -84,62 +97,23 @@ export function useMaskEditorLoader() {
let nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
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
}
}
}
const imageWidget = node.widgets?.find((w) => w.name === 'image')
const widgetFilename = imageWidget
? extractWidgetStringValue(imageWidget.value)
: undefined
// 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.
// Skip internal reference formats (e.g. "$35-0" used by some plugins like Impact-Pack)
if (widgetFilename && !widgetFilename.startsWith('$')) {
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)
const parsed = parseImageWidgetValue(widgetFilename)
nodeImageRef = {
filename: parsed.filename,
type: parsed.type || 'input',
subfolder: parsed.subfolder || undefined
}
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
}
const fileToQuery = widgetFilename || nodeImageRef.filename

View File

@@ -1155,6 +1155,7 @@
},
"maskEditor": {
"title": "Mask Editor",
"openMaskEditor": "Open in Mask Editor",
"invert": "Invert",
"clear": "Clear",
"undo": "Undo",

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { useDropZone } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const {
onDragOver,
onDragDrop,
@@ -17,6 +20,7 @@ const {
imageUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
}
forceHovered?: boolean
}>()
@@ -91,7 +95,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 block h-42 min-h-32 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
'group/dropzone m-3 block h-42 min-h-32 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@@ -108,12 +112,26 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
)
"
>
<img
<div
v-if="dropIndicator?.imageUrl"
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
class="relative max-h-full max-w-full"
>
<img
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
<button
v-if="dropIndicator?.onMaskEdit"
type="button"
:aria-label="t('maskEditor.openMaskEditor')"
:title="t('maskEditor.openMaskEditor')"
class="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background opacity-0 transition-colors duration-200 group-hover/dropzone:opacity-100 hover:bg-base-foreground/90"
@click.stop="dropIndicator?.onMaskEdit?.()"
>
<i class="icon-[comfy--mask] size-4" />
</button>
</div>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { parseImageWidgetValue } from './imageUtil'
describe('parseImageWidgetValue', () => {
it('parses a plain filename', () => {
expect(parseImageWidgetValue('example.png')).toEqual({
filename: 'example.png',
subfolder: '',
type: 'input'
})
})
it('parses filename with type suffix', () => {
expect(parseImageWidgetValue('example.png [output]')).toEqual({
filename: 'example.png',
subfolder: '',
type: 'output'
})
})
it('parses subfolder and filename', () => {
expect(parseImageWidgetValue('clipspace/mask-123.png')).toEqual({
filename: 'mask-123.png',
subfolder: 'clipspace',
type: 'input'
})
})
it('parses subfolder, filename, and type', () => {
expect(
parseImageWidgetValue(
'clipspace/clipspace-painted-masked-123.png [input]'
)
).toEqual({
filename: 'clipspace-painted-masked-123.png',
subfolder: 'clipspace',
type: 'input'
})
})
it('parses nested subfolders', () => {
expect(parseImageWidgetValue('a/b/c/image.png [temp]')).toEqual({
filename: 'image.png',
subfolder: 'a/b/c',
type: 'temp'
})
})
it('handles empty string', () => {
expect(parseImageWidgetValue('')).toEqual({
filename: '',
subfolder: '',
type: 'input'
})
})
})

View File

@@ -1,3 +1,27 @@
/**
* Parses an image widget value like "subfolder/filename [type]" into its parts.
*/
export function parseImageWidgetValue(raw: string): {
filename: string
subfolder: string
type: string
} {
let value = raw
let type = 'input'
const typeMatch = value.match(/ \[([^\]]+)\]$/)
if (typeMatch) {
type = typeMatch[1]
value = value.slice(0, -typeMatch[0].length)
}
let subfolder = ''
const slashIndex = value.lastIndexOf('/')
if (slashIndex !== -1) {
subfolder = value.slice(0, slashIndex)
value = value.slice(slashIndex + 1)
}
return { filename: value, subfolder, type }
}
export const is_all_same_aspect_ratio = (imgs: HTMLImageElement[]): boolean => {
if (!imgs.length || imgs.length === 1) return true