diff --git a/.env_example b/.env_example index 81caf27585..e2834bbf03 100644 --- a/.env_example +++ b/.env_example @@ -41,6 +41,10 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579 # Enable PostHog debug logging in the browser console. # VITE_POSTHOG_DEBUG=true +# Override staging comfy-api / comfy-platform base URLs. +# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org +# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org + # Sentry ENV vars replace with real ones for debugging # SENTRY_AUTH_TOKEN=private-token # get from sentry # SENTRY_ORG=comfy-org diff --git a/browser_tests/fixtures/helpers/SubgraphHelper.ts b/browser_tests/fixtures/helpers/SubgraphHelper.ts index b73a186a70..87eb556615 100644 --- a/browser_tests/fixtures/helpers/SubgraphHelper.ts +++ b/browser_tests/fixtures/helpers/SubgraphHelper.ts @@ -11,6 +11,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor' import { TestIds } from '@e2e/fixtures/selectors' +import type { Position, Size } from '@e2e/fixtures/types' import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils' import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils' @@ -241,6 +242,17 @@ export class SubgraphHelper { return new SubgraphSlotReference('output', slotName || '', this.comfyPage) } + async getInputBounds(): Promise { + return await this.comfyPage.page.evaluate(() => { + const graph = app!.canvas.graph as Subgraph + const inputNode = graph.inputNode + const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos) + const width = inputNode.size[0] * app!.canvas.ds.scale + const height = inputNode.size[1] * app!.canvas.ds.scale + return { x, y, width, height } + }) + } + /** * Connect a regular node output to a subgraph input. * This creates a new input slot on the subgraph if targetInputName is not provided. diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index ef1f9e09d8..91d98b054d 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -76,7 +76,15 @@ export const TestIds = { publishTabPanel: 'publish-tab-panel', apiSignin: 'api-signin-dialog', updatePassword: 'update-password-dialog', - cloudNotification: 'cloud-notification-dialog' + cloudNotification: 'cloud-notification-dialog', + openSharedWorkflow: 'open-shared-workflow-dialog', + openSharedWorkflowTitle: 'open-shared-workflow-title', + openSharedWorkflowClose: 'open-shared-workflow-close', + openSharedWorkflowErrorClose: 'open-shared-workflow-error-close', + openSharedWorkflowCancel: 'open-shared-workflow-cancel', + openSharedWorkflowOpenWithoutImporting: + 'open-shared-workflow-open-without-importing', + openSharedWorkflowConfirm: 'open-shared-workflow-confirm' }, keybindings: { presetMenu: 'keybinding-preset-menu' diff --git a/browser_tests/fixtures/sharedWorkflowImportFixture.ts b/browser_tests/fixtures/sharedWorkflowImportFixture.ts new file mode 100644 index 0000000000..6b6c2894fc --- /dev/null +++ b/browser_tests/fixtures/sharedWorkflowImportFixture.ts @@ -0,0 +1,250 @@ +import { test as base } from '@playwright/test' +import type { Page } from '@playwright/test' +import type { + Asset, + ImportPublishedAssetsRequest, + ListAssetsResponse +} from '@comfyorg/ingest-types' +import type { z } from 'zod' + +import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas' +import type { AssetInfo } from '@/schemas/apiSchema' + +type SharedWorkflowResponse = z.input + +export const sharedWorkflowImportScenario = { + shareId: 'shared-missing-media-e2e', + workflowId: 'shared-missing-media-workflow', + publishedAssetId: 'published-input-asset-1', + inputFileName: 'shared_imported_image.png' +} as const + +export type SharedWorkflowRequestEvent = + | 'import' + | 'input-assets-including-public-before-import' + | 'input-assets-including-public-after-import' + +export interface SharedWorkflowImportMocks { + resetAndStartRecording: () => void + getImportBody: () => ImportPublishedAssetsRequest | undefined + getRequestEvents: () => SharedWorkflowRequestEvent[] + waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise +} + +const defaultInputFileName = '00000000000000000000000Aexample.png' + +const sharedWorkflowAsset: AssetInfo = { + id: sharedWorkflowImportScenario.publishedAssetId, + name: sharedWorkflowImportScenario.inputFileName, + preview_url: '', + storage_url: '', + model: false, + public: false, + in_library: false +} + +const defaultInputAsset: Asset = { + id: 'default-input-asset', + name: defaultInputFileName, + asset_hash: defaultInputFileName, + size: 1_024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + last_access_time: '2026-05-01T00:00:00Z' +} + +const importedInputAsset: Asset = { + id: 'imported-input-asset', + name: sharedWorkflowImportScenario.inputFileName, + asset_hash: sharedWorkflowImportScenario.inputFileName, + size: 1_024, + mime_type: 'image/png', + tags: ['input'], + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + last_access_time: '2026-05-01T00:00:00Z' +} + +const sharedWorkflowResponse: SharedWorkflowResponse = { + share_id: sharedWorkflowImportScenario.shareId, + workflow_id: sharedWorkflowImportScenario.workflowId, + name: 'Shared Missing Media Workflow', + listed: true, + publish_time: '2026-05-01T00:00:00Z', + workflow_json: { + version: 0.4, + last_node_id: 10, + last_link_id: 0, + nodes: [ + { + id: 10, + type: 'LoadImage', + pos: [50, 200], + size: [315, 314], + flags: {}, + order: 0, + mode: 0, + inputs: [], + outputs: [ + { + name: 'IMAGE', + type: 'IMAGE', + links: null + }, + { + name: 'MASK', + type: 'MASK', + links: null + } + ], + properties: { + 'Node name for S&R': 'LoadImage' + }, + widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image'] + } + ], + links: [], + groups: [], + config: {}, + extra: { + ds: { + offset: [0, 0], + scale: 1 + } + } + }, + assets: [sharedWorkflowAsset] +} + +export const sharedWorkflowImportFixture = base.extend<{ + sharedWorkflowImportMocks: SharedWorkflowImportMocks +}>({ + sharedWorkflowImportMocks: async ({ page }, use) => { + const mocks = await mockSharedWorkflowImportFlow(page) + await use(mocks) + } +}) + +async function mockSharedWorkflowImportFlow( + page: Page +): Promise { + let isRecording = false + let importEndpointCalled = false + let importBody: ImportPublishedAssetsRequest | undefined + let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {} + let publicInclusiveInputAssetResponseAfterImport = new Promise( + (resolve) => { + resolvePublicInclusiveInputAssetResponseAfterImport = resolve + } + ) + const requestEvents: SharedWorkflowRequestEvent[] = [] + + function resetPublicInclusiveInputAssetResponseWaiter() { + publicInclusiveInputAssetResponseAfterImport = new Promise( + (resolve) => { + resolvePublicInclusiveInputAssetResponseAfterImport = resolve + } + ) + } + + function recordRequestEvent(event: SharedWorkflowRequestEvent) { + if (isRecording) requestEvents.push(event) + } + + await page.route( + `**/workflows/published/${sharedWorkflowImportScenario.shareId}`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(sharedWorkflowResponse) + }) + } + ) + + await page.route('**/api/assets/import', async (route) => { + recordRequestEvent('import') + importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest + importEndpointCalled = true + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}) + }) + }) + + // Excludes `/api/assets/import` so the specific route above + // remains isolated from the general asset listing mock. + await page.route(/\/api\/assets(?=\?|$)/, async (route) => { + const url = new URL(route.request().url()) + const includeTags = getTagParam(url, 'include_tags') + const isInputAssetRequest = includeTags.includes('input') + const includesPublicAssets = + url.searchParams.get('include_public') === 'true' + const isPublicInclusiveInputAssetRequest = + isInputAssetRequest && includesPublicAssets + const isAfterImportPublicInclusiveInputAssetRequest = + isPublicInclusiveInputAssetRequest && importEndpointCalled + + if (isPublicInclusiveInputAssetRequest) { + recordRequestEvent( + importEndpointCalled + ? 'input-assets-including-public-after-import' + : 'input-assets-including-public-before-import' + ) + } + + const allAssets = [ + defaultInputAsset, + ...(importEndpointCalled ? [importedInputAsset] : []) + ] + const assets = includeTags.length + ? allAssets.filter((asset) => + includeTags.every((tag) => asset.tags?.includes(tag)) + ) + : allAssets + + const response: ListAssetsResponse = { + assets, + total: assets.length, + has_more: false + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + + if (isAfterImportPublicInclusiveInputAssetRequest) { + resolvePublicInclusiveInputAssetResponseAfterImport() + } + }) + + return { + resetAndStartRecording: () => { + isRecording = true + importEndpointCalled = false + importBody = undefined + requestEvents.length = 0 + resetPublicInclusiveInputAssetResponseWaiter() + }, + getImportBody: () => importBody, + getRequestEvents: () => [...requestEvents], + waitForPublicInclusiveInputAssetResponseAfterImport: () => + publicInclusiveInputAssetResponseAfterImport + } +} + +function getTagParam(url: URL, key: string): string[] { + return ( + url.searchParams + .get(key) + ?.split(',') + .map((tag) => tag.trim()) + .filter(Boolean) ?? [] + ) +} diff --git a/browser_tests/tests/groupNode.spec.ts b/browser_tests/tests/groupNode.spec.ts index d002d83681..e38e4f105a 100644 --- a/browser_tests/tests/groupNode.spec.ts +++ b/browser_tests/tests/groupNode.spec.ts @@ -361,3 +361,15 @@ test.describe('Group Node', { tag: '@node' }, () => { }) }) }) + +test('Convert to subgraph unpacks the group Node @vue-nodes', async ({ + comfyPage +}) => { + await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node') + await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click() + await comfyPage.page.keyboard.press('Control+Shift+e') + await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible() + + await comfyPage.vueNodes.enterSubgraph() + await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2) +}) diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png index e0f46d3791..df2141ad73 100644 Binary files a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png differ diff --git a/browser_tests/tests/priceBadge.spec.ts b/browser_tests/tests/priceBadge.spec.ts new file mode 100644 index 0000000000..a9f8a8c4cb --- /dev/null +++ b/browser_tests/tests/priceBadge.spec.ts @@ -0,0 +1,41 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '@e2e/fixtures/ComfyPage' + +test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => { + const apiNodeName = 'Node With Price Badge' + await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)') + + const priceBadge = comfyPage.page.locator('.lg-node-header i + span') + const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName) + + await comfyPage.menu.topbar.newWorkflowButton.click() + await comfyPage.nextFrame() + + await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 }) + await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName) + await expect(comfyPage.searchBox.input).toBeHidden() + await expect(apiNode, 'Add partner node').toBeVisible() + await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible() + + await comfyPage.contextMenu + .openForVueNode(apiNode) + .then((m) => m.clickMenuItemExact('Convert to Subgraph')) + const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph') + await expect(subgraphNode, 'Convert to Subgraph').toBeVisible() + + const nodePrice = subgraphNode.locator(priceBadge) + await expect(nodePrice, 'subgraphNode has price badge').toBeVisible() + const initialPrice = Number(await nodePrice.innerText()) + + await comfyPage.subgraph.editor.togglePromotion(subgraphNode, { + nodeName: apiNodeName, + widgetName: 'price', + toState: true + }) + await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x') + await expect(nodePrice, 'Price is reactive').toHaveText( + String(initialPrice * 2) + ) +}) diff --git a/browser_tests/tests/sharedWorkflowMissingMedia.spec.ts b/browser_tests/tests/sharedWorkflowMissingMedia.spec.ts new file mode 100644 index 0000000000..6a1a14801b --- /dev/null +++ b/browser_tests/tests/sharedWorkflowMissingMedia.spec.ts @@ -0,0 +1,147 @@ +import { expect, mergeTests } from '@playwright/test' + +import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' +import { + sharedWorkflowImportFixture, + sharedWorkflowImportScenario +} from '@e2e/fixtures/sharedWorkflowImportFixture' +import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture' +import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper' +import type { WorkspaceStore } from '@e2e/types/globals' + +const IMPORT_ORDER_TIMEOUT_MS = 5_000 + +async function expectImportPrecedesPublicInclusiveInputAssetScan( + mocks: SharedWorkflowImportMocks +): Promise { + await expect(async () => { + const events = mocks.getRequestEvents() + const importIndex = events.indexOf('import') + const afterImportIndex = events.indexOf( + 'input-assets-including-public-after-import' + ) + + expect( + events, + 'public-inclusive input assets must not be scanned before import' + ).not.toContain('input-assets-including-public-before-import') + expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0) + expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan( + importIndex + ) + }).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS }) +} + +async function getCachedMissingMediaWarningNames( + comfyPage: ComfyPage +): Promise { + return await comfyPage.page.evaluate(() => { + const workflow = (window.app!.extensionManager as WorkspaceStore).workflow + .activeWorkflow + if (!workflow) return null + + return ( + workflow.pendingWarnings?.missingMediaCandidates?.map( + (candidate) => candidate.name + ) ?? [] + ) + }) +} + +async function expectNoMissingMediaAfterPublicInclusiveAssetScan( + comfyPage: ComfyPage, + mocks: SharedWorkflowImportMocks +): Promise { + await mocks.waitForPublicInclusiveInputAssetResponseAfterImport() + await comfyPage.nextFrame() + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeHidden() + await expect + .poll(() => getCachedMissingMediaWarningNames(comfyPage)) + .toEqual([]) +} + +async function openPanelAndExpectNoMissingMedia( + comfyPage: ComfyPage +): Promise { + const page = comfyPage.page + const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay) + await expect(errorOverlay).toBeHidden() + + const panel = new PropertiesPanelHelper(page) + await panel.open(comfyPage.actionbar.propertiesButton) + await expect( + panel.root.getByTestId(TestIds.propertiesPanel.errorsTab) + ).toBeHidden() + await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount( + 0 + ) +} + +const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture) + +test.describe('Shared workflow missing media', { tag: '@cloud' }, () => { + // Missing media only surfaces the overlay when the Errors tab is enabled + // (src/stores/executionErrorStore.ts). + test.use({ + initialSettings: { + 'Comfy.RightSidePanel.ShowErrorsTab': true + } + }) + + test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => { + sharedWorkflowImportMocks.resetAndStartRecording() + await comfyPage.setup({ + clearStorage: false, + url: `/?share=${sharedWorkflowImportScenario.shareId}` + }) + }) + + test('imports shared media before loading workflow so missing media is not surfaced', async ({ + comfyPage, + sharedWorkflowImportMocks + }) => { + const { page } = comfyPage + + const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow) + await expect( + dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle) + ).toBeVisible() + + await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click() + + await expect + .poll(() => + page.evaluate(() => + window.app!.graph.nodes.map((node) => ({ + type: node.type, + value: node.widgets?.[0]?.value + })) + ) + ) + .toEqual([ + { + type: 'LoadImage', + value: sharedWorkflowImportScenario.inputFileName + } + ]) + await expectImportPrecedesPublicInclusiveInputAssetScan( + sharedWorkflowImportMocks + ) + await expectNoMissingMediaAfterPublicInclusiveAssetScan( + comfyPage, + sharedWorkflowImportMocks + ) + + expect(sharedWorkflowImportMocks.getImportBody()).toEqual({ + published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId], + share_id: sharedWorkflowImportScenario.shareId + }) + expect(new URL(page.url()).searchParams.has('share')).toBe(false) + await openPanelAndExpectNoMissingMedia(comfyPage) + }) +}) diff --git a/browser_tests/tests/subgraph/subgraphSlots.spec.ts b/browser_tests/tests/subgraph/subgraphSlots.spec.ts index c6ac6f119e..999fa64c58 100644 --- a/browser_tests/tests/subgraph/subgraphSlots.spec.ts +++ b/browser_tests/tests/subgraph/subgraphSlots.spec.ts @@ -632,3 +632,72 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => { }) }) }) + +test( + 'link interactions', + { tag: ['@vue-nodes', '@subgraph'] }, + async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph') + await comfyPage.vueNodes.enterSubgraph('2') + + const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler') + const seedSlot = ksampler.getSlot('seed') + const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed') + + await test.step('Make second INT typed connection', async () => { + const toPos = await seedIOSlot.getOpenSlotPosition() + await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos }) + const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot) + await expect.poll(isConnected).toBe(true) + }) + + const stepsSlot = ksampler.getSlot('steps') + + await test.step('Node -> I/O hover effect', async () => { + await stepsSlot.hover() + await stepsSlot.click({ trial: true }) + await comfyPage.page.mouse.down() + await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() }) + + const rawClip = await comfyPage.subgraph.getInputBounds() + const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip) + const clip = { ...rawClip, ...absolutePos } + await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', { + clip + }) + + //cancel link operation + await stepsSlot.hover() + await comfyPage.page.mouse.up() + }) + + await ksampler.title.hover() + + const slotParent = stepsSlot.locator('../..') + await expect(slotParent, 'unconnected slot is hidden').toHaveCSS( + 'opacity', + '0' + ) + + await test.step('Connect I/O to node with snap', async () => { + const hasSnap = () => + comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos) + expect(await hasSnap()).toBe(false) + + const emptySlotPos = await seedIOSlot.getOpenSlotPosition() + await comfyPage.canvas.hover({ position: emptySlotPos }) + await comfyPage.page.mouse.down() + await stepsSlot.hover() + await expect.poll(hasSnap).toBe(true) + await comfyPage.page.mouse.up() + + //move hover off the slot + await ksampler.title.hover() + }) + + await expect(slotParent, 'connected slot is visible').not.toHaveCSS( + 'opacity', + '0' + ) + } +) diff --git a/browser_tests/tests/subgraph/subgraphSlots.spec.ts-snapshots/vue-io-highlight-chromium-linux.png b/browser_tests/tests/subgraph/subgraphSlots.spec.ts-snapshots/vue-io-highlight-chromium-linux.png new file mode 100644 index 0000000000..f3d212eaeb Binary files /dev/null and b/browser_tests/tests/subgraph/subgraphSlots.spec.ts-snapshots/vue-io-highlight-chromium-linux.png differ diff --git a/browser_tests/tests/templates.spec.ts b/browser_tests/tests/templates.spec.ts index 06b53a9bbc..fecd1dd78c 100644 --- a/browser_tests/tests/templates.spec.ts +++ b/browser_tests/tests/templates.spec.ts @@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => { }) await expect( - comfyPage.page.getByRole('heading', { name: 'Open shared workflow' }) + comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle) ).toBeVisible() await expect(comfyPage.templates.content).toBeHidden() diff --git a/package.json b/package.json index edf9bc71a8..cb7a7a6577 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.45.10", + "version": "1.45.11", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", @@ -60,6 +60,7 @@ "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@comfyorg/comfyui-electron-types": "catalog:", "@comfyorg/design-system": "workspace:*", + "@comfyorg/fbx-exporter-three": "^1.0.1", "@comfyorg/object-info-parser": "workspace:*", "@comfyorg/registry-types": "workspace:*", "@comfyorg/shared-frontend-utils": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89715d58a6..712c07c9b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -437,6 +437,9 @@ importers: '@comfyorg/design-system': specifier: workspace:* version: link:packages/design-system + '@comfyorg/fbx-exporter-three': + specifier: ^1.0.1 + version: 1.0.1(@types/three@0.170.0)(three@0.170.0) '@comfyorg/object-info-parser': specifier: workspace:* version: link:packages/object-info-parser @@ -1790,6 +1793,16 @@ packages: '@comfyorg/comfyui-electron-types@0.6.2': resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==} + '@comfyorg/fbx-exporter-three@1.0.1': + resolution: {integrity: sha512-fQ1zBsgmmwfio6iEi91hRiFCr946yEgqR2DGh/UMismaLyUohiKGOJL/OnJQnW3+yne/PXxVoYgcortyumsO5w==} + engines: {node: '>=18'} + peerDependencies: + '@types/three': '>=0.160.0' + three: '>=0.160.0' + peerDependenciesMeta: + '@types/three': + optional: true + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -4658,6 +4671,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -9870,8 +9884,8 @@ packages: vue-component-type-helpers@3.2.6: resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} - vue-component-type-helpers@3.2.9: - resolution: {integrity: sha512-S3BiWYaLSzHxTpln665ELSrMR9UYmrIDUmhik7nVZxmJjTKL2/a+ew1hvGxksKelivm0ujjWfG1fYOiU/2e8rA==} + vue-component-type-helpers@3.3.0: + resolution: {integrity: sha512-vwR8DDsBysI9NWXa0okPFpCcW+BUC3sPTuLBNo1faMzw4QWMFd+3/lFYFu29ZN0q+8UReXWJHEYesC9dcXYCLg==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -10481,7 +10495,7 @@ snapshots: '@astrojs/yaml2ts@0.2.3': dependencies: - yaml: 2.8.2 + yaml: 2.9.0 '@atlaskit/pragmatic-drag-and-drop@1.3.1': dependencies: @@ -11228,6 +11242,13 @@ snapshots: '@comfyorg/comfyui-electron-types@0.6.2': {} + '@comfyorg/fbx-exporter-three@1.0.1(@types/three@0.170.0)(three@0.170.0)': + dependencies: + fflate: 0.8.2 + three: 0.170.0 + optionalDependencies: + '@types/three': 0.170.0 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -13397,7 +13418,7 @@ snapshots: storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) type-fest: 2.19.0 vue: 3.5.13(typescript@5.9.3) - vue-component-type-helpers: 3.2.9 + vue-component-type-helpers: 3.3.0 '@swc/helpers@0.5.17': dependencies: @@ -13951,7 +13972,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -14840,7 +14861,7 @@ snapshots: picomatch: 4.0.3 prompts: 2.4.2 rehype: 13.0.2 - semver: 7.7.4 + semver: 7.8.0 shiki: 3.23.0 smol-toml: 1.6.1 svgo: 4.0.0 @@ -15660,7 +15681,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.4 + semver: 7.8.0 eight-colors@1.3.3: {} @@ -18313,7 +18334,7 @@ snapshots: ky: 1.14.3 registry-auth-token: 5.1.1 registry-url: 6.0.1 - semver: 7.7.4 + semver: 7.8.0 package-manager-detector@1.6.0: {} @@ -19207,7 +19228,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -19808,7 +19829,7 @@ snapshots: typescript-auto-import-cache@0.3.6: dependencies: - semver: 7.7.4 + semver: 7.8.0 typescript-eslint@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: @@ -20439,7 +20460,7 @@ snapshots: volar-service-typescript@0.0.70(@volar/language-service@2.4.28): dependencies: path-browserify: 1.0.1 - semver: 7.7.4 + semver: 7.8.0 typescript-auto-import-cache: 0.3.6 vscode-languageserver-textdocument: 1.0.12 vscode-nls: 5.2.0 @@ -20508,7 +20529,7 @@ snapshots: vue-component-type-helpers@3.2.6: {} - vue-component-type-helpers@3.2.9: {} + vue-component-type-helpers@3.3.0: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)): dependencies: diff --git a/src/components/load3d/controls/ExportControls.vue b/src/components/load3d/controls/ExportControls.vue index 3957cd12b8..f44025b5e1 100644 --- a/src/components/load3d/controls/ExportControls.vue +++ b/src/components/load3d/controls/ExportControls.vue @@ -48,7 +48,8 @@ const showExportFormats = ref(false) const exportFormats = [ { label: 'GLB', value: 'glb' }, { label: 'OBJ', value: 'obj' }, - { label: 'STL', value: 'stl' } + { label: 'STL', value: 'stl' }, + { label: 'FBX', value: 'fbx' } ] function toggleExportFormats() { diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts index 131358860d..43fc98a695 100644 --- a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts +++ b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts @@ -81,12 +81,12 @@ function renderComponent(onExportModel?: (format: string) => void) { } describe('ViewerExportControls', () => { - it('renders all three export format options', () => { + it('renders all four export format options', () => { renderComponent() const select = screen.getByRole('combobox') as HTMLSelectElement const optionValues = Array.from(select.options).map((o) => o.value) - expect(optionValues).toEqual(['glb', 'obj', 'stl']) + expect(optionValues).toEqual(['glb', 'obj', 'stl', 'fbx']) }) it('defaults the export format to obj', () => { diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.vue b/src/components/load3d/controls/viewer/ViewerExportControls.vue index eb719ec962..054a424702 100644 --- a/src/components/load3d/controls/viewer/ViewerExportControls.vue +++ b/src/components/load3d/controls/viewer/ViewerExportControls.vue @@ -42,7 +42,8 @@ const emit = defineEmits<{ const exportFormats = [ { label: 'GLB', value: 'glb' }, { label: 'OBJ', value: 'obj' }, - { label: 'STL', value: 'stl' } + { label: 'STL', value: 'stl' }, + { label: 'FBX', value: 'fbx' } ] const exportFormat = ref('obj') diff --git a/src/components/node/CreditBadge.vue b/src/components/node/CreditBadge.vue index dc109e354f..a306eebafc 100644 --- a/src/components/node/CreditBadge.vue +++ b/src/components/node/CreditBadge.vue @@ -12,7 +12,7 @@ diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index efcf059518..b4ce1b9cae 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -48,6 +48,8 @@ export interface WidgetSlotMetadata { type: string } +type Badges = (LGraphBadge | (() => LGraphBadge))[] + /** * Minimal render-specific widget data extracted from LiteGraph widgets. * Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore. @@ -107,7 +109,7 @@ export interface VueNodeData { title: string type: string apiNode?: boolean - badges?: (LGraphBadge | (() => LGraphBadge))[] + badges?: Badges bgcolor?: string color?: string flags?: { @@ -786,6 +788,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { showAdvanced: Boolean(propertyEvent.newValue) }) break + case 'badges': + vueNodeData.set(nodeId, { + ...currentData, + badges: propertyEvent.newValue as Badges + }) + break } } }, diff --git a/src/composables/node/useNodePricing.test.ts b/src/composables/node/useNodePricing.test.ts index 950d2e44cb..7533b7b770 100644 --- a/src/composables/node/useNodePricing.test.ts +++ b/src/composables/node/useNodePricing.test.ts @@ -625,9 +625,9 @@ describe('useNodePricing', () => { getNodeDisplayPrice(node) await new Promise((r) => setTimeout(r, 50)) - // VueNodes path bumps per-node ref instead of the global tick. + // VueNodes path bumps per-node ref and the global tick. expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore) - expect(pricingRevision.value).toBe(tickBefore) + expect(pricingRevision.value).toBeGreaterThan(tickBefore) } finally { LiteGraph.vueNodesMode = false } diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 634d94d0f6..5d5ed7668e 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -509,10 +509,8 @@ const scheduleEvaluation = ( if (LiteGraph.vueNodesMode) { // VueNodes mode: bump per-node revision (only this node re-renders) getNodeRevisionRef(node.id).value++ - } else { - // Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas - pricingTick.value++ } + pricingTick.value++ }) inflight.set(node, { sig, promise }) diff --git a/src/composables/node/usePriceBadge.ts b/src/composables/node/usePriceBadge.ts index 489e412849..5febb21b16 100644 --- a/src/composables/node/usePriceBadge.ts +++ b/src/composables/node/usePriceBadge.ts @@ -18,6 +18,15 @@ export const usePriceBadge = () => { } else { node.badges.push(...newBadges) } + const graph = node.graph + if (!graph) return + graph.trigger('node:property:changed', { + type: 'node:property:changed', + nodeId: node.id, + property: 'badges', + oldValue: node.badges, + newValue: node.badges + }) } function collectCreditsBadges( graph: LGraph, diff --git a/src/config/comfyApi.ts b/src/config/comfyApi.ts index 8efd651bfb..b6a8e375af 100644 --- a/src/config/comfyApi.ts +++ b/src/config/comfyApi.ts @@ -12,11 +12,12 @@ const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org' const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__ ? PROD_API_BASE_URL - : STAGING_API_BASE_URL + : (import.meta.env.VITE_STAGING_API_BASE_URL ?? STAGING_API_BASE_URL) const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__ ? PROD_PLATFORM_BASE_URL - : STAGING_PLATFORM_BASE_URL + : (import.meta.env.VITE_STAGING_PLATFORM_BASE_URL ?? + STAGING_PLATFORM_BASE_URL) export function getComfyApiBaseUrl(): string { if (!isCloud) { diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts index dd0d2bb1a3..b26fbe9dc9 100644 --- a/src/extensions/core/load3d/Load3d.test.ts +++ b/src/extensions/core/load3d/Load3d.test.ts @@ -4,6 +4,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Load3d from '@/extensions/core/load3d/Load3d' import type { GizmoMode } from '@/extensions/core/load3d/interfaces' +const { + cloneSkinnedMock, + exportGLBMock, + exportOBJMock, + exportSTLMock, + exportFBXMock +} = vi.hoisted(() => ({ + cloneSkinnedMock: vi.fn(), + exportGLBMock: vi.fn(), + exportOBJMock: vi.fn(), + exportSTLMock: vi.fn(), + exportFBXMock: vi.fn() +})) + +vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({ + clone: cloneSkinnedMock +})) + +vi.mock('@/extensions/core/load3d/ModelExporter', () => ({ + ModelExporter: { + exportGLB: exportGLBMock, + exportOBJ: exportOBJMock, + exportSTL: exportSTLMock, + exportFBX: exportFBXMock + } +})) + type GizmoStub = { setEnabled: ReturnType setMode: ReturnType @@ -849,4 +876,189 @@ describe('Load3d', () => { expect(ctx.forceRender).toHaveBeenCalled() }) }) + + describe('exportModel', () => { + beforeEach(() => { + cloneSkinnedMock.mockReset() + exportGLBMock.mockReset() + exportOBJMock.mockReset() + exportSTLMock.mockReset() + exportFBXMock.mockReset() + }) + + function setupForExport(overrides: { + currentModel: THREE.Object3D | null + originalModel?: THREE.Object3D | null + originalFileName?: string | null + originalURL?: string | null + }) { + Object.assign(ctx.load3d, { + modelManager: { + ...ctx.modelManager, + currentModel: overrides.currentModel, + originalModel: overrides.originalModel ?? null, + originalFileName: overrides.originalFileName ?? 'cube', + originalURL: overrides.originalURL ?? null + } + }) + } + + it('throws when no model is loaded', async () => { + setupForExport({ currentModel: null }) + + await expect(ctx.load3d.exportModel('fbx')).rejects.toThrow( + 'No model to export' + ) + }) + + it('zeroes the source transform during export, then restores it', async () => { + const model = new THREE.Object3D() + model.position.set(5, 6, 7) + model.rotation.set(0.1, 0.2, 0.3) + model.scale.set(2, 3, 4) + + let transformDuringExport: { + position: THREE.Vector3 + rotation: THREE.Euler + scale: THREE.Vector3 + } | null = null + exportGLBMock.mockImplementation(async () => { + transformDuringExport = { + position: model.position.clone(), + rotation: model.rotation.clone(), + scale: model.scale.clone() + } + }) + + setupForExport({ currentModel: model }) + + await ctx.load3d.exportModel('glb') + + expect(transformDuringExport!.position.x).toBe(0) + expect(transformDuringExport!.position.y).toBe(0) + expect(transformDuringExport!.position.z).toBe(0) + expect(transformDuringExport!.rotation.x).toBe(0) + expect(transformDuringExport!.scale.x).toBe(1) + expect(transformDuringExport!.scale.y).toBe(1) + expect(transformDuringExport!.scale.z).toBe(1) + + expect(model.position.x).toBe(5) + expect(model.position.y).toBe(6) + expect(model.position.z).toBe(7) + expect(model.rotation.x).toBeCloseTo(0.1) + expect(model.scale.x).toBe(2) + expect(model.scale.z).toBe(4) + }) + + it('restores the source transform even when the exporter throws', async () => { + const model = new THREE.Object3D() + model.position.set(3, 4, 5) + model.scale.set(7, 7, 7) + exportGLBMock.mockRejectedValueOnce(new Error('boom')) + + setupForExport({ currentModel: model }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(ctx.load3d.exportModel('glb')).rejects.toThrow('boom') + + expect(model.position.x).toBe(3) + expect(model.scale.x).toBe(7) + }) + + it('routes fbx through SkeletonUtils.clone and attaches the source animations', async () => { + const model = new THREE.Object3D() + const clip = { name: 'walk' } as unknown as THREE.AnimationClip + model.animations = [clip] + const cloned = new THREE.Object3D() + cloneSkinnedMock.mockReturnValueOnce(cloned) + + setupForExport({ + currentModel: model, + originalFileName: 'rig', + originalURL: 'http://example.com/api/view?filename=rig.fbx' + }) + + await ctx.load3d.exportModel('fbx') + + expect(cloneSkinnedMock).toHaveBeenCalledWith(model) + expect(exportFBXMock).toHaveBeenCalledOnce() + const [exportedModel, filename, originalURL] = exportFBXMock.mock + .calls[0] as [ + THREE.Object3D & { animations: THREE.AnimationClip[] }, + string, + string | null + ] + expect(exportedModel).toBe(cloned) + expect(exportedModel.animations).toEqual([clip]) + expect(filename).toBe('rig.fbx') + expect(originalURL).toBe('http://example.com/api/view?filename=rig.fbx') + }) + + it('falls back to originalModel.animations when the working model has none (fbx)', async () => { + const model = new THREE.Object3D() + const original = new THREE.Object3D() + const clip = { name: 'idle' } as unknown as THREE.AnimationClip + original.animations = [clip] + const cloned = new THREE.Object3D() + cloneSkinnedMock.mockReturnValueOnce(cloned) + + setupForExport({ currentModel: model, originalModel: original }) + + await ctx.load3d.exportModel('fbx') + + const [exportedModel] = exportFBXMock.mock.calls[0] as [ + THREE.Object3D & { animations: THREE.AnimationClip[] } + ] + expect(exportedModel.animations).toEqual([clip]) + }) + + it('uses Object3D.clone (not SkeletonUtils) for non-fbx formats', async () => { + const model = new THREE.Object3D() + const cloneSpy = vi.spyOn(model, 'clone') + + setupForExport({ + currentModel: model, + originalFileName: 'cube', + originalURL: null + }) + + await ctx.load3d.exportModel('glb') + + expect(cloneSpy).toHaveBeenCalled() + expect(cloneSkinnedMock).not.toHaveBeenCalled() + expect(exportGLBMock).toHaveBeenCalledOnce() + const [, filename] = exportGLBMock.mock.calls[0] as [ + unknown, + string, + unknown + ] + expect(filename).toBe('cube.glb') + }) + + it('emits exportLoadingStart and exportLoadingEnd around the export', async () => { + const model = new THREE.Object3D() + setupForExport({ currentModel: model }) + + await ctx.load3d.exportModel('glb') + + expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith( + 'exportLoadingStart', + 'Exporting as GLB...' + ) + expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith( + 'exportLoadingEnd', + null + ) + }) + + it('throws on unsupported format', async () => { + const model = new THREE.Object3D() + setupForExport({ currentModel: model }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(ctx.load3d.exportModel('xyz')).rejects.toThrow( + 'Unsupported export format: xyz' + ) + }) + }) }) diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 7915a798ae..552a919431 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -1,4 +1,5 @@ import * as THREE from 'three' +import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js' import type { AnimationManager } from './AnimationManager' import type { CameraManager } from './CameraManager' @@ -344,8 +345,30 @@ class Load3d { const exportMessage = `Exporting as ${format.toUpperCase()}...` this.eventManager.emitEvent('exportLoadingStart', exportMessage) + const source = this.modelManager.currentModel + const savedPos = source.position.clone() + const savedRot = source.rotation.clone() + const savedScale = source.scale.clone() + source.position.set(0, 0, 0) + source.rotation.set(0, 0, 0) + source.scale.set(1, 1, 1) + source.updateMatrixWorld(true) + try { - const model = this.modelManager.currentModel.clone() + const original = this.modelManager.originalModel + const clipsFromOriginal = + original && + 'animations' in original && + Array.isArray(original.animations) + ? original.animations + : [] + const clips = source.animations?.length + ? source.animations + : clipsFromOriginal + const model = + format === 'fbx' + ? Object.assign(cloneSkinned(source), { animations: clips }) + : source.clone() const originalFileName = this.modelManager.originalFileName || 'model' const filename = `${originalFileName}.${format}` @@ -364,6 +387,9 @@ class Load3d { case 'stl': ;(await ModelExporter.exportSTL(model, filename), originalURL) break + case 'fbx': + await ModelExporter.exportFBX(model, filename, originalURL) + break default: throw new Error(`Unsupported export format: ${format}`) } @@ -373,6 +399,10 @@ class Load3d { console.error(`Error exporting model as ${format}:`, error) throw error } finally { + source.position.copy(savedPos) + source.rotation.copy(savedRot) + source.scale.copy(savedScale) + source.updateMatrixWorld(true) this.eventManager.emitEvent('exportLoadingEnd', null) } } diff --git a/src/extensions/core/load3d/ModelExporter.test.ts b/src/extensions/core/load3d/ModelExporter.test.ts index a961d5125b..7809bafca5 100644 --- a/src/extensions/core/load3d/ModelExporter.test.ts +++ b/src/extensions/core/load3d/ModelExporter.test.ts @@ -8,13 +8,15 @@ const { addAlertMock, gltfParseMock, objParseMock, - stlParseMock + stlParseMock, + fbxParseAsyncMock } = vi.hoisted(() => ({ downloadBlobMock: vi.fn(), addAlertMock: vi.fn(), gltfParseMock: vi.fn(), objParseMock: vi.fn(), - stlParseMock: vi.fn() + stlParseMock: vi.fn(), + fbxParseAsyncMock: vi.fn() })) vi.mock('@/base/common/downloadUtil', () => ({ @@ -48,6 +50,12 @@ vi.mock('three/examples/jsm/exporters/STLExporter', () => ({ } })) +vi.mock('@comfyorg/fbx-exporter-three', () => ({ + FBXExporter: class { + parseAsync = fbxParseAsyncMock + } +})) + describe('ModelExporter', () => { beforeEach(() => { vi.clearAllMocks() @@ -125,7 +133,9 @@ describe('ModelExporter', () => { const blob = new Blob(['x']) vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + vi + .fn() + .mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) ) await ModelExporter.downloadFromURL( @@ -149,6 +159,27 @@ describe('ModelExporter', () => { ) vi.unstubAllGlobals() }) + + it('rethrows and shows a toast alert when the response status is not ok', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + blob: () => Promise.resolve(new Blob(['x'])) + }) + ) + + await expect( + ModelExporter.downloadFromURL('http://example.com/cube.glb', 'cube.glb') + ).rejects.toThrow('HTTP 404') + expect(downloadBlobMock).not.toHaveBeenCalled() + expect(addAlertMock).toHaveBeenCalledWith( + 'toastMessages.failedToDownloadFile' + ) + vi.unstubAllGlobals() + }) }) describe('exportGLB', () => { @@ -156,7 +187,9 @@ describe('ModelExporter', () => { const blob = new Blob(['x']) vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + vi + .fn() + .mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) ) const model = new THREE.Object3D() @@ -214,7 +247,9 @@ describe('ModelExporter', () => { const blob = new Blob(['x']) vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + vi + .fn() + .mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) ) await ModelExporter.exportOBJ( @@ -260,7 +295,9 @@ describe('ModelExporter', () => { const blob = new Blob(['x']) vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + vi + .fn() + .mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) ) await ModelExporter.exportSTL( @@ -300,4 +337,51 @@ describe('ModelExporter', () => { ) }) }) + + describe('exportFBX', () => { + it('uses the direct-URL fast path for matching .fbx URLs', async () => { + const blob = new Blob(['x']) + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }) + ) + + await ModelExporter.exportFBX( + new THREE.Object3D(), + 'out.fbx', + 'http://example.com/api/view?filename=src.fbx' + ) + + expect(downloadBlobMock).toHaveBeenCalledWith('out.fbx', blob) + expect(fbxParseAsyncMock).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('serializes via FBXExporter and downloads as binary when there is no direct URL', async () => { + const bytes = new Uint8Array([0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61]) + fbxParseAsyncMock.mockResolvedValue(bytes) + + const promise = ModelExporter.exportFBX(new THREE.Object3D(), 'out.fbx') + await vi.runAllTimersAsync() + await promise + + expect(fbxParseAsyncMock).toHaveBeenCalled() + expect(downloadBlobMock).toHaveBeenCalledWith('out.fbx', expect.any(Blob)) + }) + + it('alerts and rethrows when FBXExporter throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + fbxParseAsyncMock.mockRejectedValue(new Error('fbx fail')) + + const promise = ModelExporter.exportFBX(new THREE.Object3D(), 'out.fbx') + const assertion = expect(promise).rejects.toThrow('fbx fail') + await vi.runAllTimersAsync() + await assertion + expect(addAlertMock).toHaveBeenCalledWith( + 'toastMessages.failedToExportModel:{"format":"FBX"}' + ) + }) + }) }) diff --git a/src/extensions/core/load3d/ModelExporter.ts b/src/extensions/core/load3d/ModelExporter.ts index f3ff3a443a..5929d09b46 100644 --- a/src/extensions/core/load3d/ModelExporter.ts +++ b/src/extensions/core/load3d/ModelExporter.ts @@ -1,3 +1,4 @@ +import { FBXExporter } from '@comfyorg/fbx-exporter-three' import * as THREE from 'three' import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter' @@ -38,6 +39,9 @@ export class ModelExporter { ): Promise { try { const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to download file (HTTP ${response.status})`) + } const blob = await response.blob() downloadBlob(desiredFilename, blob) } catch (error) { @@ -116,6 +120,41 @@ export class ModelExporter { } } + static async exportFBX( + model: THREE.Object3D, + filename: string = 'model.fbx', + originalURL?: string | null + ): Promise { + if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'fbx')) { + return ModelExporter.downloadFromURL(originalURL, filename) + } + + const exporter = new FBXExporter() + + try { + await new Promise((resolve) => setTimeout(resolve, 50)) + + const bytes = await exporter.parseAsync(model) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // FBXExporter returns Uint8Array — wrap into ArrayBuffer for download. + ModelExporter.saveArrayBuffer( + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer, + filename + ) + } catch (error) { + console.error('Error exporting FBX:', error) + useToastStore().addAlert( + t('toastMessages.failedToExportModel', { format: 'FBX' }) + ) + throw error + } + } + static async exportSTL( model: THREE.Object3D, filename: string = 'model.stl', diff --git a/src/extensions/core/load3d/exportMenuHelper.test.ts b/src/extensions/core/load3d/exportMenuHelper.test.ts index 2b0722d2ba..06945ac487 100644 --- a/src/extensions/core/load3d/exportMenuHelper.test.ts +++ b/src/extensions/core/load3d/exportMenuHelper.test.ts @@ -76,7 +76,8 @@ describe('createExportMenuItems', () => { expect(submenuOptions.map((o: { content: string }) => o.content)).toEqual([ 'GLB', 'OBJ', - 'STL' + 'STL', + 'FBX' ]) }) diff --git a/src/extensions/core/load3d/exportMenuHelper.ts b/src/extensions/core/load3d/exportMenuHelper.ts index d87146cc3a..f9c24f2371 100644 --- a/src/extensions/core/load3d/exportMenuHelper.ts +++ b/src/extensions/core/load3d/exportMenuHelper.ts @@ -7,7 +7,8 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph' const EXPORT_FORMATS = [ { label: 'GLB', value: 'glb' }, { label: 'OBJ', value: 'obj' }, - { label: 'STL', value: 'stl' } + { label: 'STL', value: 'stl' }, + { label: 'FBX', value: 'fbx' } ] as const /** diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 95c30b16d9..fdfa8bfb5c 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -1672,7 +1672,15 @@ export class LGraph this.beforeChange() try { - return this._convertToSubgraphImpl(items) + function extractNodes(item: Positionable): Positionable[] { + if (!(item instanceof LGraphNode) || !item.convertToNodes) return [item] + + const innerNodes = item.convertToNodes() + for (const innerNode of innerNodes) innerNode.updateArea() + return innerNodes + } + const processedItems = new Set([...items].flatMap(extractNodes)) + return this._convertToSubgraphImpl(processedItems) } finally { // Mark state change complete for proper undo support this.afterChange() diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 97dc630360..7d3e74c3b3 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1,8 +1,8 @@ import { toString } from 'es-toolkit/compat' import { toValue } from 'vue' -import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink' +import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink' import { AutoPanController } from '@/renderer/core/canvas/useAutoPan' import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter' import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter' @@ -3307,11 +3307,15 @@ export class LGraphCanvas implements CustomEventDispatcher if (result != null) this.dirty_canvas = result } } + const firstLink: RenderLink | undefined = linkConnector.renderLinks.at(0) + const isSubgraphIOLink = + linkConnector.isConnecting && firstLink?.isIoNodeLink // get node over - const node = LiteGraph.vueNodesMode - ? null - : graph.getNodeOnPos(x, y, this.visible_nodes) + const node = + LiteGraph.vueNodesMode && !isSubgraphIOLink + ? null + : graph.getNodeOnPos(x, y, this.visible_nodes) const dragRect = this.dragging_rectangle if (dragRect) { @@ -3402,8 +3406,6 @@ export class LGraphCanvas implements CustomEventDispatcher // Check if link is over anything it could connect to - record position of valid target for snap / highlight if (linkConnector.isConnecting) { - const firstLink = linkConnector.renderLinks.at(0) - // Default: nothing highlighted let highlightPos: Point | undefined let highlightInput: INodeInputSlot | undefined @@ -3454,7 +3456,7 @@ export class LGraphCanvas implements CustomEventDispatcher highlightInput = node.inputs[inputId] } - if (highlightInput) { + if (highlightInput && !LiteGraph.vueNodesMode) { const widget = node.getWidgetFromSlot(highlightInput) if (widget) linkConnector.overWidget = widget } @@ -8503,40 +8505,7 @@ export class LGraphCanvas implements CustomEventDispatcher options = [ { content: 'Convert to Subgraph', - callback: () => { - // find groupnodes, degroup and select children - if (this.selectedItems.size) { - let hasGroups = false - for (const item of this.selectedItems) { - const node = item as LGraphNode - const isGroup = - typeof node.type === 'string' && - node.type.startsWith(`${PREFIX}${SEPARATOR}`) - if (isGroup && node.convertToNodes) { - hasGroups = true - const nodes = node.convertToNodes() - - requestAnimationFrame(() => { - this.selectItems(nodes, true) - - if (!this.selectedItems.size) - throw new Error('Convert to Subgraph: Nothing selected.') - this._graph.convertToSubgraph(this.selectedItems) - }) - return - } - } - - // If no groups were found, continue normally - if (!hasGroups) { - if (!this.selectedItems.size) - throw new Error('Convert to Subgraph: Nothing selected.') - this._graph.convertToSubgraph(this.selectedItems) - } - } else { - throw new Error('Convert to Subgraph: Nothing selected.') - } - } + callback: () => this._graph.convertToSubgraph(this.selectedItems) }, { content: 'Properties', diff --git a/src/lib/litegraph/src/canvas/RenderLink.ts b/src/lib/litegraph/src/canvas/RenderLink.ts index c79c88abc0..0060bca434 100644 --- a/src/lib/litegraph/src/canvas/RenderLink.ts +++ b/src/lib/litegraph/src/canvas/RenderLink.ts @@ -43,6 +43,8 @@ export interface RenderLink { /** The reroute that the link is being connected from. */ readonly fromReroute?: Reroute + readonly isIoNodeLink?: boolean + /** * Capability checks used for hit-testing and validation during drag. * Implementations should return `false` when a connection is not possible diff --git a/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts b/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts index 057f1e6bc5..79949bab6f 100644 --- a/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts +++ b/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts @@ -24,6 +24,7 @@ export class ToInputFromIoNodeLink implements RenderLink { readonly fromPos: Point fromDirection: LinkDirection = LinkDirection.RIGHT readonly existingLink?: LLink + readonly isIoNodeLink = true constructor( readonly network: LinkNetwork, diff --git a/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts b/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts index 5c847b8743..0f94760565 100644 --- a/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts +++ b/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts @@ -23,6 +23,7 @@ export class ToOutputFromIoNodeLink implements RenderLink { readonly fromPos: Point readonly fromSlotIndex: SlotIndex fromDirection: LinkDirection = LinkDirection.LEFT + readonly isIoNodeLink = true constructor( readonly network: LinkNetwork, diff --git a/src/lib/litegraph/src/subgraph/SubgraphInput.ts b/src/lib/litegraph/src/subgraph/SubgraphInput.ts index c94a078202..24dffbe9a5 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInput.ts @@ -136,6 +136,13 @@ export class SubgraphInput extends SubgraphSlot { } subgraph.incrementVersion() + subgraph.trigger('node:slot-links:changed', { + nodeId: node.id, + slotType: NodeSlotType.INPUT, + slotIndex: inputIndex, + connected: true, + linkId: link.id + }) node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot) subgraph.afterChange() @@ -239,11 +246,8 @@ export class SubgraphInput extends SubgraphSlot { override isValidTarget( fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput ): boolean { - if (isNodeSlot(fromSlot)) { - return ( - 'link' in fromSlot && - LiteGraph.isValidConnection(this.type, fromSlot.type) - ) + if (isNodeSlot(fromSlot) && 'link' in fromSlot) { + return LiteGraph.isValidConnection(this.type, fromSlot.type) } if (isSubgraphOutput(fromSlot)) { diff --git a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts index 7a23809d23..fe898c3d76 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts @@ -226,6 +226,13 @@ export class SubgraphInputNode link, subgraphInput ) + subgraph.trigger('node:slot-links:changed', { + nodeId: node.id, + slotType: NodeSlotType.INPUT, + slotIndex: slotIndex, + connected: false, + linkId: link.id + }) } } diff --git a/src/lib/litegraph/src/subgraph/SubgraphOutput.ts b/src/lib/litegraph/src/subgraph/SubgraphOutput.ts index 8d6f9847b4..c41dc1de00 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphOutput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphOutput.ts @@ -140,11 +140,8 @@ export class SubgraphOutput extends SubgraphSlot { override isValidTarget( fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput ): boolean { - if (isNodeSlot(fromSlot)) { - return ( - 'links' in fromSlot && - LiteGraph.isValidConnection(fromSlot.type, this.type) - ) + if (isNodeSlot(fromSlot) && 'links' in fromSlot) { + return LiteGraph.isValidConnection(fromSlot.type, this.type) } if (isSubgraphInput(fromSlot)) { diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index 6c0ed56dec..bd1dd9c7ca 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "تم إنشاء سير العمل هذا باستخدام إصدار أحدث من ComfyUI. قد لا تعمل بعض العقد بشكل صحيح.", "unknownVersion": "غير معروف" }, + "logsTerminal": { + "loadError": "تعذر تحميل السجلات، يرجى التأكد من أنك قمت بتحديث الواجهة الخلفية لـ ComfyUI.", + "resyncError": "تعذر إعادة مزامنة السجلات بعد إعادة اتصال الواجهة الخلفية. أعد فتح وحدة التحكم للمحاولة مرة أخرى." + }, "maintenance": { "None": "لا شيء", "OK": "حسنًا", @@ -2291,7 +2295,6 @@ "Wan": "وان", "WaveSpeed": "WaveSpeed", "advanced": "متقدم", - "animation": "الرسوم المتحركة", "api node": "عقدة API", "attention_experiments": "تجارب الانتباه", "audio": "صوت", @@ -2314,6 +2317,7 @@ "detection": "الكشف", "edit_models": "تحرير النماذج", "experimental": "تجريبي", + "filters": "مرشحات", "flux": "تدفق", "geometry_estimation": "تقدير الهندسة", "gligen": "gligen", @@ -2337,13 +2341,11 @@ "noise": "ضجيج", "operations": "العمليات", "photomaker": "صانع الصور", - "postprocessing": "المعالجة اللاحقة", "preprocessors": "المعالجون المسبقون", "primitive": "بدائي", "qwen": "تشي وين", "samplers": "أجهزة التجميع", "sampling": "التجميع", - "save": "حفظ", "schedulers": "الجدولة", "scheduling": "الجدولة", "sd3": "sd3", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index f10559092c..29ecc6c6a0 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "ينشئ قناع المقدمة لإزالة الخلفية من صورة باستخدام نموذج إزالة الخلفية.", "display_name": "إزالة الخلفية", "inputs": { "bg_removal_model": { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 3d4cc8dd96..0e9e60cb50 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1675,7 +1675,7 @@ "Bria": "Bria", "video": "video", "ByteDance": "ByteDance", - "preprocessors": "preprocessors", + "filters": "filters", "advanced": "advanced", "guidance": "guidance", "model_merging": "model_merging", @@ -1686,7 +1686,6 @@ "flux": "flux", "kandinsky5": "kandinsky5", "utils": "utils", - "postprocessing": "postprocessing", "hooks": "hooks", "combine": "combine", "logic": "logic", @@ -1695,6 +1694,7 @@ "inpaint": "inpaint", "scheduling": "scheduling", "create": "create", + "transform": "transform", "deprecated": "deprecated", "detection": "detection", "debug": "debug", @@ -1707,7 +1707,6 @@ "unet": "unet", "sigmas": "sigmas", "BFL": "BFL", - "": "", "Gemini": "Gemini", "gligen": "gligen", "shader": "shader", @@ -1716,17 +1715,17 @@ "HitPaw": "HitPaw", "3d_models": "3d_models", "Ideogram": "Ideogram", - "transform": "transform", + "compositing": "compositing", "color": "color", "upscaling": "upscaling", "instructpix2pix": "instructpix2pix", - "compositing": "compositing", "Kling": "Kling", "samplers": "samplers", "operations": "operations", "training": "training", "lotus": "lotus", "LTXV": "LTXV", + "preprocessors": "preprocessors", "Luma": "Luma", "Magnific": "Magnific", "Meshy": "Meshy", @@ -1746,8 +1745,6 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", - "animation": "animation", - "save": "save", "upscale_diffusion": "upscale_diffusion", "clip": "clip", "Sonilo": "Sonilo", @@ -1760,6 +1757,7 @@ "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", + "": "", "WaveSpeed": "WaveSpeed", "zimage": "zimage" }, @@ -3225,6 +3223,7 @@ "copyAssetsAndOpen": "Import assets & open workflow", "openWorkflow": "Open workflow", "openWithoutImporting": "Open without importing", + "opening": "Opening shared workflow...", "importFailed": "Failed to import workflow assets", "loadError": "Could not load this shared workflow. Please try again later." }, diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index 160eab58e0..a6d78743e5 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -305,7 +305,7 @@ } }, "BasicGuider": { - "display_name": "BasicGuider", + "display_name": "Basic Guider", "inputs": { "model": { "name": "model" @@ -1080,7 +1080,7 @@ } }, "Canny": { - "display_name": "Canny", + "display_name": "Detect Edges (Canny)", "inputs": { "image": { "name": "image" @@ -1138,7 +1138,7 @@ } }, "CFGGuider": { - "display_name": "CFGGuider", + "display_name": "CFG Guider", "inputs": { "model": { "name": "model" @@ -1704,7 +1704,7 @@ } }, "ColorTransfer": { - "display_name": "Color Transfer", + "display_name": "Transfer Color", "description": "Match the colors of one image to another using various algorithms.", "inputs": { "image_target": { @@ -1814,7 +1814,7 @@ } }, "ComfyNumberConvert": { - "display_name": "Number Convert", + "display_name": "Convert Number", "inputs": { "value": { "name": "value" @@ -2560,7 +2560,7 @@ } }, "CropByBBoxes": { - "display_name": "CropByBBoxes", + "display_name": "Crop By Bounding Boxes", "description": "Crop and resize regions from the input image batch based on provided bounding boxes.", "inputs": { "image": { @@ -2714,7 +2714,7 @@ } }, "DualCFGGuider": { - "display_name": "DualCFGGuider", + "display_name": "Dual CFG Guider", "inputs": { "model": { "name": "model" @@ -5596,7 +5596,7 @@ } }, "ImageBlur": { - "display_name": "Image Blur", + "display_name": "Blur Image", "inputs": { "image": { "name": "image" @@ -5615,7 +5615,7 @@ } }, "ImageColorToMask": { - "display_name": "ImageColorToMask", + "display_name": "Convert Image Color to Mask", "inputs": { "image": { "name": "image" @@ -5864,7 +5864,7 @@ } }, "ImageOnlyCheckpointLoader": { - "display_name": "Image Only Checkpoint Loader (img2vid model)", + "display_name": "Load Checkpoint Image Only (img2vid model)", "inputs": { "ckpt_name": { "name": "ckpt_name" @@ -8064,7 +8064,7 @@ }, "LoraLoader": { "display_name": "Load LoRA (Model and CLIP)", - "description": "LoRAs are used to modify diffusion and CLIP models, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together.", + "description": "This LoRA loader is used to modify both diffusion and CLIP models, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together.", "inputs": { "model": { "name": "model", @@ -8152,7 +8152,7 @@ }, "LoraLoaderModelOnly": { "display_name": "Load LoRA", - "description": "LoRAs are used to modify diffusion and CLIP models, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together.", + "description": "This LoRAs loader is used to modify the diffusion model, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together.", "inputs": { "model": { "name": "model" @@ -12119,7 +12119,7 @@ } }, "Morphology": { - "display_name": "ImageMorphology", + "display_name": "Apply Morphology", "inputs": { "image": { "name": "image" @@ -13962,6 +13962,7 @@ }, "RemoveBackground": { "display_name": "Remove Background", + "description": "Generates a foreground mask to remove the background from an image using a background removal model.", "inputs": { "image": { "name": "image", @@ -15174,7 +15175,7 @@ } }, "SaveAnimatedPNG": { - "display_name": "SaveAnimatedPNG", + "display_name": "Save Animated PNG", "inputs": { "images": { "name": "images" @@ -15191,7 +15192,7 @@ } }, "SaveAnimatedWEBP": { - "display_name": "SaveAnimatedWEBP", + "display_name": "Save Animated WEBP", "inputs": { "images": { "name": "images" @@ -15508,7 +15509,7 @@ } }, "SDPoseDrawKeypoints": { - "display_name": "SDPoseDrawKeypoints", + "display_name": "SDPose Draw Keypoints", "inputs": { "keypoints": { "name": "keypoints" @@ -15542,7 +15543,7 @@ } }, "SDPoseFaceBBoxes": { - "display_name": "SDPoseFaceBBoxes", + "display_name": "SDPose Face Bounding Boxes", "inputs": { "keypoints": { "name": "keypoints" @@ -15564,7 +15565,7 @@ } }, "SDPoseKeypointExtractor": { - "display_name": "SDPoseKeypointExtractor", + "display_name": "SDPose Keypoint Extractor", "description": "Extract pose keypoints from images using the SDPose model: https://huggingface.co/Comfy-Org/SDPose/tree/main/checkpoints", "inputs": { "model": { @@ -15837,7 +15838,7 @@ } }, "SolidMask": { - "display_name": "SolidMask", + "display_name": "Create Solid Mask", "inputs": { "value": { "name": "value" @@ -17405,7 +17406,7 @@ } }, "ThresholdMask": { - "display_name": "ThresholdMask", + "display_name": "Threshold Mask", "inputs": { "mask": { "name": "mask" @@ -18655,7 +18656,7 @@ } }, "VideoLinearCFGGuidance": { - "display_name": "VideoLinearCFGGuidance", + "display_name": "Video Linear CFG Guidance", "inputs": { "model": { "name": "model" @@ -18666,7 +18667,7 @@ } }, "VideoTriangleCFGGuidance": { - "display_name": "VideoTriangleCFGGuidance", + "display_name": "Video Triangle CFG Guidance", "inputs": { "model": { "name": "model" @@ -19258,7 +19259,7 @@ } }, "VOIDQuadmaskPreprocess": { - "display_name": "VOIDQuadmaskPreprocess", + "display_name": "VOID Quadmask Preprocessor", "inputs": { "mask": { "name": "mask" @@ -19777,7 +19778,7 @@ }, "wanBlockSwap": { "display_name": "wanBlockSwap", - "description": "NOP", + "description": "Intercept wanBlockSwap custom node that causes major instability and make it no-op.", "inputs": { "model": { "name": "model" diff --git a/src/locales/es/main.json b/src/locales/es/main.json index f1c4e114c7..1128d28d58 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "Este flujo de trabajo fue creado con una versión más reciente de ComfyUI. Es posible que algunos nodos no funcionen correctamente.", "unknownVersion": "desconocido" }, + "logsTerminal": { + "loadError": "No se pueden cargar los registros, por favor asegúrate de haber actualizado tu backend de ComfyUI.", + "resyncError": "No se pueden resincronizar los registros después de que el backend se reconectó. Vuelve a abrir la consola para intentarlo de nuevo." + }, "maintenance": { "None": "Ninguno", "OK": "OK", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "avanzado", - "animation": "animación", "api node": "nodo api", "attention_experiments": "experimentos_de_atención", "audio": "audio", @@ -2314,6 +2317,7 @@ "detection": "detección", "edit_models": "editar_modelos", "experimental": "experimental", + "filters": "filtros", "flux": "flux", "geometry_estimation": "estimación de geometría", "gligen": "gligen", @@ -2337,13 +2341,11 @@ "noise": "ruido", "operations": "operaciones", "photomaker": "photomaker", - "postprocessing": "postprocesamiento", "preprocessors": "preprocesadores", "primitive": "primitivo", "qwen": "qwen", "samplers": "muestreadores", "sampling": "muestreo", - "save": "guardar", "schedulers": "programadores", "scheduling": "programación", "sd3": "sd3", diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index f830be46ec..2ec5f7f1f5 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "Genera una máscara de primer plano para eliminar el fondo de una imagen utilizando un modelo de eliminación de fondo.", "display_name": "Eliminar fondo", "inputs": { "bg_removal_model": { diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index 22079493d2..d7372988a5 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "این workflow با نسخه جدیدتری از ComfyUI ایجاد شده است. برخی nodeها ممکن است به درستی کار نکنند.", "unknownVersion": "نامشخص" }, + "logsTerminal": { + "loadError": "امکان بارگذاری لاگ‌ها وجود ندارد، لطفاً اطمینان حاصل کنید که ComfyUI backend خود را به‌روزرسانی کرده‌اید.", + "resyncError": "امکان همگام‌سازی مجدد لاگ‌ها پس از اتصال مجدد backend وجود ندارد. برای تلاش مجدد، کنسول را دوباره باز کنید." + }, "maintenance": { "None": "هیچ‌کدام", "OK": "مناسب", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "پیشرفته", - "animation": "انیمیشن", "api node": "گره API", "attention_experiments": "آزمایش‌های توجه", "audio": "صدا", @@ -2314,6 +2317,7 @@ "detection": "شناسایی", "edit_models": "ویرایش مدل‌ها", "experimental": "آزمایشی", + "filters": "فیلترها", "flux": "flux", "geometry_estimation": "برآورد هندسه", "gligen": "gligen", @@ -2337,13 +2341,11 @@ "noise": "نویز", "operations": "عملیات", "photomaker": "photomaker", - "postprocessing": "پس‌پردازش", "preprocessors": "پیش‌پردازنده‌ها", "primitive": "ابتدایی", "qwen": "qwen", "samplers": "نمونه‌گیرها", "sampling": "نمونه‌گیری", - "save": "ذخیره", "schedulers": "زمان‌بندی‌ها", "scheduling": "زمان‌بندی", "sd3": "sd3", diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index 0ff00e24a6..2a6b3ca902 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "یک ماسک پیش‌زمینه ایجاد می‌کند تا پس‌زمینه را با استفاده از مدل حذف پس‌زمینه از تصویر حذف کند.", "display_name": "حذف پس‌زمینه", "inputs": { "bg_removal_model": { diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index d5d33eb2e7..b786df87f9 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "Ce workflow a été créé avec une version plus récente de ComfyUI. Certains nœuds peuvent ne pas fonctionner correctement.", "unknownVersion": "inconnue" }, + "logsTerminal": { + "loadError": "Impossible de charger les journaux, veuillez vous assurer que votre backend ComfyUI est à jour.", + "resyncError": "Impossible de resynchroniser les journaux après la reconnexion du backend. Rouvrez la console pour réessayer." + }, "maintenance": { "None": "Aucun", "OK": "OK", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "avancé", - "animation": "animation", "api node": "nœud api", "attention_experiments": "expériences_d'attention", "audio": "audio", @@ -2314,6 +2317,7 @@ "detection": "détection", "edit_models": "edit_models", "experimental": "expérimental", + "filters": "filtres", "flux": "flux", "geometry_estimation": "estimation_de_géométrie", "gligen": "gligen", @@ -2337,13 +2341,11 @@ "noise": "bruit", "operations": "opérations", "photomaker": "photomaker", - "postprocessing": "post-traitement", "preprocessors": "préprocesseurs", "primitive": "primitif", "qwen": "qwen", "samplers": "échantillonneurs", "sampling": "échantillonnage", - "save": "enregistrer", "schedulers": "planificateurs", "scheduling": "planification", "sd3": "sd3", diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index b899ed2a18..e861421cb0 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "Génère un masque de premier plan pour supprimer l'arrière-plan d'une image à l'aide d'un modèle de suppression d'arrière-plan.", "display_name": "Supprimer l’arrière-plan", "inputs": { "bg_removal_model": { diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 9cbf873bd7..940c313cf9 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "このワークフローは新しいバージョンのComfyUIで作成されました。一部のノードが正しく動作しない場合があります。", "unknownVersion": "不明" }, + "logsTerminal": { + "loadError": "ログを読み込めません。ComfyUI バックエンドが最新であることを確認してください。", + "resyncError": "バックエンド再接続後にログを再同期できませんでした。再度コンソールを開いて再試行してください。" + }, "maintenance": { "None": "なし", "OK": "OK", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "高度な機能", - "animation": "アニメーション", "api node": "apiノード", "attention_experiments": "アテンション実験", "audio": "オーディオ", @@ -2314,6 +2317,7 @@ "detection": "検出", "edit_models": "モデル編集", "experimental": "実験的", + "filters": "フィルター", "flux": "flux", "geometry_estimation": "geometry_estimation", "gligen": "グライジェン", @@ -2337,13 +2341,11 @@ "noise": "ノイズ", "operations": "操作", "photomaker": "photomaker", - "postprocessing": "ポストプロセッシング", "preprocessors": "前処理", "primitive": "プリミティブ", "qwen": "qwen", "samplers": "サンプラー", "sampling": "サンプリング", - "save": "保存", "schedulers": "スケジューラー", "scheduling": "スケジューリング", "sd3": "SD3", diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index 216d3adc33..7a99614e53 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "背景除去モデルを使用して画像から背景を削除するための前景マスクを生成します。", "display_name": "背景を除去", "inputs": { "bg_removal_model": { diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index b95ae2aeb6..0ab2ea4683 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "이 워크플로우는 더 최신 버전의 ComfyUI에서 생성되었습니다. 일부 노드는 제대로 작동하지 않을 수 있습니다.", "unknownVersion": "알 수 없음" }, + "logsTerminal": { + "loadError": "로그를 불러올 수 없습니다. ComfyUI 백엔드가 최신인지 확인해 주세요.", + "resyncError": "백엔드 재연결 후 로그를 다시 동기화할 수 없습니다. 다시 시도하려면 콘솔을 다시 여세요." + }, "maintenance": { "None": "없음", "OK": "확인", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "고급", - "animation": "애니메이션", "api node": "api 노드", "attention_experiments": "어텐션 실험", "audio": "오디오", @@ -2314,6 +2317,7 @@ "detection": "감지", "edit_models": "edit_models", "experimental": "실험적", + "filters": "필터", "flux": "flux", "geometry_estimation": "geometry_estimation", "gligen": "글리젠", @@ -2337,13 +2341,11 @@ "noise": "노이즈", "operations": "연산", "photomaker": "포토메이커", - "postprocessing": "후처리", "preprocessors": "전처리기", "primitive": "기본 입력", "qwen": "qwen", "samplers": "샘플러", "sampling": "샘플링", - "save": "저장", "schedulers": "스케줄러", "scheduling": "스케줄링", "sd3": "sd3", diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index cbac137b54..f3e14af3d5 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "배경 제거 모델을 사용하여 이미지에서 배경을 제거하기 위한 전경 마스크를 생성합니다.", "display_name": "배경 제거", "inputs": { "bg_removal_model": { diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 3e1fb09713..e6373a3a07 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "Este fluxo de trabalho foi criado com uma versão mais recente do ComfyUI. Alguns nós podem não funcionar corretamente.", "unknownVersion": "desconhecida" }, + "logsTerminal": { + "loadError": "Não foi possível carregar os logs. Certifique-se de que você atualizou o backend do ComfyUI.", + "resyncError": "Não foi possível ressincronizar os logs após a reconexão do backend. Reabra o console para tentar novamente." + }, "maintenance": { "None": "Nenhum", "OK": "OK", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "avançado", - "animation": "animação", "api node": "nó da API", "attention_experiments": "experimentos_de_atenção", "audio": "áudio", @@ -2314,6 +2317,7 @@ "detection": "detecção", "edit_models": "editar_modelos", "experimental": "experimental", + "filters": "filtros", "flux": "flux", "geometry_estimation": "geometry_estimation", "gligen": "gligen", @@ -2337,13 +2341,11 @@ "noise": "ruído", "operations": "operações", "photomaker": "photomaker", - "postprocessing": "pós-processamento", "preprocessors": "pré-processadores", "primitive": "primitivo", "qwen": "qwen", "samplers": "amostradores", "sampling": "amostragem", - "save": "salvar", "schedulers": "agendadores", "scheduling": "agendamento", "sd3": "sd3", diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index f3c8856410..ff311a7461 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "Gera uma máscara de primeiro plano para remover o fundo de uma imagem usando um modelo de remoção de fundo.", "display_name": "Remover Fundo", "inputs": { "bg_removal_model": { diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 34db3cfae3..034d2c91f7 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "Этот рабочий процесс был создан в более новой версии ComfyUI. Некоторые узлы могут работать некорректно.", "unknownVersion": "неизвестно" }, + "logsTerminal": { + "loadError": "Не удалось загрузить логи, пожалуйста, убедитесь, что вы обновили ваш ComfyUI backend.", + "resyncError": "Не удалось повторно синхронизировать логи после переподключения backend. Откройте консоль заново, чтобы повторить попытку." + }, "maintenance": { "None": "Нет", "OK": "OK", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "расширенный", - "animation": "анимация", "api node": "api-узел", "attention_experiments": "эксперименты_внимания", "audio": "аудио", @@ -2314,6 +2317,7 @@ "detection": "детекция", "edit_models": "редактировать_модели", "experimental": "экспериментальное", + "filters": "фильтры", "flux": "flux", "geometry_estimation": "оценка_геометрии", "gligen": "gligen", @@ -2337,13 +2341,11 @@ "noise": "шум", "operations": "операции", "photomaker": "photomaker", - "postprocessing": "постобработка", "preprocessors": "предобработчики", "primitive": "примитив", "qwen": "qwen", "samplers": "семплеры", "sampling": "выборка", - "save": "сохранить", "schedulers": "schedulers", "scheduling": "scheduling", "sd3": "sd3", diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index 890ba45461..4010f23c39 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "Генерирует маску переднего плана для удаления фона с изображения с помощью модели удаления фона.", "display_name": "Удалить фон", "inputs": { "bg_removal_model": { diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index ac8dea8411..3210146273 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "Bu iş akışı ComfyUI'nin daha yeni bir sürümüyle oluşturulmuş. Bazı düğümler düzgün çalışmayabilir.", "unknownVersion": "bilinmeyen" }, + "logsTerminal": { + "loadError": "Kayıtlar yüklenemiyor, lütfen ComfyUI arka ucunuzu güncellediğinizden emin olun.", + "resyncError": "Arka uç yeniden bağlandıktan sonra kayıtlar yeniden eşitlenemedi. Tekrar denemek için konsolu yeniden açın." + }, "maintenance": { "None": "Yok", "OK": "Tamam", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "gelişmiş", - "animation": "animasyon", "api node": "api düğümü", "attention_experiments": "dikkat_deneyleri", "audio": "ses", @@ -2314,6 +2317,7 @@ "detection": "tespit", "edit_models": "modelleri_düzenle", "experimental": "deneysel", + "filters": "filtreler", "flux": "flux", "geometry_estimation": "geometri_tahmini", "gligen": "gligen", @@ -2337,13 +2341,11 @@ "noise": "gürültü", "operations": "işlemler", "photomaker": "photomaker", - "postprocessing": "son işleme", "preprocessors": "ön işlemciler", "primitive": "ilkel", "qwen": "qwen", "samplers": "örnekleyiciler", "sampling": "örnekleme", - "save": "kaydet", "schedulers": "zamanlayıcılar", "scheduling": "zamanlama", "sd3": "sd3", diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 5b44de715e..99ee6387da 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "Bir arka plan kaldırma modeli kullanarak bir görüntüden arka planı kaldırmak için bir ön plan maskesi oluşturur.", "display_name": "Arka Planı Kaldır", "inputs": { "bg_removal_model": { diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 4128f2ce43..4106fd892b 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "此工作流程是以較新版本的 ComfyUI 建立的。部分節點可能無法正確運作。", "unknownVersion": "未知" }, + "logsTerminal": { + "loadError": "無法載入日誌,請確認您已更新 ComfyUI 後端。", + "resyncError": "後端重新連線後無法重新同步日誌。請重新開啟主控台以重試。" + }, "maintenance": { "None": "無", "OK": "正常", @@ -2291,7 +2295,6 @@ "Wan": "Wan", "WaveSpeed": "WaveSpeed", "advanced": "進階", - "animation": "動畫", "api node": "API 節點", "attention_experiments": "注意力實驗", "audio": "音訊", @@ -2314,6 +2317,7 @@ "detection": "偵測", "edit_models": "編輯模型", "experimental": "實驗性", + "filters": "濾鏡", "flux": "Flux", "geometry_estimation": "幾何估算", "gligen": "GLIGEN", @@ -2337,13 +2341,11 @@ "noise": "雜訊", "operations": "操作", "photomaker": "photomaker", - "postprocessing": "後處理", "preprocessors": "前處理器", "primitive": "基礎元件", "qwen": "千問", "samplers": "取樣器", "sampling": "取樣", - "save": "儲存", "schedulers": "排程器", "scheduling": "排程", "sd3": "sd3", diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index f965d7890d..81a99db516 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "使用背景去除模型產生前景遮罩,以從圖像中移除背景。", "display_name": "移除背景", "inputs": { "bg_removal_model": { diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 4f61f2a987..b5a1baec56 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -1745,6 +1745,10 @@ "outdatedVersionGeneric": "这个工作流由新版 ComfyUI 创建,部分节点可能无法正常运行。", "unknownVersion": "未知" }, + "logsTerminal": { + "loadError": "无法加载日志,请确保已更新您的ComfyUI后端。", + "resyncError": "后端重新连接后无法重新同步日志。请重新打开控制台以重试。" + }, "maintenance": { "None": "无", "OK": "确定", @@ -2291,7 +2295,6 @@ "Wan": "Wan万相", "WaveSpeed": "WaveSpeed", "advanced": "高级", - "animation": "动画", "api node": "api 节点", "attention_experiments": "注意力实验", "audio": "音频", @@ -2314,6 +2317,7 @@ "detection": "检测", "edit_models": "编辑模型", "experimental": "实验性", + "filters": "滤镜", "flux": "Flux", "geometry_estimation": "几何估计", "gligen": "GLIGEN", @@ -2337,13 +2341,11 @@ "noise": "噪波", "operations": "操作", "photomaker": "PhotoMaker", - "postprocessing": "后处理", "preprocessors": "预处理器", "primitive": "基础", "qwen": "Qwen千问", "samplers": "采样器", "sampling": "采样", - "save": "保存", "schedulers": "调度器", "scheduling": "调度", "sd3": "SD3", diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index 4801b7d5c6..b872396922 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -13989,6 +13989,7 @@ } }, "RemoveBackground": { + "description": "使用背景移除模型生成前景mask,从图像中移除背景。", "display_name": "移除背景", "inputs": { "bg_removal_model": { diff --git a/src/platform/assets/composables/media/useFlatOutputAssets.ts b/src/platform/assets/composables/media/useFlatOutputAssets.ts new file mode 100644 index 0000000000..dcf4986ff0 --- /dev/null +++ b/src/platform/assets/composables/media/useFlatOutputAssets.ts @@ -0,0 +1,27 @@ +import { storeToRefs } from 'pinia' + +import { useAssetsStore } from '@/stores/assetsStore' + +import type { IAssetsProvider } from './IAssetsProvider' + +export function useFlatOutputAssets(): IAssetsProvider { + const store = useAssetsStore() + const { + flatOutputAssets, + flatOutputLoading, + flatOutputError, + flatOutputHasMore, + flatOutputIsLoadingMore + } = storeToRefs(store) + + return { + media: flatOutputAssets, + loading: flatOutputLoading, + error: flatOutputError, + fetchMediaList: store.updateFlatOutputs, + refresh: store.updateFlatOutputs, + loadMore: store.loadMoreFlatOutputs, + hasMore: flatOutputHasMore, + isLoadingMore: flatOutputIsLoadingMore + } +} diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 3ac5dc2c71..4c53574777 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -180,6 +180,8 @@ const DEFAULT_LIMIT = 500 const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500 export const MODELS_TAG = 'models' +export const INPUT_TAG = 'input' +export const OUTPUT_TAG = 'output' /** Asset tag used by the backend for placeholder records that are not installed. */ export const MISSING_TAG = 'missing' const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG] diff --git a/src/platform/assets/utils/assetMetadataUtils.ts b/src/platform/assets/utils/assetMetadataUtils.ts index ff81ef0495..7d669b2053 100644 --- a/src/platform/assets/utils/assetMetadataUtils.ts +++ b/src/platform/assets/utils/assetMetadataUtils.ts @@ -198,3 +198,13 @@ export function getAssetCardTitle(asset: AssetItem): string { if (curatedName && curatedName !== asset.name) return curatedName return getAssetDisplayFilename(asset) } + +/** + * Returns the filename component the cloud `/api/view` endpoint resolves + * for this asset — `asset_hash` when present (cloud assets are hash-keyed + * in storage), otherwise `asset.name`. Use this when constructing widget + * values or media URLs that must round-trip through the view endpoint. + */ +export function getAssetUrlFilename(asset: AssetItem): string { + return asset.asset_hash || asset.name +} diff --git a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts index a6320fe13c..1fd7e8f9c5 100644 --- a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts +++ b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts @@ -34,6 +34,7 @@ const i18n = createI18n({ copyAssetsAndOpen: 'Copy assets & open workflow', openWorkflow: 'Open workflow', openWithoutImporting: 'Open without importing', + opening: 'Opening shared workflow...', loadError: 'Could not load this shared workflow. Please try again later.' }, @@ -292,6 +293,25 @@ describe('OpenSharedWorkflowDialogContent', () => { expect(onConfirm).toHaveBeenCalledWith(assetsPayload) }) + it('shows opening status and disables actions while opening', async () => { + mockGetSharedWorkflow.mockResolvedValue(assetsPayload) + const { container } = renderComponent({ openingAction: 'copy-and-open' }) + await flushPromises() + + expect(screen.getByRole('status').textContent).toContain( + 'Opening shared workflow...' + ) + expect(container.textContent).not.toContain( + 'Opening the workflow will create a new copy in your workspace' + ) + expect(screen.getByTestId('open-shared-workflow-close')).toBeEnabled() + expect(screen.getByTestId('open-shared-workflow-cancel')).toBeDisabled() + expect( + screen.getByTestId('open-shared-workflow-open-without-importing') + ).toBeDisabled() + expect(screen.getByTestId('open-shared-workflow-confirm')).toBeDisabled() + }) + it('filters out assets already in library', async () => { const mixedPayload = makePayload({ assets: [ diff --git a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue index 0d6750a1b6..eb3130c8de 100644 --- a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue +++ b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue @@ -1,12 +1,24 @@