Compare commits

..

2 Commits

Author SHA1 Message Date
Comfy Org PR Bot
badc97b982 [backport core/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12453)
Backport of #12447 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 22:12:03 -07:00
Comfy Org PR Bot
67affd2075 [backport core/1.45] Fix missing value control on 'Primitive Int' (#12461)
Backport of #12431 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:23:57 -07:00
42 changed files with 349 additions and 1021 deletions

View File

@@ -213,7 +213,8 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
incrementButton: widget.getByTestId(TestIds.widgets.increment)
incrementButton: widget.getByTestId(TestIds.widgets.increment),
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
}
}

View File

@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -152,6 +152,7 @@ export const TestIds = {
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
valueControl: 'value-control',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button',
selectDefaultSearchInput: 'widget-select-default-search-input',

View File

@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -38,4 +38,15 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
})
test('displays control widgets with default state', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Int')
const widget = comfyPage.vueNodes.getWidgetByName('Int', 'value')
await expect(widget).toBeVisible()
const { valueControl } = comfyPage.vueNodes.getInputNumberControls(widget)
await expect(valueControl).toBeVisible()
})
})

View File

@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.push(node.widgets![0])
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets![2] = node.widgets![0]
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.splice(0, 0, node.widgets![0])
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
})
await expect(loadCheckpointNode).toHaveCount(4)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.1",
"version": "1.45.14",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -71,7 +71,6 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -11,39 +11,17 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="cn('pi pi-camera text-lg text-base-foreground')" />
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
</Button>
<PopupSlider
v-if="showFOVButton"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<Button
v-tooltip.right="{
value: $t('load3d.retainViewOnReload'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.retainViewOnReload')"
:aria-pressed="retainViewOnReload"
@click="retainViewOnReload = !retainViewOnReload"
>
<i
:class="
cn(
'pi text-lg text-base-foreground',
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
)
"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
@@ -52,9 +30,6 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
default: false
})
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {

View File

@@ -5,12 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -74,8 +71,7 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -83,8 +79,7 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
@@ -281,75 +276,4 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,7 +27,6 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -77,8 +76,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -90,7 +87,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -106,13 +102,6 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -137,6 +126,7 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -231,48 +230,6 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -141,9 +141,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -194,12 +193,8 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -238,7 +238,6 @@ import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContex
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useFlatOutputAssetsGrouped } from '@/platform/assets/composables/media/useFlatOutputAssetsGrouped'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -312,7 +311,7 @@ const formattedExecutionTime = computed(() => {
const toast = useToast()
const inputAssets = useAssetsApi('input')
const outputAssets = useFlatOutputAssetsGrouped()
const outputAssets = useAssetsApi('output')
// Asset selection
const {

View File

@@ -23,6 +23,7 @@ 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 { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
@@ -154,9 +155,7 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),

View File

@@ -144,7 +144,6 @@ describe('useLoad3d', () => {
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setRetainViewOnReload: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
@@ -569,21 +568,17 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
vi.mocked(mockLoad3d.setFOV!).mockClear()
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
composable.cameraConfig.value.retainViewOnReload = true
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null,
retainViewOnReload: true
state: null
})
})

View File

@@ -483,7 +483,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
}
},
{ deep: true }

View File

@@ -2,10 +2,7 @@ import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraState,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
const {
cloneSkinnedMock,
@@ -772,133 +769,6 @@ describe('Load3d', () => {
})
})
describe('retainViewOnReload', () => {
function setupLoadInternal(initialFlag: boolean) {
const getCameraState = vi.fn<() => CameraState>(() => ({
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'perspective'
}))
const setCameraState = vi.fn()
const getCurrentCameraType = vi.fn(() => 'perspective' as const)
const loaderLoadModel = vi.fn().mockResolvedValue(undefined)
Object.assign(ctx.load3d, {
cameraManager: {
...ctx.cameraManager,
getCameraState,
setCameraState,
getCurrentCameraType
},
controlsManager: { ...ctx.controlsManager, reset: vi.fn() },
loaderManager: { loadModel: loaderLoadModel },
modelManager: {
...ctx.modelManager,
currentModel: new THREE.Group(),
originalModel: null
},
animationManager: {
...ctx.animationManager,
setupModelAnimations: vi.fn()
},
handleResize: vi.fn(),
retainViewOnReload: initialFlag,
hasLoadedModel: false
})
return { getCameraState, setCameraState, getCurrentCameraType }
}
it('first load uses default framing even with retain enabled', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
// hasLoadedModel started false, so retain shouldn't kick in yet.
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('subsequent load captures camera state, skips reset, and restores it', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('does not retain when the flag is off, even after a prior load', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('toggles to the saved camera type before restoring state when types differ', async () => {
const mocks = setupLoadInternal(true)
mocks.getCameraState.mockImplementation(() => ({
position: new THREE.Vector3(0, 0, 5),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'orthographic'
}))
// First load (active type stays perspective per the default mock).
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.toggleCamera as ReturnType<typeof vi.fn>).mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.clearModel()
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
})
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.setRetainViewOnReload(true)
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }

View File

@@ -104,8 +104,6 @@ class Load3d {
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private retainViewOnReload: boolean = false
private hasLoadedModel: boolean = false
constructor(
container: Element | HTMLElement,
@@ -566,33 +564,13 @@ class Load3d {
}
}
/**
* Toggles whether `_loadModelInternal` preserves the current camera state
* across model loads. When enabled and a model has previously loaded, the
* camera position/target/zoom (and camera type) are captured before the
* scene clears and restored after the new model is in place.
*/
public setRetainViewOnReload(value: boolean): void {
this.retainViewOnReload = value
}
private async _loadModelInternal(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
// Retain view only kicks in after a successful first load — on the very
// first load there's no meaningful "current" framing to preserve, so the
// default `setupForModel` framing wins.
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
: null
if (!shouldRetainView) {
this.cameraManager.reset()
this.controlsManager.reset()
}
this.cameraManager.reset()
this.controlsManager.reset()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.animationManager.dispose()
@@ -605,19 +583,6 @@ class Load3d {
this.modelManager.currentModel,
this.modelManager.originalModel
)
this.hasLoadedModel = true
}
if (savedCameraState) {
// SceneModelManager.setupModel called setupForModel which clobbered the
// camera. Restore the captured state on top of that.
if (
savedCameraState.cameraType !==
this.cameraManager.getCurrentCameraType()
) {
this.toggleCamera(savedCameraState.cameraType)
}
this.cameraManager.setCameraState(savedCameraState)
}
this.handleResize()
@@ -642,7 +607,6 @@ class Load3d {
this.gizmoManager.detach()
this.modelManager.clearModel()
this.adapterRef.current = null
this.hasLoadedModel = false
this.forceRender()
}

View File

@@ -50,7 +50,6 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -911,44 +911,6 @@
"paused": "تم الإيقاف مؤقتًا",
"resume": "استئناف التنزيل"
},
"errorCatalog": {
"fallbacks": {
"inputName": "مدخل غير معروف",
"nodeName": "هذه العقدة"
},
"promptErrors": {
"no_prompt": {
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
},
"prompt_no_outputs": {
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة أو معاينة الصورة) لإنتاج نتيجة."
},
"server_error_cloud": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
},
"server_error_local": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً. لم يتم خصم أي رصيد.",
"toastMessageLocal": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً.",
"toastTitle": "فشل {nodeName}"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "بعض منافذ الإدخال المطلوبة غير متصلة.",
"title": "الاتصال مفقود",
"toastMessage": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
"toastTitle": "مدخل مطلوب مفقود"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "جاري إعادة تحميل النموذج...",
"removeBackgroundImage": "إزالة صورة الخلفية",
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
"scene": "المشهد",
"showGrid": "عرض الشبكة",
"showSkeleton": "إظهار الهيكل العظمي",
@@ -2740,6 +2701,20 @@
"normal": "عادي",
"parameters": "المعلمات",
"pinned": "مثبت",
"promptErrors": {
"no_prompt": {
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
},
"prompt_no_outputs": {
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة، معاينة الصورة) لإنتاج نتيجة."
},
"server_error_cloud": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
},
"server_error_local": {
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
}
},
"properties": "الخصائص",
"removeFavorite": "إزالة من المفضلة",
"resetAllParameters": "إعادة تعيين جميع المعلمات",

View File

@@ -1952,7 +1952,6 @@
},
"load3d": {
"switchCamera": "Switch Camera",
"retainViewOnReload": "Lock camera view across model reloads",
"showGrid": "Show Grid",
"backgroundColor": "Background Color",
"lightIntensity": "Light Intensity",

View File

@@ -911,44 +911,6 @@
"paused": "Pausado",
"resume": "Reanudar descarga"
},
"errorCatalog": {
"fallbacks": {
"inputName": "entrada desconocida",
"nodeName": "Este nodo"
},
"promptErrors": {
"no_prompt": {
"desc": "Los datos del flujo de trabajo enviados al servidor están vacíos. Esto puede ser un error inesperado del sistema."
},
"prompt_no_outputs": {
"desc": "El flujo de trabajo no contiene ningún nodo de salida (por ejemplo, Guardar imagen, Vista previa de imagen) para producir un resultado."
},
"server_error_cloud": {
"desc": "El servidor encontró un error inesperado. Por favor, inténtalo de nuevo más tarde."
},
"server_error_local": {
"desc": "El servidor encontró un error inesperado. Por favor, revisa los registros del servidor."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "Este nodo generó un error durante la ejecución. Revisa sus entradas o prueba una configuración diferente. No se cobraron créditos.",
"toastMessageLocal": "Este nodo generó un error durante la ejecución. Revisa sus entradas o prueba una configuración diferente.",
"toastTitle": "{nodeName} falló"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} carece de una entrada requerida: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "Las ranuras de entrada requeridas no tienen ninguna conexión.",
"title": "Conexión faltante",
"toastMessage": "{nodeName} carece de una entrada requerida: {inputName}",
"toastTitle": "Falta entrada requerida"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "Recargando modelo...",
"removeBackgroundImage": "Eliminar imagen de fondo",
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
"retainViewOnReload": "Bloquear la vista de la cámara al recargar el modelo",
"scene": "Escena",
"showGrid": "Mostrar cuadrícula",
"showSkeleton": "Mostrar esqueleto",
@@ -2740,6 +2701,20 @@
"normal": "Normal",
"parameters": "Parámetros",
"pinned": "Fijado",
"promptErrors": {
"no_prompt": {
"desc": "Los datos del flujo de trabajo enviados al servidor están vacíos. Esto puede ser un error inesperado del sistema."
},
"prompt_no_outputs": {
"desc": "El flujo de trabajo no contiene ningún nodo de salida (por ejemplo, Guardar imagen, Vista previa de imagen) para producir un resultado."
},
"server_error_cloud": {
"desc": "El servidor encontró un error inesperado. Por favor, inténtalo de nuevo más tarde."
},
"server_error_local": {
"desc": "El servidor encontró un error inesperado. Por favor, revisa los registros del servidor."
}
},
"properties": "Propiedades",
"removeFavorite": "Quitar de favoritos",
"resetAllParameters": "Restablecer todos los parámetros",

View File

@@ -911,44 +911,6 @@
"paused": "متوقف شده",
"resume": "ادامه دانلود"
},
"errorCatalog": {
"fallbacks": {
"inputName": "ورودی نامشخص",
"nodeName": "این نود"
},
"promptErrors": {
"no_prompt": {
"desc": "داده‌های ورک‌فلو ارسال‌شده به سرور خالی است. این ممکن است یک خطای غیرمنتظره سیستمی باشد."
},
"prompt_no_outputs": {
"desc": "در این ورک‌فلو هیچ نود خروجی (مانند Save Image یا Preview Image) برای تولید نتیجه وجود ندارد."
},
"server_error_cloud": {
"desc": "سرور با یک خطای غیرمنتظره مواجه شد. لطفاً بعداً دوباره تلاش کنید."
},
"server_error_local": {
"desc": "سرور با یک خطای غیرمنتظره مواجه شد. لطفاً لاگ‌های سرور را بررسی کنید."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "این نود هنگام اجرا با خطا مواجه شد. ورودی‌ها را بررسی کنید یا پیکربندی دیگری را امتحان نمایید. هیچ اعتباری کسر نشد.",
"toastMessageLocal": "این نود هنگام اجرا با خطا مواجه شد. ورودی‌ها را بررسی کنید یا پیکربندی دیگری را امتحان نمایید.",
"toastTitle": "{nodeName} با خطا مواجه شد"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} یک ورودی ضروری را ندارد: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "ورودی‌های ضروری بدون اتصال هستند.",
"title": "اتصال وجود ندارد",
"toastMessage": "{nodeName} یک ورودی ضروری را ندارد: {inputName}",
"toastTitle": "ورودی ضروری وجود ندارد"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "در حال بارگذاری مجدد مدل...",
"removeBackgroundImage": "حذف تصویر پس‌زمینه",
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
"scene": "صحنه",
"showGrid": "نمایش شبکه",
"showSkeleton": "نمایش اسکلت",
@@ -2740,6 +2701,20 @@
"normal": "عادی",
"parameters": "پارامترها",
"pinned": "سنجاق شده",
"promptErrors": {
"no_prompt": {
"desc": "داده‌های گردش‌کار ارسال‌شده به سرور خالی است. این ممکن است یک خطای غیرمنتظره سیستمی باشد."
},
"prompt_no_outputs": {
"desc": "گردش‌کار هیچ نود خروجی (مانند Save Image یا Preview Image) برای تولید نتیجه ندارد."
},
"server_error_cloud": {
"desc": "سرور با خطای غیرمنتظره‌ای مواجه شد. لطفاً بعداً دوباره تلاش کنید."
},
"server_error_local": {
"desc": "سرور با خطای غیرمنتظره‌ای مواجه شد. لطفاً لاگ‌های سرور را بررسی کنید."
}
},
"properties": "ویژگی‌ها",
"removeFavorite": "حذف از علاقه‌مندی‌ها",
"resetAllParameters": "بازنشانی همه پارامترها",

View File

@@ -911,44 +911,6 @@
"paused": "En pause",
"resume": "Reprendre le téléchargement"
},
"errorCatalog": {
"fallbacks": {
"inputName": "entrée inconnue",
"nodeName": "Ce nœud"
},
"promptErrors": {
"no_prompt": {
"desc": "Les données du workflow envoyées au serveur sont vides. Il peut sagir dune erreur système inattendue."
},
"prompt_no_outputs": {
"desc": "Le workflow ne contient aucun nœud de sortie (par exemple, Enregistrer limage, Prévisualiser limage) pour produire un résultat."
},
"server_error_cloud": {
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez réessayer plus tard."
},
"server_error_local": {
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez consulter les journaux du serveur."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "Ce nœud a généré une erreur lors de lexécution. Vérifiez ses entrées ou essayez une autre configuration. Aucun crédit na été déduit.",
"toastMessageLocal": "Ce nœud a généré une erreur lors de lexécution. Vérifiez ses entrées ou essayez une autre configuration.",
"toastTitle": "Échec de {nodeName}"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} nécessite une entrée obligatoire : {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "Des entrées requises ne sont pas connectées.",
"title": "Connexion manquante",
"toastMessage": "{nodeName} nécessite une entrée obligatoire : {inputName}",
"toastTitle": "Entrée requise manquante"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "Rechargement du modèle...",
"removeBackgroundImage": "Supprimer l'image de fond",
"resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie",
"retainViewOnReload": "Verrouiller la vue de la caméra lors du rechargement du modèle",
"scene": "Scène",
"showGrid": "Afficher la grille",
"showSkeleton": "Afficher le squelette",
@@ -2740,6 +2701,20 @@
"normal": "Normal",
"parameters": "Paramètres",
"pinned": "Épinglé",
"promptErrors": {
"no_prompt": {
"desc": "Les données du flux de travail envoyées au serveur sont vides. Il peut s'agir d'une erreur système inattendue."
},
"prompt_no_outputs": {
"desc": "Le flux de travail ne contient aucun nœud de sortie (par exemple, Enregistrer l'image, Prévisualiser l'image) pour produire un résultat."
},
"server_error_cloud": {
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez réessayer plus tard."
},
"server_error_local": {
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez consulter les journaux du serveur."
}
},
"properties": "Propriétés",
"removeFavorite": "Retirer des favoris",
"resetAllParameters": "Réinitialiser tous les paramètres",

View File

@@ -911,44 +911,6 @@
"paused": "一時停止",
"resume": "ダウンロードを再開"
},
"errorCatalog": {
"fallbacks": {
"inputName": "不明な入力",
"nodeName": "このノード"
},
"promptErrors": {
"no_prompt": {
"desc": "サーバーに送信されたワークフローデータが空です。これは予期しないシステムエラーの可能性があります。"
},
"prompt_no_outputs": {
"desc": "ワークフローに出力ノード(例:画像を保存、画像をプレビュー)が含まれていないため、結果を生成できません。"
},
"server_error_cloud": {
"desc": "サーバーで予期しないエラーが発生しました。しばらくしてから再度お試しください。"
},
"server_error_local": {
"desc": "サーバーで予期しないエラーが発生しました。サーバーログを確認してください。"
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "このノードの実行中にエラーが発生しました。入力内容を確認するか、別の設定をお試しください。クレジットは消費されていません。",
"toastMessageLocal": "このノードの実行中にエラーが発生しました。入力内容を確認するか、別の設定をお試しください。",
"toastTitle": "{nodeName} の実行に失敗しました"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} に必須入力 {inputName} がありません。",
"itemLabel": "{nodeName} - {inputName}",
"message": "必須入力スロットに接続がありません。",
"title": "接続がありません",
"toastMessage": "{nodeName} に必須入力 {inputName} がありません。",
"toastTitle": "必須入力がありません"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "モデルを再読み込み中...",
"removeBackgroundImage": "背景画像を削除",
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
"scene": "シーン",
"showGrid": "グリッドを表示",
"showSkeleton": "スケルトンを表示",
@@ -2740,6 +2701,20 @@
"normal": "ノーマル",
"parameters": "パラメータ",
"pinned": "ピン留め",
"promptErrors": {
"no_prompt": {
"desc": "サーバーに送信されたワークフローデータが空です。これは予期しないシステムエラーの可能性があります。"
},
"prompt_no_outputs": {
"desc": "ワークフローに結果を生成する出力ノード(例:画像を保存、画像をプレビュー)が含まれていません。"
},
"server_error_cloud": {
"desc": "サーバーで予期しないエラーが発生しました。しばらくしてから再度お試しください。"
},
"server_error_local": {
"desc": "サーバーで予期しないエラーが発生しました。サーバーログをご確認ください。"
}
},
"properties": "プロパティ",
"removeFavorite": "お気に入りを解除",
"resetAllParameters": "すべてのパラメータをリセット",

View File

@@ -911,44 +911,6 @@
"paused": "일시 중지됨",
"resume": "다운로드 재개"
},
"errorCatalog": {
"fallbacks": {
"inputName": "알 수 없는 입력",
"nodeName": "이 노드"
},
"promptErrors": {
"no_prompt": {
"desc": "서버로 전송된 워크플로우 데이터가 비어 있습니다. 시스템 오류일 수 있습니다."
},
"prompt_no_outputs": {
"desc": "워크플로우에 결과를 생성할 출력 노드(예: 이미지 저장, 이미지 미리보기)가 없습니다."
},
"server_error_cloud": {
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
},
"server_error_local": {
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 서버 로그를 확인해 주세요."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "이 노드는 실행 중 오류가 발생했습니다. 입력값을 확인하거나 다른 설정을 시도해 보세요. 크레딧은 차감되지 않았습니다.",
"toastMessageLocal": "이 노드는 실행 중 오류가 발생했습니다. 입력값을 확인하거나 다른 설정을 시도해 보세요.",
"toastTitle": "{nodeName} 실패"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName}에 필수 입력이 누락되었습니다: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "필수 입력 슬롯에 연결이 없습니다.",
"title": "연결 누락",
"toastMessage": "{nodeName}에 필수 입력이 누락되었습니다: {inputName}",
"toastTitle": "필수 입력 누락"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "모델 다시 로드 중...",
"removeBackgroundImage": "배경 이미지 제거",
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
"scene": "장면",
"showGrid": "그리드 표시",
"showSkeleton": "스켈레톤 표시",
@@ -2740,6 +2701,20 @@
"normal": "일반",
"parameters": "파라미터",
"pinned": "고정됨",
"promptErrors": {
"no_prompt": {
"desc": "서버로 전송된 워크플로우 데이터가 비어 있습니다. 이는 예기치 않은 시스템 오류일 수 있습니다."
},
"prompt_no_outputs": {
"desc": "워크플로우에 결과를 생성할 출력 노드(예: 이미지 저장, 이미지 미리보기)가 포함되어 있지 않습니다."
},
"server_error_cloud": {
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 나중에 다시 시도해 주세요."
},
"server_error_local": {
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 서버 로그를 확인해 주세요."
}
},
"properties": "속성",
"removeFavorite": "즐겨찾기 해제",
"resetAllParameters": "모든 매개변수 재설정",

View File

@@ -911,44 +911,6 @@
"paused": "Pausado",
"resume": "Retomar download"
},
"errorCatalog": {
"fallbacks": {
"inputName": "entrada desconhecida",
"nodeName": "Este nó"
},
"promptErrors": {
"no_prompt": {
"desc": "Os dados do fluxo de trabalho enviados ao servidor estão vazios. Isso pode ser um erro inesperado do sistema."
},
"prompt_no_outputs": {
"desc": "O fluxo de trabalho não contém nenhum nó de saída (ex: Salvar Imagem, Visualizar Imagem) para produzir um resultado."
},
"server_error_cloud": {
"desc": "O servidor encontrou um erro inesperado. Por favor, tente novamente mais tarde."
},
"server_error_local": {
"desc": "O servidor encontrou um erro inesperado. Por favor, verifique os logs do servidor."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "Este nó gerou um erro durante a execução. Verifique suas entradas ou tente uma configuração diferente. Nenhum crédito foi cobrado.",
"toastMessageLocal": "Este nó gerou um erro durante a execução. Verifique suas entradas ou tente uma configuração diferente.",
"toastTitle": "{nodeName} falhou"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} está sem uma entrada obrigatória: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "Entradas obrigatórias não possuem conexão.",
"title": "Conexão ausente",
"toastMessage": "{nodeName} está sem uma entrada obrigatória: {inputName}",
"toastTitle": "Entrada obrigatória ausente"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "Recarregando modelo...",
"removeBackgroundImage": "Remover Imagem de Fundo",
"resizeNodeMatchOutput": "Redimensionar Nó para corresponder à saída",
"retainViewOnReload": "Manter a visão da câmera ao recarregar o modelo",
"scene": "Cena",
"showGrid": "Mostrar Grade",
"showSkeleton": "Mostrar Esqueleto",
@@ -2740,6 +2701,20 @@
"normal": "Normal",
"parameters": "Parâmetros",
"pinned": "Fixado",
"promptErrors": {
"no_prompt": {
"desc": "Os dados do fluxo de trabalho enviados ao servidor estão vazios. Isso pode ser um erro de sistema inesperado."
},
"prompt_no_outputs": {
"desc": "O fluxo de trabalho não contém nenhum nó de saída (por exemplo, Salvar Imagem, Visualizar Imagem) para produzir um resultado."
},
"server_error_cloud": {
"desc": "O servidor encontrou um erro inesperado. Por favor, tente novamente mais tarde."
},
"server_error_local": {
"desc": "O servidor encontrou um erro inesperado. Por favor, verifique os logs do servidor."
}
},
"properties": "Propriedades",
"removeFavorite": "Desfavoritar",
"resetAllParameters": "Redefinir todos os parâmetros",

View File

@@ -911,44 +911,6 @@
"paused": "Приостановлено",
"resume": "Возобновить загрузку"
},
"errorCatalog": {
"fallbacks": {
"inputName": "неизвестный вход",
"nodeName": "Этот узел"
},
"promptErrors": {
"no_prompt": {
"desc": "Данные рабочего процесса, отправленные на сервер, пусты. Это может быть неожиданной системной ошибкой."
},
"prompt_no_outputs": {
"desc": "В рабочем процессе отсутствуют выходные узлы (например, Сохранить изображение, Просмотр изображения) для получения результата."
},
"server_error_cloud": {
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, попробуйте позже."
},
"server_error_local": {
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, проверьте журналы сервера."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "Этот узел вызвал ошибку во время выполнения. Проверьте его входные данные или попробуйте другую конфигурацию. Кредиты не списаны.",
"toastMessageLocal": "Этот узел вызвал ошибку во время выполнения. Проверьте его входные данные или попробуйте другую конфигурацию.",
"toastTitle": "{nodeName} завершился с ошибкой"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} отсутствует обязательный вход: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "Требуемые входные слоты не имеют подключений.",
"title": "Отсутствует соединение",
"toastMessage": "{nodeName} отсутствует обязательный вход: {inputName}",
"toastTitle": "Отсутствует обязательный вход"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "Перезагрузка модели...",
"removeBackgroundImage": "Удалить фоновое изображение",
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
"scene": "Сцена",
"showGrid": "Показать сетку",
"showSkeleton": "Показать скелет",
@@ -2740,6 +2701,20 @@
"normal": "Обычный",
"parameters": "Параметры",
"pinned": "Закреплено",
"promptErrors": {
"no_prompt": {
"desc": "Данные рабочего процесса, отправленные на сервер, пусты. Это может быть неожиданной системной ошибкой."
},
"prompt_no_outputs": {
"desc": "В рабочем процессе отсутствуют выходные узлы (например, Сохранить изображение, Предпросмотр изображения) для получения результата."
},
"server_error_cloud": {
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, попробуйте позже."
},
"server_error_local": {
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, проверьте логи сервера."
}
},
"properties": "Свойства",
"removeFavorite": "Убрать из избранного",
"resetAllParameters": "Сбросить все параметры",

View File

@@ -911,44 +911,6 @@
"paused": "Duraklatıldı",
"resume": "İndirmeye Devam Et"
},
"errorCatalog": {
"fallbacks": {
"inputName": "bilinmeyen giriş",
"nodeName": "Bu düğüm"
},
"promptErrors": {
"no_prompt": {
"desc": "Sunucuya gönderilen çalışma akışı verisi boş. Bu beklenmeyen bir sistem hatası olabilir."
},
"prompt_no_outputs": {
"desc": "Çalışma akışında sonuç üretecek herhangi bir çıktı düğümü (örn. Görseli Kaydet, Görseli Önizle) bulunmuyor."
},
"server_error_cloud": {
"desc": "Sunucuda beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin."
},
"server_error_local": {
"desc": "Sunucuda beklenmeyen bir hata oluştu. Lütfen sunucu günlüklerini kontrol edin."
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "Bu düğüm çalıştırılırken bir hata oluştu. Girişlerini kontrol edin veya farklı bir yapılandırmayı deneyin. Kredi harcanmadı.",
"toastMessageLocal": "Bu düğüm çalıştırılırken bir hata oluştu. Girişlerini kontrol edin veya farklı bir yapılandırmayı deneyin.",
"toastTitle": "{nodeName} başarısız oldu"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} için gerekli bir giriş eksik: {inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "Gerekli giriş yuvalarına bağlantı yapılmamış.",
"title": "Eksik bağlantı",
"toastMessage": "{nodeName} için gerekli bir giriş eksik: {inputName}",
"toastTitle": "Gerekli giriş eksik"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"accessRestrictedTitle": "Access Restricted",
@@ -1751,7 +1713,6 @@
"reloadingModel": "Model yeniden yükleniyor...",
"removeBackgroundImage": "Arka Plan Resmini Kaldır",
"resizeNodeMatchOutput": "Düğümü çıktıya uyacak şekilde yeniden boyutlandır",
"retainViewOnReload": "Model yeniden yüklendiğinde kamera görünümünü kilitle",
"scene": "Sahne",
"showGrid": "Izgarayı Göster",
"showSkeleton": "İskeleti Göster",
@@ -2740,6 +2701,20 @@
"normal": "Normal",
"parameters": "Parametreler",
"pinned": "Sabitlendi",
"promptErrors": {
"no_prompt": {
"desc": "Sunucuya gönderilen çalışma akışı verisi boş. Bu beklenmeyen bir sistem hatası olabilir."
},
"prompt_no_outputs": {
"desc": "Çalışma akışında sonuç üretecek herhangi bir çıktı düğümü (ör. Görüntüyü Kaydet, Görüntüyü Önizle) bulunmuyor."
},
"server_error_cloud": {
"desc": "Sunucu beklenmeyen bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin."
},
"server_error_local": {
"desc": "Sunucu beklenmeyen bir hata ile karşılaştı. Lütfen sunucu günlüklerini kontrol edin."
}
},
"properties": "Özellikler",
"removeFavorite": "Favorilerden Kaldır",
"resetAllParameters": "Tüm parametreleri sıfırla",

View File

@@ -911,44 +911,6 @@
"paused": "已暫停",
"resume": "繼續下載"
},
"errorCatalog": {
"fallbacks": {
"inputName": "未知輸入",
"nodeName": "此節點"
},
"promptErrors": {
"no_prompt": {
"desc": "傳送到伺服器的工作流程資料為空。這可能是系統的非預期錯誤。"
},
"prompt_no_outputs": {
"desc": "工作流程中沒有包含任何輸出節點(例如:儲存圖像、預覽圖像),無法產生結果。"
},
"server_error_cloud": {
"desc": "伺服器發生非預期錯誤。請稍後再試。"
},
"server_error_local": {
"desc": "伺服器發生非預期錯誤。請檢查伺服器日誌。"
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "此節點在執行時發生錯誤。請檢查其輸入或嘗試其他設定。未扣除點數。",
"toastMessageLocal": "此節點在執行時發生錯誤。請檢查其輸入或嘗試其他設定。",
"toastTitle": "{nodeName} 執行失敗"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} 缺少必要的輸入:{inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "必要的輸入插槽沒有連接來源。",
"title": "缺少連接",
"toastMessage": "{nodeName} 缺少必要的輸入:{inputName}",
"toastTitle": "缺少必要輸入"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "您的帳戶無權使用此功能。",
"accessRestrictedTitle": "存取受限",
@@ -1751,7 +1713,6 @@
"reloadingModel": "重新載入模型中...",
"removeBackgroundImage": "移除背景圖片",
"resizeNodeMatchOutput": "調整節點以符合輸出",
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
"scene": "場景",
"showGrid": "顯示格線",
"showSkeleton": "顯示骨架",
@@ -2740,6 +2701,20 @@
"normal": "一般",
"parameters": "參數",
"pinned": "已釘選",
"promptErrors": {
"no_prompt": {
"desc": "傳送到伺服器的工作流程資料為空。這可能是系統發生了非預期的錯誤。"
},
"prompt_no_outputs": {
"desc": "工作流程中沒有任何輸出節點(例如:儲存圖像、預覽圖像),無法產生結果。"
},
"server_error_cloud": {
"desc": "伺服器發生非預期錯誤。請稍後再試。"
},
"server_error_local": {
"desc": "伺服器發生非預期錯誤。請檢查伺服器日誌。"
}
},
"properties": "屬性",
"removeFavorite": "取消收藏",
"resetAllParameters": "重設所有參數",

View File

@@ -911,44 +911,6 @@
"paused": "已暂停",
"resume": "恢复下载"
},
"errorCatalog": {
"fallbacks": {
"inputName": "未知输入",
"nodeName": "此节点"
},
"promptErrors": {
"no_prompt": {
"desc": "发送到服务器的工作流数据为空。这可能是系统的意外错误。"
},
"prompt_no_outputs": {
"desc": "工作流未包含任何输出节点(如保存图像、预览图像),无法生成结果。"
},
"server_error_cloud": {
"desc": "服务器遇到意外错误。请稍后再试。"
},
"server_error_local": {
"desc": "服务器遇到意外错误。请检查服务器日志。"
}
},
"runtimeErrors": {
"execution_failed": {
"itemLabel": "{nodeName}",
"toastMessageCloud": "此节点在执行过程中发生错误。请检查其输入或尝试其他配置。未扣除积分。",
"toastMessageLocal": "此节点在执行过程中发生错误。请检查其输入或尝试其他配置。",
"toastTitle": "{nodeName} 执行失败"
}
},
"validationErrors": {
"required_input_missing": {
"details": "{nodeName} 缺少必需的输入:{inputName}",
"itemLabel": "{nodeName} - {inputName}",
"message": "必需的输入插槽没有连接。",
"title": "缺少连接",
"toastMessage": "{nodeName} 缺少必需的输入:{inputName}",
"toastTitle": "缺少必需输入"
}
}
},
"errorDialog": {
"accessRestrictedMessage": "您的账户无权使用此功能。",
"accessRestrictedTitle": "访问受限",
@@ -1751,7 +1713,6 @@
"reloadingModel": "正在重新加载模型...",
"removeBackgroundImage": "移除背景图片",
"resizeNodeMatchOutput": "调整节点以匹配输出",
"retainViewOnReload": "模型重新加载时锁定相机视角",
"scene": "场景",
"showGrid": "显示网格",
"showSkeleton": "显示骨架",
@@ -2740,6 +2701,20 @@
"normal": "正常",
"parameters": "参数",
"pinned": "顶固",
"promptErrors": {
"no_prompt": {
"desc": "发送到服务器的工作流数据为空。这可能是一个意外的系统错误。"
},
"prompt_no_outputs": {
"desc": "工作流中没有包含任何输出节点(例如:保存图像、预览图像),无法生成结果。"
},
"server_error_cloud": {
"desc": "服务器遇到意外错误。请稍后再试。"
},
"server_error_local": {
"desc": "服务器遇到意外错误。请检查服务器日志。"
}
},
"properties": "属性",
"removeFavorite": "取消收藏",
"resetAllParameters": "重置所有参数",

View File

@@ -1,105 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useFlatOutputAssetsGrouped } from './useFlatOutputAssetsGrouped'
const mediaRef: Ref<AssetItem[]> = ref([])
vi.mock('./useFlatOutputAssets', () => ({
useFlatOutputAssets: () => ({
media: mediaRef,
loading: ref(false),
error: ref(null),
hasMore: ref(false),
isLoadingMore: ref(false),
fetchMediaList: vi.fn(),
refresh: vi.fn(),
loadMore: vi.fn()
})
}))
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-id',
name: 'output.png',
tags: ['output'],
...overrides
}
}
describe('useFlatOutputAssetsGrouped', () => {
it('collapses rows with the same job_id into a single representative', () => {
mediaRef.value = [
asset({ id: 'a', name: 'out1.png', job_id: 'job-1' }),
asset({ id: 'b', name: 'out2.png', job_id: 'job-1' }),
asset({ id: 'c', name: 'out3.png', job_id: 'job-1' }),
asset({ id: 'd', name: 'solo.png', job_id: 'job-2' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['a', 'd'])
})
it('exposes the group size as user_metadata.outputCount', () => {
mediaRef.value = [
asset({ id: 'a', job_id: 'job-1' }),
asset({ id: 'b', job_id: 'job-1' }),
asset({ id: 'c', job_id: 'job-1' }),
asset({ id: 'd', job_id: 'job-2' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value[0].user_metadata?.outputCount).toBe(3)
expect(media.value[0].user_metadata?.jobId).toBe('job-1')
expect(media.value[1].user_metadata?.outputCount).toBe(1)
})
it('falls back to prompt_id when job_id is absent (legacy)', () => {
mediaRef.value = [
asset({ id: 'a', prompt_id: 'job-legacy' }),
asset({ id: 'b', prompt_id: 'job-legacy' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value).toHaveLength(1)
expect(media.value[0].user_metadata?.jobId).toBe('job-legacy')
expect(media.value[0].user_metadata?.outputCount).toBe(2)
})
it('passes through rows that have neither job_id nor prompt_id', () => {
mediaRef.value = [asset({ id: 'orphan-a' }), asset({ id: 'orphan-b' })]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['orphan-a', 'orphan-b'])
})
it('preserves the order of the first occurrence per job_id', () => {
mediaRef.value = [
asset({ id: 'a', job_id: 'job-A' }),
asset({ id: 'b', job_id: 'job-B' }),
asset({ id: 'c', job_id: 'job-A' }),
asset({ id: 'd', job_id: 'job-C' })
]
const { media } = useFlatOutputAssetsGrouped()
expect(media.value.map((a) => a.id)).toEqual(['a', 'b', 'd'])
})
it('does not mutate the underlying assets', () => {
const original = asset({ id: 'a', job_id: 'job-1' })
mediaRef.value = [original, asset({ id: 'b', job_id: 'job-1' })]
const { media } = useFlatOutputAssetsGrouped()
void media.value
expect(original.user_metadata).toBeUndefined()
})
})

View File

@@ -1,58 +0,0 @@
import { computed } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { IAssetsProvider } from './IAssetsProvider'
import { useFlatOutputAssets } from './useFlatOutputAssets'
/**
* Cloud `/api/assets?include_tags=output` returns one row per individual output
* file. The asset sidebar's stack UX expects one card per job with an
* `outputCount` badge, so collapse rows that share a `job_id` into a single
* representative (the first occurrence — assets are returned newest-first).
*
* The siblings remain reachable through the existing stack-expand path via
* `resolveOutputAssetItems(metadata)`.
*/
export function useFlatOutputAssetsGrouped(): IAssetsProvider {
const inner = useFlatOutputAssets()
const media = computed(() => groupByJobId(inner.media.value))
return {
...inner,
media
}
}
function groupByJobId(assets: AssetItem[]): AssetItem[] {
const countsByJobId = new Map<string, number>()
for (const asset of assets) {
const jobId = asset.job_id ?? asset.prompt_id
if (!jobId) continue
countsByJobId.set(jobId, (countsByJobId.get(jobId) ?? 0) + 1)
}
const seenJobIds = new Set<string>()
const grouped: AssetItem[] = []
for (const asset of assets) {
const jobId = asset.job_id ?? asset.prompt_id ?? null
if (!jobId) {
grouped.push(asset)
continue
}
if (seenJobIds.has(jobId)) continue
seenJobIds.add(jobId)
const outputCount = countsByJobId.get(jobId) ?? 1
grouped.push({
...asset,
user_metadata: {
...asset.user_metadata,
jobId,
outputCount
}
})
}
return grouped
}

View File

@@ -56,7 +56,7 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
function getStackJobId(asset: AssetItem): string | null {
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.jobId ?? asset.job_id ?? null
return metadata?.jobId ?? null
}
function isStackExpanded(asset: AssetItem): boolean {

View File

@@ -2,14 +2,13 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { ResultItemImpl } from '@/stores/queueStore'
/**
* Metadata for output assets. Originates from the queue/history mapping but
* also surfaces on assets sourced directly from `/api/assets?include_tags=output`,
* which carry `jobId` only (no per-output `nodeId` / `subfolder`).
* Metadata for output assets from queue store
* Extends Record<string, unknown> for compatibility with AssetItem schema
*/
export interface OutputAssetMetadata extends Record<string, unknown> {
jobId: string
nodeId?: string | number
subfolder?: string
nodeId: string | number
subfolder: string
executionTimeInSeconds?: number
format?: string
workflow?: ComfyWorkflowJSON
@@ -17,11 +16,17 @@ export interface OutputAssetMetadata extends Record<string, unknown> {
allOutputs?: ResultItemImpl[]
}
/**
* Type guard to check if metadata is OutputAssetMetadata
*/
function isOutputAssetMetadata(
metadata: Record<string, unknown> | undefined
): metadata is OutputAssetMetadata {
if (!metadata) return false
return typeof metadata.jobId === 'string'
return (
typeof metadata.jobId === 'string' &&
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
)
}
/**

View File

@@ -18,10 +18,7 @@ const zAsset = z.object({
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
job_id: z.string().nullish(),
// Deprecated alias of job_id. See ingest-types Asset schema; both backends emit this during the L6 transition.
prompt_id: z.string().nullish()
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
const zAssetResponse = zListAssetsResponse

View File

@@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => {
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey for widgets without stable identity', () => {
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey when no nodeId is available at all', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
undefined,
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
@@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => {
expect(result).toHaveLength(1)
expect(result[0].hidden).toBe(false)
})
it('collapses duplicate normal widgets on the same node to one render', () => {
const colorA = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const colorB = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'ColorToRGBInt',
widgets: [colorA, colorB],
title: 'Color to RGB Int',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('color')
expect(result[0].renderKey).toBe('node:1:color:color:color')
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {

View File

@@ -129,11 +129,15 @@ export function getWidgetIdentity(
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const hostNodeIdRoot =
nodeId !== undefined && nodeId !== ''
? `node:${String(stripGraphPrefix(nodeId))}`
: undefined
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
: hostNodeIdRoot
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`

View File

@@ -28,6 +28,7 @@ const textMap: Record<ControlOptions, string | null> = {
<template>
<button
data-testid="value-control"
type="button"
:aria-label="t('widgets.valueControl.' + mode)"
:class="

View File

@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IColorWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
function createMockNode(): LGraphNode {
const widgets: IColorWidget[] = []
const addWidget = vi.fn(
(
type: string,
name: string,
value: string,
_callback: () => void,
options: IWidgetOptions
) => {
const widget = {
type,
name,
value,
options,
callback: _callback
} as unknown as IColorWidget
widgets.push(widget)
return widget
}
)
return { widgets, addWidget } as unknown as LGraphNode
}
const colorSpec: InputSpec = {
type: 'COLOR',
name: 'color',
default: '#ffffff',
socketless: true
}
describe('useColorWidget', () => {
it('reads the top-level default from the V2 spec', () => {
const node = createMockNode()
const widget = useColorWidget()(node, colorSpec)
expect(widget.value).toBe('#ffffff')
})
it('falls back to nested options.default when top-level default is absent', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color',
options: { default: '#abcdef' }
} as InputSpec)
expect(widget.value).toBe('#abcdef')
})
it('falls back to #000000 when no default is declared', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color'
} as InputSpec)
expect(widget.value).toBe('#000000')
})
it('returns the existing widget instead of creating a duplicate', () => {
const node = createMockNode()
const first = useColorWidget()(node, colorSpec)
const second = useColorWidget()(node, colorSpec)
expect(second).toBe(first)
expect(node.widgets).toHaveLength(1)
})
})

View File

@@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
const { name, options } = inputSpec as ColorInputSpec
const defaultValue = options?.default || '#000000'
const colorSpec = inputSpec as ColorInputSpec
const { name, options } = colorSpec
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
const existing = node.widgets?.find(
(w): w is IColorWidget => w.name === name && w.type === 'color'
)
if (existing) return existing
const widget = node.addWidget('color', name, defaultValue, () => {}, {
serialize: true