Compare commits

...

5 Commits

Author SHA1 Message Date
dante01yoon
e994c2f630 Revert "fix: update knip package entrypoints"
This reverts commit 711fa84e3c.
2026-04-28 16:23:08 +09:00
dante01yoon
711fa84e3c fix: update knip package entrypoints 2026-04-28 16:00:51 +09:00
dante01yoon
51874a57eb refactor: address promotion policy review 2026-04-28 15:57:27 +09:00
Alexander Brown
5782131a18 Merge branch 'main' into refactor/extract-widget-classification 2026-04-27 19:17:49 -07:00
dante01yoon
87bcff999c refactor: extract widget classification from promotionUtils
Move widget classification logic (isPreviewPseudoWidget, isRecommendedWidget,
getPromotableWidgets) into dedicated widgetClassification module. Add missing
isRecommendedWidget tests. No behavior change.
2026-04-22 06:38:53 +09:00
6 changed files with 281 additions and 216 deletions

View File

@@ -8,15 +8,17 @@ import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionPolicy'
import {
getPromotableWidgets,
isRecommendedWidget
} from '@/core/graph/subgraph/promotionPolicy'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

View File

@@ -0,0 +1,195 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetItem } from './promotionPolicy'
import {
getPromotableWidgets,
isPreviewPseudoWidget,
isRecommendedWidget
} from './promotionPolicy'
function widget(
overrides: Partial<
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
function widgetItem(
nodeType: string,
widgetName: string,
overrides: Partial<IBaseWidget> = {}
): WidgetItem {
const node = { title: nodeType, id: 1, type: nodeType }
const w = fromPartial<IBaseWidget>({
name: widgetName,
computedDisabled: false,
...overrides
})
return [node, w]
}
describe('isPreviewPseudoWidget', () => {
it('returns true for $$-prefixed widget names', () => {
expect(
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
).toBe(true)
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
})
it('returns true for serialize:false with type "preview"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: false, type: 'preview' })
)
).toBe(true)
})
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
expect(
isPreviewPseudoWidget(
widget({
name: 'videopreview',
type: 'preview',
options: { serialize: false }
})
)
).toBe(true)
})
it('returns true for serialize:false with type "video"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'vid', serialize: false, type: 'video' })
)
).toBe(true)
})
it('returns true for serialize:false with type "audioUI"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'audio', serialize: false, type: 'audioUI' })
)
).toBe(true)
})
it('returns false for type "preview" when serialize is not false', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: true, type: 'preview' })
)
).toBe(false)
})
it('returns false for regular widgets', () => {
expect(
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
).toBe(false)
})
it('returns false for serialize:false with unknown type', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'text', serialize: false, type: 'customtext' })
)
).toBe(false)
})
})
describe('getPromotableWidgets', () => {
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
const node = new LGraphNode('PreviewImage')
node.type = 'PreviewImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for SaveImage nodes', () => {
const node = new LGraphNode('SaveImage')
node.type = 'SaveImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
const node = new LGraphNode('ImageInvert')
node.type = 'ImageInvert'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
})
describe('isRecommendedWidget', () => {
it('returns true for widgets on recommended node types', () => {
expect(isRecommendedWidget(widgetItem('CLIPTextEncode', 'text'))).toBe(true)
expect(isRecommendedWidget(widgetItem('LoadImage', 'image'))).toBe(true)
expect(isRecommendedWidget(widgetItem('SaveImage', 'filename'))).toBe(true)
expect(isRecommendedWidget(widgetItem('PreviewImage', 'anything'))).toBe(
true
)
})
it('returns true for seed widgets regardless of node type', () => {
expect(isRecommendedWidget(widgetItem('KSampler', 'seed'))).toBe(true)
expect(isRecommendedWidget(widgetItem('KSamplerAdvanced', 'seed'))).toBe(
true
)
})
it('returns false for non-recommended node and widget combinations', () => {
expect(isRecommendedWidget(widgetItem('KSampler', 'steps'))).toBe(false)
expect(isRecommendedWidget(widgetItem('VAEDecode', 'samples'))).toBe(false)
})
it('returns false when widget is computedDisabled', () => {
expect(
isRecommendedWidget(
widgetItem('CLIPTextEncode', 'text', { computedDisabled: true })
)
).toBe(false)
expect(
isRecommendedWidget(
widgetItem('KSampler', 'seed', { computedDisabled: true })
)
).toBe(false)
})
})

View File

@@ -0,0 +1,69 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
export type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
return true
return false
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
return {
name: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'IMAGE_PREVIEW',
options: { serialize: false },
serialize: false,
y: 0,
computedDisabled: false
}
}
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
const widgets = [...(node.widgets ?? [])]
const hasCanvasPreviewWidget = widgets.some(
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
const supportsVirtualPreview = supportsVirtualCanvasImagePreview(node)
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
widgets.push(createVirtualCanvasImagePreviewWidget())
}
return widgets
}

View File

@@ -1,8 +1,8 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -17,95 +17,12 @@ vi.mock('@/services/litegraphService', () => ({
}))
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
} from './promotionUtils'
function widget(
overrides: Partial<
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('returns true for $$-prefixed widget names', () => {
expect(
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
).toBe(true)
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
})
it('returns true for serialize:false with type "preview"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: false, type: 'preview' })
)
).toBe(true)
})
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
expect(
isPreviewPseudoWidget(
widget({
name: 'videopreview',
type: 'preview',
options: { serialize: false }
})
)
).toBe(true)
})
it('returns true for serialize:false with type "video"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'vid', serialize: false, type: 'video' })
)
).toBe(true)
})
it('returns true for serialize:false with type "audioUI"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'audio', serialize: false, type: 'audioUI' })
)
).toBe(true)
})
it('returns false for type "preview" when serialize is not false', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: true, type: 'preview' })
)
).toBe(false)
})
it('returns false for regular widgets', () => {
expect(
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
).toBe(false)
})
it('returns false for serialize:false with unknown type', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'text', serialize: false, type: 'customtext' })
)
).toBe(false)
})
})
describe('pruneDisconnected', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -169,63 +86,6 @@ describe('pruneDisconnected', () => {
})
})
describe('getPromotableWidgets', () => {
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
const node = new LGraphNode('PreviewImage')
node.type = 'PreviewImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for SaveImage nodes', () => {
const node = new LGraphNode('SaveImage')
node.type = 'SaveImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
const node = new LGraphNode('ImageInvert')
node.type = 'ImageInvert'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
})
describe('promoteRecommendedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -1,6 +1,15 @@
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
PartialNode,
WidgetItem
} from '@/core/graph/subgraph/promotionPolicy'
import {
getPromotableWidgets,
isPreviewPseudoWidget,
isRecommendedWidget
} from '@/core/graph/subgraph/promotionPolicy'
import { t } from '@/i18n'
import type {
IContextMenuValue,
@@ -18,11 +27,6 @@ import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
@@ -74,25 +78,6 @@ function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
return true
return false
}
export function promoteWidget(
node: PartialNode,
widget: IBaseWidget,
@@ -199,50 +184,6 @@ export function tryToggleWidgetPromotion() {
else demoteWidget(node, widget, parents)
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function supportsVirtualPreviewWidget(node: LGraphNode): boolean {
return supportsVirtualCanvasImagePreview(node)
}
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
return {
name: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'IMAGE_PREVIEW',
options: { serialize: false },
serialize: false,
y: 0,
computedDisabled: false
}
}
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
const widgets = [...(node.widgets ?? [])]
const hasCanvasPreviewWidget = widgets.some(
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
const supportsVirtualPreview = supportsVirtualPreviewWidget(node)
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
widgets.push(createVirtualCanvasImagePreviewWidget())
}
return widgets
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
}

View File

@@ -6,10 +6,8 @@ import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import {
addWidgetPromotionOptions,
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { addWidgetPromotionOptions } from '@/core/graph/subgraph/promotionUtils'
import { isPreviewPseudoWidget } from '@/core/graph/subgraph/promotionPolicy'
import { applyDynamicInputs } from '@/core/graph/widgets/dynamicWidgets'
import { st, t } from '@/i18n'
import {