mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 00:14:55 +00:00
Compare commits
2 Commits
jaewon/fe-
...
core/1.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
badc97b982 | ||
|
|
67affd2075 |
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
retainViewOnReload?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
|
||||
@@ -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": "إعادة تعيين جميع المعلمات",
|
||||
|
||||
@@ -1952,7 +1952,6 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"retainViewOnReload": "Lock camera view across model reloads",
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "بازنشانی همه پارامترها",
|
||||
|
||||
@@ -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 s’agir d’une erreur système inattendue."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Le workflow 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."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Ce nœud a généré une erreur lors de l’exécution. Vérifiez ses entrées ou essayez une autre configuration. Aucun crédit n’a été déduit.",
|
||||
"toastMessageLocal": "Ce nœud a généré une erreur lors de l’exé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",
|
||||
|
||||
@@ -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": "すべてのパラメータをリセット",
|
||||
|
||||
@@ -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": "모든 매개변수 재설정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сбросить все параметры",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "重設所有參數",
|
||||
|
||||
@@ -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": "重置所有参数",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user