From 54f31276582d23407fe902870927480f54c8b777 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sat, 18 Apr 2026 09:10:02 -0400 Subject: [PATCH 001/460] test: regenerate screenshot expectations (#11360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary regenerate screenshot expectations ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11360-test-regenerate-screenshot-expectations-3466d73d365081878addd53a266a31b7) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions --- .../tests/vueNodes/widgets/imageCrop.spec.ts | 2 +- .../image-crop-empty-state-chromium-linux.png | Bin 20127 -> 20365 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts b/browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts index a74cc55e24..700fcca8bf 100644 --- a/browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts @@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => { ) test( - 'Empty state matches screenshot baseline', + 'Empty state matches the screenshot baseline', { tag: '@screenshot' }, async ({ comfyPage }) => { const node = comfyPage.vueNodes.getNodeLocator('1') diff --git a/browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts-snapshots/image-crop-empty-state-chromium-linux.png b/browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts-snapshots/image-crop-empty-state-chromium-linux.png index ea086fcfcb888ff4ccb25db8784ff05b4e7ad325..88835b93fcca7e9fe418bfaed1aa6562f92ef0e9 100644 GIT binary patch delta 444 zcmV;t0YmP*K1o004H+(Xj|*MCKmxU{@? zFHay8RapiApuy3o(x^2ONt;$~?w2l|SF2Q(Wh;}q0!y#g`E`Hp&gF1Ql}fcrsZ^Cx zbfsJ_@(TbS6b%y|kMHQ%!qI{LY2oDLOv^@DS?QPJ&U1{aFH%=v$z<8t*;y@GIMKh( z&aQ559%8YJNaSc(4af8Y$prxQr&1{k3l)V5c}{j#adDwiS*p=!1Oh>A^Z!2p00960 mLdO%k00006NkkYyR09AI&NNC;Y23m90000O|2rK}wdm4>~ z!{MOO9RTc|PN(zq^vcRir?G`LEC8@;e7>MnEBD*C(*yzm0t*1_7cEu!`T56Ri=$OS z`VoNz0Cq~N)p~zPyfZRV)oPV#3<3)P?2%5Zb#``fY~gh0cDku}MR02l0N5e5TJ7Z} zZQc=+Q@6T5|2$1#{WTq^=T{i%ZLU_wocn zQI%x?02&;PDveqrk+f;$=6>nYd9_MqS++8%E3ou>onL?F?pzM1RH;;}luA`8MOVt@ zBEJCOLD4Yb@%WC8EgT)_pB7F|&a`Zlm6d)e?mWk+`XY4&mQ0qNot@R9g%kbj?Ck31 z<{=ilh(wNt)o@HdkX!&ze=3!-uuxH`kmqD)6&DvOm8BYuMj#N>Hvj(v00960UUQc` i00006NkkYyR09B4V>5zX^sX=f0000 Date: Sat, 18 Apr 2026 17:32:03 +0100 Subject: [PATCH 002/460] test: add regression test for getCanvasCenter null guard (#8399) (#11271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a regression test for #8399 (null check in `getCanvasCenter` to prevent crash on asset insert). The fix in `src/services/litegraphService.ts` added optional chaining around `app.canvas?.ds?.visible_area` with a `[0, 0]` fallback so inserting an asset before the canvas finishes initializing no longer crashes. There was no existing unit test for `litegraphService`, so this regression could silently return. ## Changes - **What**: New unit test file `src/services/litegraphService.test.ts` covering `useLitegraphService().getCanvasCenter`. - Mocks `@/scripts/app` so `app.canvas` can be swapped per test via `Reflect.set`. - Null-canvas case (regression for #8399): returns `[0, 0]` instead of throwing. - Missing `ds.visible_area` case: also returns `[0, 0]`. - Initialised case: returns the centre of the visible area. - Verified RED→GREEN locally. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11271-test-add-regression-test-for-getCanvasCenter-null-guard-8399-3436d73d3650815c9925c8fdf9ec4bd3) by [Unito](https://www.unito.io) --- src/services/litegraphService.test.ts | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/services/litegraphService.test.ts diff --git a/src/services/litegraphService.test.ts b/src/services/litegraphService.test.ts new file mode 100644 index 0000000000..25a065bae5 --- /dev/null +++ b/src/services/litegraphService.test.ts @@ -0,0 +1,43 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/scripts/app', () => ({ + app: { canvas: undefined }, + ComfyApp: class {} +})) + +import { app } from '@/scripts/app' +import { useLitegraphService } from '@/services/litegraphService' + +describe('useLitegraphService().getCanvasCenter', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('returns origin when canvas is not yet initialised', () => { + Reflect.set(app, 'canvas', undefined) + + const center = useLitegraphService().getCanvasCenter() + + expect(center).toEqual([0, 0]) + }) + + it('returns origin when canvas exists but ds.visible_area is missing', () => { + Reflect.set(app, 'canvas', { ds: {} }) + + const center = useLitegraphService().getCanvasCenter() + + expect(center).toEqual([0, 0]) + }) + + it('returns the visible-area centre once the canvas is ready', () => { + Reflect.set(app, 'canvas', { + ds: { visible_area: [10, 20, 200, 100] } + }) + + const center = useLitegraphService().getCanvasCenter() + + expect(center).toEqual([110, 70]) + }) +}) From cf3006f82cf6a0cbe27a7c357757c59db48129a3 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 18 Apr 2026 13:28:32 -0700 Subject: [PATCH 003/460] fix: reduce noise in coverage Slack notifications (#11283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Suppress low-signal coverage Slack notifications that show +0.0% or -0.0% deltas. ## Changes - **What**: Add `MIN_DELTA` threshold (0.05%) so only meaningful improvements trigger notifications. Only display rows for metrics that actually improved (no more E2E row showing -0.0% alongside a real unit improvement). Fix `formatDelta` to clamp near-zero values to `+0.0%` instead of showing `-0.0%`. - 4 of the first 6 notifications posted were noise (+0.0% deltas from instrumentation jitter). With this change, only 2 of 6 would have been posted — both showing real improvements. ## Review Focus The `MIN_DELTA` value of 0.05 means any delta that rounds to ±0.0% at 1 decimal place is suppressed. This matches the display precision so users never see +0.0% notifications. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11283-fix-reduce-noise-in-coverage-Slack-notifications-3436d73d3650819ab3bcfebdb748ac8b) by [Unito](https://www.unito.io) --- scripts/coverage-slack-notify.ts | 33 ++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/scripts/coverage-slack-notify.ts b/scripts/coverage-slack-notify.ts index 8c154b1ad1..7c4729e2a3 100644 --- a/scripts/coverage-slack-notify.ts +++ b/scripts/coverage-slack-notify.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs' const TARGET = 80 const MILESTONE_STEP = 5 +const MIN_DELTA = 0.05 const BAR_WIDTH = 20 interface CoverageData { @@ -71,8 +72,9 @@ function formatPct(value: number): string { } function formatDelta(delta: number): string { - const sign = delta >= 0 ? '+' : '' - return sign + delta.toFixed(1) + '%' + const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta + const sign = rounded >= 0 ? '+' : '' + return sign + rounded.toFixed(1) + '%' } function crossedMilestone(prev: number, curr: number): number | null { @@ -150,15 +152,18 @@ function main() { const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov') const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov') - const unitImproved = - unitCurrent !== null && - unitBaseline !== null && - unitCurrent.percentage > unitBaseline.percentage + const unitDelta = + unitCurrent !== null && unitBaseline !== null + ? unitCurrent.percentage - unitBaseline.percentage + : 0 - const e2eImproved = - e2eCurrent !== null && - e2eBaseline !== null && - e2eCurrent.percentage > e2eBaseline.percentage + const e2eDelta = + e2eCurrent !== null && e2eBaseline !== null + ? e2eCurrent.percentage - e2eBaseline.percentage + : 0 + + const unitImproved = unitDelta >= MIN_DELTA + const e2eImproved = e2eDelta >= MIN_DELTA if (!unitImproved && !e2eImproved) { process.exit(0) @@ -172,12 +177,12 @@ function main() { ) summaryLines.push('') - if (unitCurrent && unitBaseline) { - summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline)) + if (unitImproved) { + summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!)) } - if (e2eCurrent && e2eBaseline) { - summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline)) + if (e2eImproved) { + summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!)) } summaryLines.push('') From da91bdc957f74d6e89be9ad9ab66ee8b354525ec Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Sat, 18 Apr 2026 14:29:44 -0700 Subject: [PATCH 004/460] fix: persist middle-click reroute node setting across reloads (#11362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary - Remove hardcoded `LiteGraph.middle_click_slot_add_default_node = true` from `slotDefaults` extension `init()` that unconditionally overrode the user's persisted preference on every page load - Add E2E regression test verifying both the setting store value and the LiteGraph runtime flag persist through page reload ## Root Cause The `Comfy.SlotDefaults` extension's `init()` method (in `slotDefaults.ts`) contained a hardcoded `LiteGraph.middle_click_slot_add_default_node = true` from the original JS→TS conversion (July 2024). When `Comfy.Node.MiddleClickRerouteNode` was later made configurable in v1.3.42, this line was never removed. Since extension `init()` runs **after** `useLitegraphSettings()` syncs the stored value, the hardcoded assignment overwrote the user's preference on every reload. ## Changes | File | Change | |------|--------| | `src/extensions/core/slotDefaults.ts` | Remove line 21 (`LiteGraph.middle_click_slot_add_default_node = true`) | | `browser_tests/tests/dialogs/settingsDialog.spec.ts` | Add reload persistence test asserting both store value and LiteGraph global | The setting default (`true`) is already properly managed by `coreSettings.ts` and reactively synced via `useLitegraphSettings.ts`, so removing the hardcoded line preserves existing default behavior while allowing user overrides to persist. ## Screenshots ![Setting shown as enabled (default state)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970358-dcd6bd51-00c8-4ed4-86ce-0f1a89576f52.png) ![Setting toggled off by user](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970719-fb1f587f-964d-4e6c-954e-3145812badaf.png) ![Setting correctly persists as off after page reload (with fix applied)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528971113-36b577cb-5fd1-445d-8c8f-3ea8f6f46326.png) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11362-fix-persist-middle-click-reroute-node-setting-across-reloads-3466d73d365081ef8692dbd0619c8594) by [Unito](https://www.unito.io) Co-authored-by: Glary-Bot --- .../tests/dialogs/settingsDialog.spec.ts | 32 +++++++++++++++++++ src/extensions/core/slotDefaults.ts | 1 - 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/browser_tests/tests/dialogs/settingsDialog.spec.ts b/browser_tests/tests/dialogs/settingsDialog.spec.ts index 802171108a..4d6a2fa0ab 100644 --- a/browser_tests/tests/dialogs/settingsDialog.spec.ts +++ b/browser_tests/tests/dialogs/settingsDialog.spec.ts @@ -131,6 +131,38 @@ test.describe('Settings dialog', { tag: '@ui' }, () => { expect(switched).toBe(true) }) + test('Boolean setting persists after page reload', async ({ comfyPage }) => { + const settingId = 'Comfy.Node.MiddleClickRerouteNode' + const initialValue = await comfyPage.settings.getSetting(settingId) + + try { + await comfyPage.settings.setSetting(settingId, !initialValue) + + await expect + .poll(() => comfyPage.settings.getSetting(settingId)) + .toBe(!initialValue) + + await comfyPage.page.reload({ waitUntil: 'domcontentloaded' }) + await comfyPage.page.waitForFunction( + () => window.app && window.app.extensionManager + ) + + await expect + .poll(() => comfyPage.settings.getSetting(settingId)) + .toBe(!initialValue) + + await expect + .poll(() => + comfyPage.page.evaluate( + () => window.LiteGraph!.middle_click_slot_add_default_node + ) + ) + .toBe(!initialValue) + } finally { + await comfyPage.settings.setSetting(settingId, initialValue) + } + }) + test('Dropdown setting can be changed and persists', async ({ comfyPage }) => { diff --git a/src/extensions/core/slotDefaults.ts b/src/extensions/core/slotDefaults.ts index cf03ddec6b..c36a32c637 100644 --- a/src/extensions/core/slotDefaults.ts +++ b/src/extensions/core/slotDefaults.ts @@ -18,7 +18,6 @@ app.registerExtension({ suggestionsNumber: null, init(this: SlotDefaultsExtension) { LiteGraph.search_filter_enabled = true - LiteGraph.middle_click_slot_add_default_node = true this.suggestionsNumber = app.ui.settings.addSetting({ id: 'Comfy.NodeSuggestions.number', category: ['Comfy', 'Node Search Box', 'NodeSuggestions'], From b756545f594e7f66afa6ba7aac39ca01aa74dfbf Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:28:05 +0900 Subject: [PATCH 005/460] refactor: clean up ChangeTracker logging, guards, and redundant widget wrapper (#11328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-ups to PR #10816. Bundles four review items left open after that PR merged — three inside `ChangeTracker` itself and one in the widget composable that wraps it. ### What changed - **Removed all `loglevel` logging from `src/scripts/changeTracker.ts`** — the logger was set to `info`, so every `logger.debug` call was dead code at runtime. `logger.warn` calls were replaced with direct reporting. The only-downstream dead code (`graphDiff` helper) and its sole dependency (`jsondiffpatch`) are also removed. - **Named the `captureCanvasState()` guard conditions** — `isUndoRedoing` and `isInsideChangeTransaction` now carry the intent that the inline `_restoringState` / `changeCount > 0` expressions used to obscure. - **Surfaced lifecycle violations through a single reporting helper** — `reportInactiveTrackerCall()` logs `console.warn` once per method per session and, on Desktop, emits a `Sentry.addBreadcrumb` with the offending workflow path. `deactivate()` and `captureCanvasState()` share this path so the same invariant is reported consistently. - **Inlined `captureWorkflowState` wrapper in `useWidgetSelectActions`** — the private helper forwarded to `changeTracker.captureCanvasState()` with no added logic. Both call sites now invoke the change tracker directly. ### Issues fixed - Fixes #11249 - Fixes #11259 - Fixes #11258 - Fixes #11248 ### Test plan - [x] `pnpm test:unit src/scripts/changeTracker.test.ts` — 16 tests pass - [x] `pnpm test:unit src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.test.ts` — 6 tests pass - [x] `pnpm typecheck` - [x] `pnpm lint` - [x] `pnpm format` --- package.json | 1 - pnpm-lock.yaml | 20 ---- pnpm-workspace.yaml | 1 - .../composables/useWidgetSelectActions.ts | 8 +- src/scripts/changeTracker.ts | 103 ++++++------------ 5 files changed, 38 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index 7a2683a444..0b832a430b 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,6 @@ "fuse.js": "^7.0.0", "glob": "catalog:", "jsonata": "catalog:", - "jsondiffpatch": "catalog:", "loglevel": "^1.9.2", "marked": "^15.0.11", "pinia": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a589421d4..23edea50cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,9 +267,6 @@ catalogs: jsonata: specifier: ^2.1.0 version: 2.1.0 - jsondiffpatch: - specifier: ^0.7.3 - version: 0.7.3 knip: specifier: ^6.3.1 version: 6.3.1 @@ -557,9 +554,6 @@ importers: jsonata: specifier: 'catalog:' version: 2.1.0 - jsondiffpatch: - specifier: 'catalog:' - version: 0.7.3 loglevel: specifier: ^1.9.2 version: 1.9.2 @@ -1780,9 +1774,6 @@ packages: '@cyberalien/svg-utils@1.1.1': resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==} - '@dmsnell/diff-match-patch@1.1.0': - resolution: {integrity: sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==} - '@dual-bundle/import-meta-resolve@4.2.1': resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} @@ -7269,11 +7260,6 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsondiffpatch@0.7.3: - resolution: {integrity: sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -11239,8 +11225,6 @@ snapshots: dependencies: '@iconify/types': 2.0.0 - '@dmsnell/diff-match-patch@1.1.0': {} - '@dual-bundle/import-meta-resolve@4.2.1': {} '@emmetio/abbreviation@2.3.3': @@ -17140,10 +17124,6 @@ snapshots: jsonc-parser@3.3.1: {} - jsondiffpatch@0.7.3: - dependencies: - '@dmsnell/diff-match-patch': 1.1.0 - jsonfile@6.2.0: dependencies: universalify: 2.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 141a229b4a..29b3a82afa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -90,7 +90,6 @@ catalog: jiti: 2.6.1 jsdom: ^27.4.0 jsonata: ^2.1.0 - jsondiffpatch: ^0.7.3 knip: ^6.3.1 lenis: ^1.3.21 lint-staged: ^16.2.7 diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.ts index 122de798c4..0c9780c90e 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.ts @@ -23,10 +23,6 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) { const toastStore = useToastStore() const { wrapWithErrorHandlingAsync } = useErrorHandling() - function captureWorkflowState() { - useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState() - } - function updateSelectedItems(selectedItems: Set) { const id = selectedItems.size > 0 ? selectedItems.values().next().value : undefined @@ -36,7 +32,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) { : dropdownItems.value.find((item) => item.id === id)?.name modelValue.value = name - captureWorkflowState() + useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState() } async function uploadFile( @@ -109,7 +105,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) { widget.callback(uploadedPaths[0]) } - captureWorkflowState() + useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState() } ) diff --git a/src/scripts/changeTracker.ts b/src/scripts/changeTracker.ts index 10ff0bfac2..0218a793ae 100644 --- a/src/scripts/changeTracker.ts +++ b/src/scripts/changeTracker.ts @@ -1,9 +1,9 @@ +import * as Sentry from '@sentry/vue' import _ from 'es-toolkit/compat' -import * as jsondiffpatch from 'jsondiffpatch' -import log from 'loglevel' import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph' import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph' +import { isDesktop } from '@/platform/distribution/types' import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' @@ -20,14 +20,37 @@ function clone(obj: T): T { return JSON.parse(JSON.stringify(obj)) } -const logger = log.getLogger('ChangeTracker') -// Change to debug for more verbose logging -logger.setLevel('info') - function isActiveTracker(tracker: ChangeTracker): boolean { return useWorkflowStore().activeWorkflow?.changeTracker === tracker } +const reportedInactiveCalls = new Set() + +/** + * Report a ChangeTracker method being called on an inactive tracker — + * a lifecycle violation that usually indicates stale extension state or + * an incorrect call ordering. Reports once per method per workflow per + * session so the signal is not drowned out by hot-path invocations while + * still distinguishing between workflows. + */ +function reportInactiveTrackerCall(method: string, workflowPath: string) { + const key = `${method}:${workflowPath}` + if (reportedInactiveCalls.has(key)) return + reportedInactiveCalls.add(key) + + console.warn(`${method}() called on inactive tracker for: ${workflowPath}`) + + if (isDesktop) { + Sentry.captureMessage( + `ChangeTracker.${method}() called on inactive tracker`, + { + level: 'warning', + tags: { workflow: workflowPath } + } + ) + } +} + export class ChangeTracker { static MAX_HISTORY = 50 /** @@ -77,7 +100,6 @@ export class ChangeTracker { // Do not reset the state if we are restoring. if (this._restoringState) return - logger.debug('Reset State') if (state) this.activeState = clone(state) this.initialState = clone(this.activeState) } @@ -107,10 +129,7 @@ export class ChangeTracker { */ deactivate() { if (!isActiveTracker(this)) { - logger.warn( - 'deactivate() called on inactive tracker for:', - this.workflow.path - ) + reportInactiveTrackerCall('deactivate', this.workflow.path) return } if (!this._restoringState) this.captureCanvasState() @@ -165,13 +184,6 @@ export class ChangeTracker { this.initialState, this.activeState ) - if (logger.getLevel() <= logger.levels.DEBUG && workflow.isModified) { - const diff = ChangeTracker.graphDiff( - this.initialState, - this.activeState - ) - logger.debug('Graph diff:', diff) - } } } @@ -181,19 +193,18 @@ export class ChangeTracker { * Calling this on an inactive tracker would capture the wrong graph. */ captureCanvasState() { + const isUndoRedoing = this._restoringState + const isInsideChangeTransaction = this.changeCount > 0 if ( !app.graph || - this.changeCount || - this._restoringState || + isInsideChangeTransaction || + isUndoRedoing || ChangeTracker.isLoadingGraph ) return if (!isActiveTracker(this)) { - logger.warn( - 'captureCanvasState called on inactive tracker for:', - this.workflow.path - ) + reportInactiveTrackerCall('captureCanvasState', this.workflow.path) return } @@ -207,7 +218,6 @@ export class ChangeTracker { if (this.undoQueue.length > ChangeTracker.MAX_HISTORY) { this.undoQueue.shift() } - logger.debug('Diff detected. Undo queue length:', this.undoQueue.length) this.activeState = currentState this.redoQueue.length = 0 @@ -219,7 +229,7 @@ export class ChangeTracker { checkState() { if (!ChangeTracker._checkStateWarned) { ChangeTracker._checkStateWarned = true - logger.warn( + console.warn( 'checkState() is deprecated — use captureCanvasState() instead.' ) } @@ -248,22 +258,10 @@ export class ChangeTracker { async undo() { await this.updateState(this.undoQueue, this.redoQueue) - logger.debug( - 'Undo. Undo queue length:', - this.undoQueue.length, - 'Redo queue length:', - this.redoQueue.length - ) } async redo() { await this.updateState(this.redoQueue, this.undoQueue) - logger.debug( - 'Redo. Undo queue length:', - this.undoQueue.length, - 'Redo queue length:', - this.redoQueue.length - ) } async undoRedo(e: KeyboardEvent) { @@ -337,7 +335,6 @@ export class ChangeTracker { // If our active element is some type of input then handle changes after they're done if (ChangeTracker.bindInput(bindInputEl)) return - logger.debug('captureCanvasState on keydown') changeTracker.captureCanvasState() }) }, @@ -347,25 +344,21 @@ export class ChangeTracker { window.addEventListener('keyup', () => { if (keyIgnored) { keyIgnored = false - logger.debug('captureCanvasState on keyup') captureState() } }) // Handle clicking DOM elements (e.g. widgets) window.addEventListener('mouseup', () => { - logger.debug('captureCanvasState on mouseup') captureState() }) // Handle prompt queue event for dynamic widget changes api.addEventListener('promptQueued', () => { - logger.debug('captureCanvasState on promptQueued') captureState() }) api.addEventListener('graphCleared', () => { - logger.debug('captureCanvasState on graphCleared') captureState() }) @@ -373,7 +366,6 @@ export class ChangeTracker { const processMouseUp = LGraphCanvas.prototype.processMouseUp LGraphCanvas.prototype.processMouseUp = function (e) { const v = processMouseUp.apply(this, [e]) - logger.debug('captureCanvasState on processMouseUp') captureState() return v } @@ -390,7 +382,6 @@ export class ChangeTracker { callback(v) captureState() } - logger.debug('captureCanvasState on prompt') return prompt.apply(this, [title, value, extendedCallback, event]) } @@ -398,7 +389,6 @@ export class ChangeTracker { const close = LiteGraph.ContextMenu.prototype.close LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) { const v = close.apply(this, [e]) - logger.debug('captureCanvasState on contextMenuClose') captureState() return v } @@ -501,25 +491,4 @@ export class ChangeTracker { return false } - - private static graphDiff(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) { - function sortGraphNodes(graph: ComfyWorkflowJSON) { - return { - links: graph.links, - floatingLinks: graph.floatingLinks, - reroutes: graph.reroutes, - groups: graph.groups, - extra: graph.extra, - definitions: graph.definitions, - subgraphs: graph.subgraphs, - nodes: graph.nodes.sort((a, b) => { - if (typeof a.id === 'number' && typeof b.id === 'number') { - return a.id - b.id - } - return 0 - }) - } - } - return jsondiffpatch.diff(sortGraphNodes(a), sortGraphNodes(b)) - } } From 3b4811b00dd26b092958865f0ecd47486723ae63 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 18 Apr 2026 15:40:59 -0700 Subject: [PATCH 006/460] feat: deploy E2E coverage HTML report to GitHub Pages (#11291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Browsable E2E coverage report deployed to GitHub Pages on every main merge, replacing the current workflow of downloading LCOV artifacts and using an external viewer. ## Changes - **What**: After merging shard LCOVs, run `genhtml` to produce an HTML report with per-file line coverage. On `main`, deploy to GitHub Pages via `actions/deploy-pages`. For PR runs, the HTML report is still available as the `e2e-coverage-html` artifact. - **Dependencies**: None new — `genhtml` is part of the `lcov` package already installed in the workflow. ## Review Focus - **GitHub Pages must be enabled**: Settings → Pages → Source → "GitHub Actions". Without this the deploy job will fail silently. - The deploy job only runs for `main` branch (`if: github.event.workflow_run.head_branch == 'main'`) so PR coverage doesn't clobber the deployed report. - Added `pages: write` and `id-token: write` permissions to the workflow for the Pages deployment. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11291-feat-deploy-E2E-coverage-HTML-report-to-GitHub-Pages-3446d73d36508136ba6fd806690c9cfc) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown --- .github/workflows/ci-tests-e2e-coverage.yaml | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/ci-tests-e2e-coverage.yaml b/.github/workflows/ci-tests-e2e-coverage.yaml index 8fdfe04279..7613bbc927 100644 --- a/.github/workflows/ci-tests-e2e-coverage.yaml +++ b/.github/workflows/ci-tests-e2e-coverage.yaml @@ -98,3 +98,50 @@ jobs: flags: e2e token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false + + - name: Generate HTML coverage report + run: | + if [ ! -s coverage/playwright/coverage.lcov ]; then + echo "No coverage data; generating placeholder report." + mkdir -p coverage/html + echo '

No E2E coverage data available for this run.

' > coverage/html/index.html + exit 0 + fi + genhtml coverage/playwright/coverage.lcov \ + -o coverage/html \ + --title "ComfyUI E2E Coverage" \ + --no-function-coverage \ + --precision 1 + + - name: Upload HTML report artifact + uses: actions/upload-artifact@v6 + with: + name: e2e-coverage-html + path: coverage/html/ + retention-days: 30 + + deploy: + needs: merge + if: github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Download HTML report + uses: actions/download-artifact@v7 + with: + name: e2e-coverage-html + path: coverage/html + + - name: Upload to GitHub Pages + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: coverage/html + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 From 7089a7d1a0a3f18e4fd81f2aff4c0299d13c076b Mon Sep 17 00:00:00 2001 From: Dante Date: Sun, 19 Apr 2026 07:35:39 +0900 Subject: [PATCH 007/460] fix: show asset display names in bulk delete confirmation (#11321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Bulk-delete confirmation on Comfy Cloud listed raw SHA-256 filenames, making the modal impossible to use to verify what would be deleted. ## Changes - **What**: `useMediaAssetActions.deleteAssets` now maps each asset through `getAssetDisplayName`, so the confirmation's `itemList` matches the user-assigned names shown in the left media panel (`MediaAssetCard`). - **Tests**: Added two regression tests covering `user_metadata.name` / `display_name` resolution and the `asset.name` fallback. ## Review Focus - Parity with `MediaAssetCard` display: we reuse the same `getAssetDisplayName` helper; extension stripping (via `getFilenameDetails`) is not applied in the modal since file extensions are useful context when confirming deletions. Reported in Slack: https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776383570015289 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11321-fix-show-asset-display-names-in-bulk-delete-confirmation-3456d73d36508108a3d5f2290ca39e18) by [Unito](https://www.unito.io) --- .../composables/useMediaAssetActions.test.ts | 52 +++++++++++++++++++ .../composables/useMediaAssetActions.ts | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/platform/assets/composables/useMediaAssetActions.test.ts b/src/platform/assets/composables/useMediaAssetActions.test.ts index f849cc6def..38c83f77ba 100644 --- a/src/platform/assets/composables/useMediaAssetActions.test.ts +++ b/src/platform/assets/composables/useMediaAssetActions.test.ts @@ -484,4 +484,56 @@ describe('useMediaAssetActions', () => { ) }) }) + + describe('deleteAssets - confirmation dialog item names', () => { + beforeEach(() => { + mockIsCloud.value = true + mockGetAssetType.mockReturnValue('output') + mockShowDialog.mockReset() + }) + + it('should show user_metadata display names instead of hash filenames', () => { + const actions = useMediaAssetActions() + + const assets = [ + createMockAsset({ + id: 'asset-1', + name: 'c885097ab185ced82f017bcbc98948918499f7480315fd5b928b5bb8d4951efc.png', + user_metadata: { name: 'My Sunset Render' } + }), + createMockAsset({ + id: 'asset-2', + name: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2.png', + display_name: 'Portrait Variation' + }) + ] + + void actions.deleteAssets(assets) + + expect(mockShowDialog).toHaveBeenCalledTimes(1) + const dialogProps = mockShowDialog.mock.calls[0][0].props as { + itemList: string[] + } + expect(dialogProps.itemList).toEqual([ + 'My Sunset Render', + 'Portrait Variation' + ]) + }) + + it('should fall back to asset.name when no display name is available', () => { + const actions = useMediaAssetActions() + + const asset = createMockAsset({ + id: 'asset-3', + name: 'fallback-image.png' + }) + + void actions.deleteAssets(asset) + + const dialogProps = mockShowDialog.mock.calls[0][0].props as { + itemList: string[] + } + expect(dialogProps.itemList).toEqual(['fallback-image.png']) + }) + }) }) diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index 186b2c15fb..7bdee89be9 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -595,7 +595,7 @@ export function useMediaAssetActions() { count: assetArray.length }), type: 'delete', - itemList: assetArray.map((asset) => asset.name), + itemList: assetArray.map((asset) => getAssetDisplayName(asset)), onConfirm: async () => { // Show loading overlay for all assets being deleted assetArray.forEach((asset) => From 40083d593b0a241c6187594da4c48113a2c2d16a Mon Sep 17 00:00:00 2001 From: Dante Date: Sun, 19 Apr 2026 07:36:16 +0900 Subject: [PATCH 008/460] test: cover Button, Textarea, Slider components (#11325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes coverage gaps in \`src/components/ui/\` as part of the unit-test backfill. Uses \`@testing-library/vue\` + \`@testing-library/user-event\` for user-centric, behavioral assertions. ## Testing focus Three Reka-UI primitives. The challenge is testing the contract — not the library internals — given happy-dom's gaps and Reka's \`useMounted()\`-based async initialization. ### \`Button\` (7 tests) - Slot rendering + click event propagation. - \`loading=true\`: three invariants hold **simultaneously** — slot hidden, \`pi-spin\` spinner present, button is \`toBeDisabled()\`. - \`disabled=true\` alone: button disabled, no spinner. - \`as="a"\`: polymorphic root tag (Reka \`Primitive\`'s \`as\` prop switches the rendered element). - Variant class pass-through: **one** deliberate style assertion because the variant-system wiring is part of the component's public contract. No other styling/class checks (AGENTS.md bans class-based tests). ### \`Textarea\` (6 tests) - \`v-model\` two-way binding: \`user.type()\` updates the bound ref; initial value populates the textarea. - \`disabled\` asserted **behaviorally** — typing is blocked when disabled, not just the attribute presence. - Pass-through: \`placeholder\`, \`rows\`, \`class\`. ### \`Slider\` (8 tests) - Thumb count matches \`modelValue.length\` (range support). - ARIA: \`aria-valuemin\` / \`aria-valuemax\` / \`aria-valuenow\`. **Caveat:** Reka's \`SliderRoot\` uses \`useMounted()\`, so \`aria-valuenow\` is absent on the first render tick. The tests use a two-tick \`flush()\` helper (\`await nextTick()\` twice) to wait it out — no mocking of Reka required. - Keyboard drag: \`user.keyboard('{ArrowRight}')\` / \`'{ArrowLeft}'\` moves the value; with \`step: 10\` starting from 50, ArrowRight produces exactly \`[60]\`. - \`disabled\` → no emit on keyboard events. ### Reka integration limit Pointer-driven \`slide-start\` / \`slide-end\` gestures in happy-dom would require faking \`getBoundingClientRect\` and \`setPointerCapture\` — that crosses into mocking Reka internals. Keyboard-drag paths are covered instead (the user-facing contract); the \`pressed\` CSS state is exercised implicitly by surviving a full mount + update cycle. ## Principles applied - No mocks of Vue, Reka, or \`@vueuse/core\`. - Queries via \`getByRole\` / \`getByLabelText\`; **no** class-name or Tailwind-token queries (per AGENTS.md). - All 21 tests pass; typecheck/lint/format clean. Test-only; no production code touched. --- src/components/ui/button/Button.test.ts | 86 ++++++++++++ src/components/ui/slider/Slider.test.ts | 141 ++++++++++++++++++++ src/components/ui/textarea/Textarea.test.ts | 71 ++++++++++ 3 files changed, 298 insertions(+) create mode 100644 src/components/ui/button/Button.test.ts create mode 100644 src/components/ui/slider/Slider.test.ts create mode 100644 src/components/ui/textarea/Textarea.test.ts diff --git a/src/components/ui/button/Button.test.ts b/src/components/ui/button/Button.test.ts new file mode 100644 index 0000000000..cb986f98f3 --- /dev/null +++ b/src/components/ui/button/Button.test.ts @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import Button from './Button.vue' + +describe('Button', () => { + it('renders slot content inside a button by default', () => { + render(Button, { + slots: { default: 'Click me' } + }) + + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() + }) + + it('fires click events when enabled', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + + render(Button, { + slots: { default: 'Click me' }, + attrs: { onClick } + }) + + await user.click(screen.getByRole('button', { name: 'Click me' })) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('hides slot content, shows a spinner, and disables the button while loading', () => { + const { container } = render(Button, { + props: { loading: true }, + slots: { default: 'Submit' } + }) + + expect(screen.queryByText('Submit')).not.toBeInTheDocument() + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue spinner icon has no accessible role + expect(container.querySelector('.pi-spin')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('does not fire click when loading', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + + render(Button, { + props: { loading: true }, + attrs: { onClick } + }) + + await user.click(screen.getByRole('button')) + + expect(onClick).not.toHaveBeenCalled() + }) + + it('disables the button when disabled prop is true', () => { + render(Button, { + props: { disabled: true }, + slots: { default: 'Nope' } + }) + + expect(screen.getByRole('button', { name: 'Nope' })).toBeDisabled() + }) + + it('renders as an anchor when as="a"', () => { + const { container } = render(Button, { + props: { as: 'a' }, + slots: { default: 'Link' } + }) + + // eslint-disable-next-line testing-library/no-node-access -- root element tag is the contract under test + const root = container.firstElementChild + expect(root?.tagName).toBe('A') + }) + + it('applies variant classes through buttonVariants', () => { + render(Button, { + props: { variant: 'primary' }, + slots: { default: 'Primary' } + }) + + expect(screen.getByRole('button', { name: 'Primary' })).toHaveClass( + 'bg-primary-background' + ) + }) +}) diff --git a/src/components/ui/slider/Slider.test.ts b/src/components/ui/slider/Slider.test.ts new file mode 100644 index 0000000000..f5741ef65b --- /dev/null +++ b/src/components/ui/slider/Slider.test.ts @@ -0,0 +1,141 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import Slider from './Slider.vue' + +async function flush() { + await nextTick() + await nextTick() +} + +describe('Slider', () => { + it('renders a single thumb with role="slider" for a single-value model', async () => { + render(Slider, { props: { modelValue: [50] } }) + await flush() + + const thumbs = screen.getAllByRole('slider') + expect(thumbs).toHaveLength(1) + }) + + it('renders one thumb per value for a range model', async () => { + render(Slider, { props: { modelValue: [20, 50] } }) + await flush() + + const thumbs = screen.getAllByRole('slider') + expect(thumbs).toHaveLength(2) + }) + + it('exposes min/max/step via ARIA on the thumb', async () => { + render(Slider, { + props: { modelValue: [10], min: 0, max: 200, step: 5 } + }) + await flush() + + const thumb = screen.getByRole('slider') + expect(thumb).toHaveAttribute('aria-valuemin', '0') + expect(thumb).toHaveAttribute('aria-valuemax', '200') + expect(thumb).toHaveAttribute('aria-valuenow', '10') + }) + + it('emits update:modelValue with an increased value on ArrowRight', async () => { + const user = userEvent.setup() + const onUpdate = vi.fn<(value: number[] | undefined) => void>() + + render(Slider, { + props: { + modelValue: [50], + min: 0, + max: 100, + step: 1, + 'onUpdate:modelValue': onUpdate + } + }) + await flush() + + screen.getByRole('slider').focus() + await user.keyboard('{ArrowRight}') + + expect(onUpdate).toHaveBeenCalled() + const latest = onUpdate.mock.calls.at(-1)?.[0] + expect(latest?.[0]).toBeGreaterThan(50) + }) + + it('emits update:modelValue with a decreased value on ArrowLeft', async () => { + const user = userEvent.setup() + const onUpdate = vi.fn<(value: number[] | undefined) => void>() + + render(Slider, { + props: { + modelValue: [50], + min: 0, + max: 100, + step: 1, + 'onUpdate:modelValue': onUpdate + } + }) + await flush() + + screen.getByRole('slider').focus() + await user.keyboard('{ArrowLeft}') + + expect(onUpdate).toHaveBeenCalled() + const latest = onUpdate.mock.calls.at(-1)?.[0] + expect(latest?.[0]).toBeLessThan(50) + }) + + it('respects step size when emitting updates', async () => { + const user = userEvent.setup() + const onUpdate = vi.fn<(value: number[] | undefined) => void>() + + render(Slider, { + props: { + modelValue: [50], + min: 0, + max: 100, + step: 10, + 'onUpdate:modelValue': onUpdate + } + }) + await flush() + + screen.getByRole('slider').focus() + await user.keyboard('{ArrowRight}') + + expect(onUpdate).toHaveBeenCalledWith([60]) + }) + + it('marks the root as disabled when disabled prop is set', async () => { + const { container } = render(Slider, { + props: { modelValue: [30], disabled: true } + }) + await flush() + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Reka exposes disabled state as a data attribute on the root + const root = container.querySelector('[data-slot="slider"]') + expect(root).toHaveAttribute('data-disabled') + }) + + it('does not emit updates via keyboard when disabled', async () => { + const user = userEvent.setup() + const onUpdate = vi.fn() + + render(Slider, { + props: { + modelValue: [50], + min: 0, + max: 100, + step: 1, + disabled: true, + 'onUpdate:modelValue': onUpdate + } + }) + await flush() + + screen.getByRole('slider').focus() + await user.keyboard('{ArrowRight}') + + expect(onUpdate).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/ui/textarea/Textarea.test.ts b/src/components/ui/textarea/Textarea.test.ts new file mode 100644 index 0000000000..bcb8fb40dc --- /dev/null +++ b/src/components/ui/textarea/Textarea.test.ts @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import Textarea from './Textarea.vue' + +describe('Textarea', () => { + it('renders a textarea element', () => { + render(Textarea) + + expect(screen.getByRole('textbox')).toBeInstanceOf(HTMLTextAreaElement) + }) + + it('populates the textarea with the initial v-model value', () => { + render(Textarea, { props: { modelValue: 'initial text' } }) + + expect(screen.getByRole('textbox')).toHaveValue('initial text') + }) + + it('emits update:modelValue as the user types', async () => { + const user = userEvent.setup() + const onUpdate = vi.fn<(value: string | number | undefined) => void>() + + render(Textarea, { + props: { + modelValue: '', + 'onUpdate:modelValue': onUpdate + } + }) + + await user.type(screen.getByRole('textbox'), 'hi') + + expect(onUpdate).toHaveBeenCalled() + expect(onUpdate.mock.calls.at(-1)?.[0]).toBe('hi') + }) + + it('forwards placeholder and rows attrs to the native textarea', () => { + render(Textarea, { + attrs: { placeholder: 'Write something', rows: 6 } + }) + + const textarea = screen.getByPlaceholderText('Write something') + expect(textarea).toHaveAttribute('rows', '6') + }) + + it('does not accept typed input when disabled', async () => { + const user = userEvent.setup() + const onUpdate = vi.fn() + + render(Textarea, { + props: { + modelValue: '', + 'onUpdate:modelValue': onUpdate + }, + attrs: { disabled: true } + }) + + const textarea = screen.getByRole('textbox') + expect(textarea).toBeDisabled() + await user.type(textarea, 'blocked') + + expect(onUpdate).not.toHaveBeenCalled() + expect(textarea).toHaveValue('') + }) + + it('forwards custom class alongside internal classes', () => { + render(Textarea, { props: { class: 'custom-extra-class' } }) + + expect(screen.getByRole('textbox')).toHaveClass('custom-extra-class') + }) +}) From 4c7729ee0b060ac660e637b19531c1e8611cf4c5 Mon Sep 17 00:00:00 2001 From: Dante Date: Sun, 19 Apr 2026 07:40:11 +0900 Subject: [PATCH 009/460] fix: remove hover dimming overlay on image nodes (#11296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Remove the black opacity/dimming overlay on image node hover and add shadows to action buttons for visibility against light backgrounds. ## Changes - **What**: Remove `opacity-50` dimming on hover in `DisplayCarousel.vue`, remove `transition-opacity hover:opacity-80` from grid thumbnails in `ImagePreview.vue`, add `shadow-md` to action buttons in `ImagePreview.vue`. Applies to Save Image, Load Image, Preview Image, and all nodes using these shared image components. ## Review Focus Button shadows (`shadow-md`) should provide sufficient contrast against light image backgrounds without needing the dimming overlay. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11296-fix-remove-hover-dimming-overlay-on-image-nodes-3446d73d36508193bb5cc27d431014fd) by [Unito](https://www.unito.io) --- .../extensions/vueNodes/components/ImagePreview.vue | 4 ++-- .../vueNodes/widgets/components/DisplayCarousel.vue | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue index 1071ee0bb5..badd047541 100644 --- a/src/renderer/extensions/vueNodes/components/ImagePreview.vue +++ b/src/renderer/extensions/vueNodes/components/ImagePreview.vue @@ -14,7 +14,7 @@ + + +
@@ -51,8 +72,8 @@ v-if="!isPreview" class="pointer-events-auto absolute right-2 z-20" :class="{ - 'top-12': !enable3DViewer, - 'top-24': enable3DViewer + 'top-24': !enable3DViewer, + 'top-36': enable3DViewer }" > ) diff --git a/src/components/load3d/Load3DControls.vue b/src/components/load3d/Load3DControls.vue index 36dad94bfd..00ee90136b 100644 --- a/src/components/load3d/Load3DControls.vue +++ b/src/components/load3d/Load3DControls.vue @@ -92,6 +92,14 @@ v-if="showExportControls" @export-model="handleExportModel" /> + + @@ -102,6 +110,7 @@ import { computed, ref } from 'vue' import CameraControls from '@/components/load3d/controls/CameraControls.vue' import { useDismissableOverlay } from '@/composables/useDismissableOverlay' import ExportControls from '@/components/load3d/controls/ExportControls.vue' +import GizmoControls from '@/components/load3d/controls/GizmoControls.vue' import HDRIControls from '@/components/load3d/controls/HDRIControls.vue' import LightControls from '@/components/load3d/controls/LightControls.vue' import ModelControls from '@/components/load3d/controls/ModelControls.vue' @@ -109,6 +118,7 @@ import SceneControls from '@/components/load3d/controls/SceneControls.vue' import Button from '@/components/ui/button/Button.vue' import type { CameraConfig, + GizmoMode, LightConfig, ModelConfig, SceneConfig @@ -148,6 +158,7 @@ const categoryLabels: Record = { model: 'load3d.model', camera: 'load3d.camera', light: 'load3d.light', + gizmo: 'load3d.gizmo.label', export: 'load3d.export' } @@ -156,7 +167,7 @@ const availableCategories = computed(() => { return ['scene', 'model', 'camera'] } - return ['scene', 'model', 'camera', 'light', 'export'] + return ['scene', 'model', 'camera', 'light', 'gizmo', 'export'] }) const showSceneControls = computed( @@ -175,6 +186,9 @@ const showLightControls = computed( !!modelConfig.value ) const showExportControls = computed(() => activeCategory.value === 'export') +const showGizmoControls = computed( + () => activeCategory.value === 'gizmo' && !!modelConfig.value +) const toggleMenu = () => { isMenuOpen.value = !isMenuOpen.value @@ -190,6 +204,7 @@ const categoryIcons = { model: 'icon-[lucide--box]', camera: 'icon-[lucide--camera]', light: 'icon-[lucide--sun]', + gizmo: 'icon-[lucide--move-3d]', export: 'icon-[lucide--download]' } as const @@ -205,6 +220,9 @@ const emit = defineEmits<{ (e: 'updateBackgroundImage', file: File | null): void (e: 'exportModel', format: string): void (e: 'updateHdriFile', file: File | null): void + (e: 'toggleGizmo', enabled: boolean): void + (e: 'setGizmoMode', mode: GizmoMode): void + (e: 'resetGizmoTransform'): void }>() const handleBackgroundImageUpdate = (file: File | null) => { @@ -218,4 +236,16 @@ const handleExportModel = (format: string) => { const handleHDRIFileUpdate = (file: File | null) => { emit('updateHdriFile', file) } + +const handleToggleGizmo = (enabled: boolean) => { + emit('toggleGizmo', enabled) +} + +const handleSetGizmoMode = (mode: GizmoMode) => { + emit('setGizmoMode', mode) +} + +const handleResetGizmoTransform = () => { + emit('resetGizmoTransform') +} diff --git a/src/components/load3d/Load3dViewerContent.vue b/src/components/load3d/Load3dViewerContent.vue index 6be0dfa6bd..b57bf38cc5 100644 --- a/src/components/load3d/Load3dViewerContent.vue +++ b/src/components/load3d/Load3dViewerContent.vue @@ -74,6 +74,14 @@ /> +
+ +
+
@@ -99,6 +107,7 @@ import { useI18n } from 'vue-i18n' import AnimationControls from '@/components/load3d/controls/AnimationControls.vue' import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue' import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue' +import GizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue' import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue' import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue' import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue' diff --git a/src/components/load3d/controls/GizmoControls.test.ts b/src/components/load3d/controls/GizmoControls.test.ts new file mode 100644 index 0000000000..83014aab94 --- /dev/null +++ b/src/components/load3d/controls/GizmoControls.test.ts @@ -0,0 +1,155 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import GizmoControls from '@/components/load3d/controls/GizmoControls.vue' +import type { GizmoConfig } from '@/extensions/core/load3d/interfaces' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + gizmo: { + toggle: 'Gizmo', + translate: 'Translate', + rotate: 'Rotate', + scale: 'Scale', + reset: 'Reset Transform' + } + } + } + } +}) + +function makeConfig(overrides: Partial = {}): GizmoConfig { + return { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + ...overrides + } +} + +function renderComponent(initial: Partial = {}) { + const gizmoConfig = ref(makeConfig(initial)) + + const utils = render(GizmoControls, { + props: { + gizmoConfig: gizmoConfig.value, + 'onUpdate:gizmoConfig': (v: GizmoConfig | undefined) => { + if (v) gizmoConfig.value = v + } + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { ...utils, gizmoConfig, user: userEvent.setup() } +} + +describe('GizmoControls', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders only the toggle button when gizmo is disabled', () => { + renderComponent({ enabled: false }) + + expect(screen.getByRole('button', { name: 'Gizmo' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Rotate' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Scale' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Reset Transform' })).toBeNull() + }) + + it('renders mode and reset buttons when gizmo is enabled', () => { + renderComponent({ enabled: true }) + + expect(screen.getByRole('button', { name: 'Translate' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Rotate' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Scale' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Reset Transform' })).toBeTruthy() + }) + + it('flips enabled and emits toggleGizmo when the toggle is clicked', async () => { + const { user, gizmoConfig, emitted } = renderComponent({ enabled: false }) + + await user.click(screen.getByRole('button', { name: 'Gizmo' })) + + expect(gizmoConfig.value.enabled).toBe(true) + expect(emitted().toggleGizmo).toEqual([[true]]) + }) + + it('turns off gizmo and emits false when toggled from enabled state', async () => { + const { user, gizmoConfig, emitted } = renderComponent({ enabled: true }) + + await user.click(screen.getByRole('button', { name: 'Gizmo' })) + + expect(gizmoConfig.value.enabled).toBe(false) + expect(emitted().toggleGizmo).toEqual([[false]]) + }) + + it.each([ + ['Translate', 'translate'], + ['Rotate', 'rotate'], + ['Scale', 'scale'] + ] as const)( + 'sets mode to %s and emits setGizmoMode when clicked', + async (label, mode) => { + const { user, gizmoConfig, emitted } = renderComponent({ enabled: true }) + + await user.click(screen.getByRole('button', { name: label })) + + expect(gizmoConfig.value.mode).toBe(mode) + expect(emitted().setGizmoMode).toEqual([[mode]]) + } + ) + + it('emits resetGizmoTransform without mutating config on reset click', async () => { + const { user, gizmoConfig, emitted } = renderComponent({ + enabled: true, + mode: 'rotate' + }) + + await user.click(screen.getByRole('button', { name: 'Reset Transform' })) + + expect(emitted().resetGizmoTransform).toEqual([[]]) + expect(gizmoConfig.value.mode).toBe('rotate') + expect(gizmoConfig.value.enabled).toBe(true) + }) + + it('highlights the active mode button with a ring', () => { + renderComponent({ enabled: true, mode: 'rotate' }) + + const translate = screen.getByRole('button', { name: 'Translate' }) + const rotate = screen.getByRole('button', { name: 'Rotate' }) + const scale = screen.getByRole('button', { name: 'Scale' }) + + expect(rotate.className).toContain('ring-2') + expect(translate.className).not.toContain('ring-2') + expect(scale.className).not.toContain('ring-2') + }) + + it('does nothing when clicked with no model value bound', async () => { + const user = userEvent.setup() + const { emitted } = render(GizmoControls, { + props: { gizmoConfig: undefined }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + await user.click(screen.getByRole('button', { name: 'Gizmo' })) + + expect(emitted().toggleGizmo).toBeUndefined() + }) +}) diff --git a/src/components/load3d/controls/GizmoControls.vue b/src/components/load3d/controls/GizmoControls.vue new file mode 100644 index 0000000000..d57d07a633 --- /dev/null +++ b/src/components/load3d/controls/GizmoControls.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/components/load3d/controls/viewer/ViewerGizmoControls.test.ts b/src/components/load3d/controls/viewer/ViewerGizmoControls.test.ts new file mode 100644 index 0000000000..0adb09312d --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerGizmoControls.test.ts @@ -0,0 +1,133 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import ViewerGizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue' +import type { GizmoMode } from '@/extensions/core/load3d/interfaces' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { on: 'On', off: 'Off' }, + load3d: { + gizmo: { + toggle: 'Gizmo', + translate: 'Translate', + rotate: 'Rotate', + scale: 'Scale', + reset: 'Reset Transform' + } + } + } + } +}) + +function renderComponent( + initial: { enabled?: boolean; mode?: GizmoMode } = {} +) { + const enabled = ref(initial.enabled ?? false) + const mode = ref(initial.mode ?? 'translate') + + const utils = render(ViewerGizmoControls, { + props: { + gizmoEnabled: enabled.value, + 'onUpdate:gizmoEnabled': (v: boolean | undefined) => { + if (v !== undefined) enabled.value = v + }, + gizmoMode: mode.value, + 'onUpdate:gizmoMode': (v: GizmoMode | undefined) => { + if (v) mode.value = v + } + }, + global: { + plugins: [i18n] + } + }) + + return { ...utils, enabled, mode, user: userEvent.setup() } +} + +describe('ViewerGizmoControls', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders only the on/off toggle when gizmo is disabled', () => { + renderComponent({ enabled: false }) + + expect(screen.getByText('Gizmo')).toBeTruthy() + expect(screen.getByText('Off')).toBeTruthy() + expect(screen.getByText('On')).toBeTruthy() + + expect(screen.queryByText('Translate')).toBeNull() + expect(screen.queryByText('Rotate')).toBeNull() + expect(screen.queryByText('Scale')).toBeNull() + expect(screen.queryByText('Reset Transform')).toBeNull() + }) + + it('renders mode toggles and reset button when gizmo is enabled', () => { + renderComponent({ enabled: true }) + + expect(screen.getByText('Translate')).toBeTruthy() + expect(screen.getByText('Rotate')).toBeTruthy() + expect(screen.getByText('Scale')).toBeTruthy() + expect(screen.getByText('Reset Transform')).toBeTruthy() + }) + + it('enables gizmo when the On item is clicked', async () => { + const { user, enabled } = renderComponent({ enabled: false }) + + await user.click(screen.getByText('On')) + + expect(enabled.value).toBe(true) + }) + + it('disables gizmo when the Off item is clicked from an enabled state', async () => { + const { user, enabled } = renderComponent({ enabled: true }) + + await user.click(screen.getByText('Off')) + + expect(enabled.value).toBe(false) + }) + + it.each([ + ['Translate', 'translate'], + ['Rotate', 'rotate'], + ['Scale', 'scale'] + ] as const)( + 'updates mode to %s when its toggle item is clicked', + async (label, expected) => { + const { user, mode } = renderComponent({ + enabled: true, + mode: 'translate' + }) + + await user.click(screen.getByText(label)) + + expect(mode.value).toBe(expected) + } + ) + + it('emits reset-transform when the reset button is clicked', async () => { + const { user, emitted } = renderComponent({ + enabled: true, + mode: 'rotate' + }) + + await user.click(screen.getByRole('button', { name: /reset transform/i })) + + expect(emitted()['reset-transform']).toEqual([[]]) + }) + + it('leaves mode unchanged when deselecting the active mode', async () => { + const { user, mode } = renderComponent({ enabled: true, mode: 'scale' }) + + await user.click(screen.getByText('Scale')) + + expect(mode.value).toBe('scale') + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerGizmoControls.vue b/src/components/load3d/controls/viewer/ViewerGizmoControls.vue new file mode 100644 index 0000000000..086c8d6b97 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerGizmoControls.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index 153ec60a98..c96414a4ec 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -146,6 +146,12 @@ describe('useLoad3d', () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), remove: vi.fn(), + setGizmoEnabled: vi.fn(), + setGizmoMode: vi.fn(), + resetGizmoTransform: vi.fn(), + applyGizmoTransform: vi.fn(), + fitToViewer: vi.fn(), + setAnimationTime: vi.fn(), renderer: { domElement: mockCanvas } as Partial as Load3d['renderer'] @@ -169,38 +175,6 @@ describe('useLoad3d', () => { }) describe('initialization', () => { - it('should initialize with default values', () => { - const composable = useLoad3d(mockNode) - - expect(composable.sceneConfig.value).toEqual({ - showGrid: true, - backgroundColor: '#000000', - backgroundImage: '', - backgroundRenderMode: 'tiled' - }) - expect(composable.modelConfig.value).toEqual({ - upDirection: 'original', - materialMode: 'original', - showSkeleton: false - }) - expect(composable.cameraConfig.value).toEqual({ - cameraType: 'perspective', - fov: 75 - }) - expect(composable.lightConfig.value).toEqual({ - intensity: 5, - hdri: { - enabled: false, - hdriPath: '', - showAsBackground: false, - intensity: 1 - } - }) - expect(composable.isRecording.value).toBe(false) - expect(composable.hasRecording.value).toBe(false) - expect(composable.loading.value).toBe(false) - }) - it('should initialize Load3d with container and node', async () => { const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') @@ -229,8 +203,6 @@ describe('useLoad3d', () => { expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true) expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000') expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled') - expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original') - expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original') expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective') expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75) expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5) @@ -271,53 +243,29 @@ describe('useLoad3d', () => { expect(mockLoad3d.renderer!.domElement.hidden).toBe(true) }) - it('should load model if model_file widget exists', async () => { + it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => { mockNode.widgets!.push({ name: 'model_file', value: 'test.glb', type: 'text' } as IWidget) - vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ - 'subfolder', - 'test.glb' - ]) - vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/test.glb' - ) - vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/test.glb' - ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') await composable.initializeLoad3d(containerRef) - expect(mockLoad3d.loadModel).toHaveBeenCalledWith( - 'http://localhost/api/view/test.glb' - ) + expect(mockLoad3d.loadModel).not.toHaveBeenCalled() + expect(nodeToLoad3dMap.has(mockNode)).toBe(true) }) - it('should restore camera state after loading model', async () => { - mockNode.widgets!.push({ - name: 'model_file', - value: 'test.glb', - type: 'text' - } as IWidget) - ;(mockNode.properties!['Camera Config'] as { state: unknown }).state = { + it('should restore camera config from node properties', async () => { + ;( + mockNode.properties!['Camera Config'] as Record + ).state = { position: { x: 1, y: 2, z: 3 }, target: { x: 0, y: 0, z: 0 } } - vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ - 'subfolder', - 'test.glb' - ]) - vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/test.glb' - ) - vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/test.glb' - ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') @@ -325,7 +273,7 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) await nextTick() - expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({ + expect(composable.cameraConfig.value.state).toEqual({ position: { x: 1, y: 2, z: 3 }, target: { x: 0, y: 0, z: 0 } }) @@ -460,11 +408,13 @@ describe('useLoad3d', () => { expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y') expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe') - expect(mockNode.properties['Model Config']).toEqual({ - upDirection: '+y', - materialMode: 'wireframe', - showSkeleton: false - }) + const savedModelConfig = mockNode.properties['Model Config'] as Record< + string, + unknown + > + expect(savedModelConfig.upDirection).toBe('+y') + expect(savedModelConfig.materialMode).toBe('wireframe') + expect(savedModelConfig.showSkeleton).toBe(false) }) it('should update camera config when values change', async () => { @@ -862,79 +812,72 @@ describe('useLoad3d', () => { }) }) - describe('getModelUrl', () => { - it('should handle http URLs directly', async () => { - mockNode.widgets!.push({ - name: 'model_file', - value: 'http://example.com/model.glb', - type: 'text' - } as IWidget) - - const composable = useLoad3d(mockNode) - const containerRef = document.createElement('div') - - await composable.initializeLoad3d(containerRef) - - expect(mockLoad3d.loadModel).toHaveBeenCalledWith( - 'http://example.com/model.glb' - ) - }) - - it('should construct URL for local files', async () => { - mockNode.widgets!.push({ - name: 'model_file', - value: 'models/test.glb', - type: 'text' - } as IWidget) + describe('handleModelDrop', () => { + it('should upload file, construct URL, and load model', async () => { + vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb') vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ - 'models', - 'test.glb' + 'uploaded', + 'model.glb' ]) vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/models/test.glb' + '/api/view/uploaded/model.glb' ) vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/models/test.glb' + 'http://localhost/api/view/uploaded/model.glb' ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') - await composable.initializeLoad3d(containerRef) - expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb') - expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith( - 'models', - 'test.glb', - 'input' - ) - expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb') + const file = new File([''], 'model.glb', { + type: 'model/gltf-binary' + }) + await composable.handleModelDrop(file) + + expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d') expect(mockLoad3d.loadModel).toHaveBeenCalledWith( - 'http://localhost/api/view/models/test.glb' + 'http://localhost/api/view/uploaded/model.glb' ) }) - it('should use output type for preview mode', async () => { - mockNode.widgets = [ - { name: 'model_file', value: 'test.glb', type: 'text' } as IWidget - ] // No width/height widgets - vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb']) + it('should use resource folder for upload subfolder', async () => { + mockNode.properties['Resource Folder'] = 'subfolder' + vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb') + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([ + 'uploaded', + 'model.glb' + ]) vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( - '/api/view/test.glb' + '/api/view/uploaded/model.glb' ) vi.mocked(api.apiURL).mockReturnValue( - 'http://localhost/api/view/test.glb' + 'http://localhost/api/view/uploaded/model.glb' ) const composable = useLoad3d(mockNode) const containerRef = document.createElement('div') - await composable.initializeLoad3d(containerRef) - expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith( - '', - 'test.glb', - 'output' + const file = new File([''], 'model.glb', { + type: 'model/gltf-binary' + }) + await composable.handleModelDrop(file) + + expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder') + }) + + it('should not load model when load3d is not initialized', async () => { + const composable = useLoad3d(mockNode) + + const file = new File([''], 'model.glb', { + type: 'model/gltf-binary' + }) + await composable.handleModelDrop(file) + + expect(mockLoad3d.loadModel).not.toHaveBeenCalled() + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.no3dScene' ) }) }) @@ -1071,4 +1014,241 @@ describe('useLoad3d', () => { expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg') }) }) + + describe('gizmo controls', () => { + it('should include default gizmo config in modelConfig', () => { + const composable = useLoad3d(mockNode) + + expect(composable.modelConfig.value.gizmo).toEqual({ + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + }) + }) + + it('should restore gizmo config from node properties', async () => { + ;(mockNode.properties!['Model Config'] as Record).gizmo = + { + enabled: true, + mode: 'rotate', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 }, + scale: { x: 2, y: 2, z: 2 } + } + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + expect(composable.modelConfig.value.gizmo).toEqual({ + enabled: true, + mode: 'rotate', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 }, + scale: { x: 2, y: 2, z: 2 } + }) + }) + + it('should add default gizmo config when missing from saved config', async () => { + mockNode.properties!['Model Config'] = { + upDirection: 'original', + materialMode: 'original', + showSkeleton: false + } + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + expect(composable.modelConfig.value.gizmo).toBeDefined() + expect(composable.modelConfig.value.gizmo!.enabled).toBe(false) + }) + + it('should add default scale when gizmo config lacks scale', async () => { + ;(mockNode.properties!['Model Config'] as Record).gizmo = + { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 } + } + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + expect(composable.modelConfig.value.gizmo!.scale).toEqual({ + x: 1, + y: 1, + z: 1 + }) + }) + + it('handleToggleGizmo should enable gizmo and update config', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + + expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true) + expect(composable.modelConfig.value.gizmo!.enabled).toBe(true) + }) + + it('handleToggleGizmo should disable gizmo and update config', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + composable.handleToggleGizmo(false) + + expect(mockLoad3d.setGizmoEnabled).toHaveBeenLastCalledWith(false) + expect(composable.modelConfig.value.gizmo!.enabled).toBe(false) + }) + + it('handleSetGizmoMode should set mode and update config', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleSetGizmoMode('rotate') + + expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate') + expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate') + }) + + it('handleResetGizmoTransform should call resetGizmoTransform', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleResetGizmoTransform() + + expect(mockLoad3d.resetGizmoTransform).toHaveBeenCalled() + }) + + it('should persist gizmo config to node properties via modelConfig watcher', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + composable.handleSetGizmoMode('rotate') + await nextTick() + + const savedConfig = mockNode.properties['Model Config'] as { + gizmo: { enabled: boolean; mode: string } + } + expect(savedConfig.gizmo.enabled).toBe(true) + expect(savedConfig.gizmo.mode).toBe('rotate') + }) + + it('should register gizmoTransformChange event handler', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls + const gizmoEventCall = addEventCalls.find( + ([event]) => event === 'gizmoTransformChange' + ) + expect(gizmoEventCall).toBeDefined() + }) + + it('gizmoTransformChange event should update modelConfig', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls + const gizmoEventCall = addEventCalls.find( + ([event]) => event === 'gizmoTransformChange' + ) + const handler = gizmoEventCall![1] as (data: unknown) => void + + handler({ + position: { x: 5, y: 6, z: 7 }, + rotation: { x: 0.5, y: 0.6, z: 0.7 }, + scale: { x: 3, y: 3, z: 3 }, + enabled: true, + mode: 'rotate' + }) + + expect(composable.modelConfig.value.gizmo!.position).toEqual({ + x: 5, + y: 6, + z: 7 + }) + expect(composable.modelConfig.value.gizmo!.rotation).toEqual({ + x: 0.5, + y: 0.6, + z: 0.7 + }) + expect(composable.modelConfig.value.gizmo!.scale).toEqual({ + x: 3, + y: 3, + z: 3 + }) + expect(composable.modelConfig.value.gizmo!.enabled).toBe(true) + expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate') + }) + + it('should reset gizmo config on model switch (not first load)', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + + await composable.initializeLoad3d(containerRef) + + composable.handleToggleGizmo(true) + composable.handleSetGizmoMode('rotate') + + const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls + const loadingStartCall = addEventCalls.find( + ([event]) => event === 'modelLoadingStart' + ) + const loadingStartHandler = loadingStartCall![1] as () => void + + const loadingEndCall = addEventCalls.find( + ([event]) => event === 'modelLoadingEnd' + ) + const loadingEndHandler = loadingEndCall![1] as () => void + loadingEndHandler() + + loadingStartHandler() + + expect(composable.modelConfig.value.gizmo).toEqual({ + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + }) + }) + + it('should not call gizmo methods when load3d is not initialized', () => { + const composable = useLoad3d(mockNode) + + // These should not throw + composable.handleToggleGizmo(true) + composable.handleSetGizmoMode('rotate') + composable.handleResetGizmoTransform() + + expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled() + expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled() + expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 23ffafcdb2..929d2088ec 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -2,7 +2,7 @@ import type { MaybeRef } from 'vue' import { toRef } from '@vueuse/core' import { getActivePinia } from 'pinia' -import { nextTick, ref, toRaw, watch } from 'vue' +import { ref, toRaw, watch } from 'vue' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' @@ -16,6 +16,8 @@ import type { CameraState, CameraType, EventCallback, + GizmoConfig, + GizmoMode, LightConfig, MaterialMode, ModelConfig, @@ -38,6 +40,7 @@ const pendingCallbacks = new Map() export const useLoad3d = (nodeOrRef: MaybeRef) => { const nodeRef = toRef(nodeOrRef) let load3d: Load3d | null = null + let isFirstModelLoad = true const sceneConfig = ref({ showGrid: true, @@ -49,7 +52,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { const modelConfig = ref({ upDirection: 'original', materialMode: 'original', - showSkeleton: false + showSkeleton: false, + gizmo: { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } }) const hasSkeleton = ref(false) @@ -183,11 +193,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { const savedModelConfig = node.properties['Model Config'] as ModelConfig if (savedModelConfig) { - modelConfig.value = savedModelConfig + modelConfig.value = { + ...savedModelConfig, + gizmo: savedModelConfig.gizmo + ? { + ...savedModelConfig.gizmo, + scale: savedModelConfig.gizmo.scale ?? { x: 1, y: 1, z: 1 } + } + : { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } + } } const savedCameraConfig = node.properties['Camera Config'] as CameraConfig - const cameraStateToRestore = savedCameraConfig?.state if (savedCameraConfig) { cameraConfig.value = savedCameraConfig @@ -235,31 +258,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } - const modelWidget = node.widgets?.find((w) => w.name === 'model_file') - if (modelWidget?.value) { - const modelUrl = getModelUrl(modelWidget.value as string) - if (modelUrl) { - loading.value = true - loadingMessage.value = t('load3d.reloadingModel') - try { - await load3d.loadModel(modelUrl) - - if (cameraStateToRestore) { - await nextTick() - load3d.setCameraState(cameraStateToRestore) - } - } catch (error) { - console.error('Failed to reload model:', error) - useToastStore().addAlert(t('toastMessages.failedToLoadModel')) - } finally { - loading.value = false - loadingMessage.value = '' - } - } - } else if (cameraStateToRestore) { - load3d.setCameraState(cameraStateToRestore) - } - applySceneConfigToLoad3d() applyLightConfigToLoad3d() } @@ -276,6 +274,31 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } + const applyGizmoConfigToLoad3d = () => { + if (!load3d) return + const gizmo = modelConfig.value.gizmo + if (!gizmo) return + const hasTransform = + gizmo.position.x !== 0 || + gizmo.position.y !== 0 || + gizmo.position.z !== 0 || + gizmo.rotation.x !== 0 || + gizmo.rotation.y !== 0 || + gizmo.rotation.z !== 0 || + gizmo.scale.x !== 1 || + gizmo.scale.y !== 1 || + gizmo.scale.z !== 1 + if (hasTransform) { + load3d.applyGizmoTransform(gizmo.position, gizmo.rotation, gizmo.scale) + } + if (gizmo.enabled) { + load3d.setGizmoEnabled(true) + } + if (gizmo.mode !== 'translate') { + load3d.setGizmoMode(gizmo.mode) + } + } + const applyLightConfigToLoad3d = () => { if (!load3d) return const cfg = lightConfig.value @@ -294,29 +317,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } - const getModelUrl = (modelPath: string): string | null => { - if (!modelPath) return null - - try { - if (modelPath.startsWith('http')) { - return modelPath - } - - const trimmed = modelPath.trim() - const hasOutputSuffix = trimmed.endsWith('[output]') - const cleanPath = hasOutputSuffix - ? trimmed.replace(/\s*\[output\]$/, '') - : trimmed - const type = hasOutputSuffix || isPreview.value ? 'output' : 'input' - - const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath) - return api.apiURL(Load3dUtils.getResourceURL(subfolder, filename, type)) - } catch (error) { - console.error('Failed to construct model URL:', error) - return null - } - } - const waitForLoad3d = (callback: Load3dReadyCallback) => { const rawNode = toRaw(nodeRef.value) if (!rawNode) return @@ -380,16 +380,34 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { watch( modelConfig, (newValue) => { - if (load3d && nodeRef.value) { + if (nodeRef.value) { nodeRef.value.properties['Model Config'] = newValue - load3d.setUpDirection(newValue.upDirection) - load3d.setMaterialMode(newValue.materialMode) - load3d.setShowSkeleton(newValue.showSkeleton) } }, { deep: true } ) + watch( + () => modelConfig.value.upDirection, + (newValue) => { + if (load3d) load3d.setUpDirection(newValue) + } + ) + + watch( + () => modelConfig.value.materialMode, + (newValue) => { + if (load3d) load3d.setMaterialMode(newValue) + } + ) + + watch( + () => modelConfig.value.showSkeleton, + (newValue) => { + if (load3d) load3d.setShowSkeleton(newValue) + } + ) + watch( cameraConfig, (newValue) => { @@ -741,6 +759,20 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { modelLoadingStart: () => { loadingMessage.value = t('load3d.loadingModel') loading.value = true + if (!isFirstModelLoad) { + modelConfig.value = { + upDirection: 'original', + materialMode: 'original', + showSkeleton: false, + gizmo: { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } + } + } }, modelLoadingEnd: () => { loadingMessage.value = '' @@ -748,8 +780,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { isSplatModel.value = load3d?.isSplatModel() ?? false isPlyModel.value = load3d?.isPlyModel() ?? false hasSkeleton.value = load3d?.hasSkeleton() ?? false - // Reset skeleton visibility when loading new model - modelConfig.value.showSkeleton = false + applyGizmoConfigToLoad3d() + isFirstModelLoad = false if (load3d && isAssetPreviewSupported()) { const node = nodeRef.value @@ -816,9 +848,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } } + }, + gizmoTransformChange: (data: GizmoConfig) => { + if (modelConfig.value.gizmo && nodeRef.value) { + modelConfig.value.gizmo.position = data.position + modelConfig.value.gizmo.rotation = data.rotation + modelConfig.value.gizmo.scale = data.scale + modelConfig.value.gizmo.enabled = data.enabled + modelConfig.value.gizmo.mode = data.mode + } } } as const + const handleToggleGizmo = (enabled: boolean) => { + if (load3d && modelConfig.value.gizmo) { + modelConfig.value.gizmo.enabled = enabled + load3d.setGizmoEnabled(enabled) + } + } + + const handleSetGizmoMode = (mode: GizmoMode) => { + if (load3d && modelConfig.value.gizmo) { + modelConfig.value.gizmo.mode = mode + load3d.setGizmoMode(mode) + } + } + + const handleFitToViewer = () => { + if (load3d) { + load3d.fitToViewer() + } + } + + const handleResetGizmoTransform = () => { + if (load3d) { + load3d.resetGizmoTransform() + } + } + const handleEvents = (action: 'add' | 'remove') => { Object.entries(eventConfig).forEach(([event, handler]) => { const method = `${action}EventListener` as const @@ -878,6 +945,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { handleHDRIFileUpdate, handleExportModel, handleModelDrop, + handleToggleGizmo, + handleSetGizmoMode, + handleResetGizmoTransform, + handleFitToViewer, cleanup } } diff --git a/src/composables/useLoad3dViewer.test.ts b/src/composables/useLoad3dViewer.test.ts index 5b8f424300..1866bdf68f 100644 --- a/src/composables/useLoad3dViewer.test.ts +++ b/src/composables/useLoad3dViewer.test.ts @@ -110,7 +110,15 @@ describe('useLoad3dViewer', () => { addEventListener: vi.fn(), hasAnimations: vi.fn().mockReturnValue(false), isSplatModel: vi.fn().mockReturnValue(false), - isPlyModel: vi.fn().mockReturnValue(false) + isPlyModel: vi.fn().mockReturnValue(false), + setGizmoEnabled: vi.fn(), + setGizmoMode: vi.fn(), + setBackgroundRenderMode: vi.fn(), + getGizmoTransform: vi.fn().mockReturnValue({ + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + }) } mockSourceLoad3d = { @@ -163,20 +171,6 @@ describe('useLoad3dViewer', () => { }) describe('initialization', () => { - it('should initialize with default values', () => { - const viewer = useLoad3dViewer(mockNode) - - expect(viewer.backgroundColor.value).toBe('') - expect(viewer.showGrid.value).toBe(true) - expect(viewer.cameraType.value).toBe('perspective') - expect(viewer.fov.value).toBe(75) - expect(viewer.lightIntensity.value).toBe(1) - expect(viewer.backgroundImage.value).toBe('') - expect(viewer.hasBackgroundImage.value).toBe(false) - expect(viewer.upDirection.value).toBe('original') - expect(viewer.materialMode.value).toBe('original') - }) - it('should initialize viewer with source Load3d state', async () => { const viewer = useLoad3dViewer(mockNode) const containerRef = document.createElement('div') @@ -240,104 +234,7 @@ describe('useLoad3dViewer', () => { }) }) - describe('state watchers', () => { - it('should update background color when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.backgroundColor.value = '#ff0000' - await nextTick() - - expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000') - }) - - it('should update grid visibility when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.showGrid.value = false - await nextTick() - - expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false) - }) - - it('should update camera type when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.cameraType.value = 'orthographic' - await nextTick() - - expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic') - }) - - it('should update FOV when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.fov.value = 90 - await nextTick() - - expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90) - }) - - it('should update light intensity when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.lightIntensity.value = 2 - await nextTick() - - expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2) - }) - - it('should update background image when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.backgroundImage.value = 'new-bg.jpg' - await nextTick() - - expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg') - expect(viewer.hasBackgroundImage.value).toBe(true) - }) - - it('should update up direction when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.upDirection.value = '+y' - await nextTick() - - expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y') - }) - - it('should update material mode when state changes', async () => { - const viewer = useLoad3dViewer(mockNode) - const containerRef = document.createElement('div') - - await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - - viewer.materialMode.value = 'wireframe' - await nextTick() - - expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe') - }) - + describe('error handling', () => { it('should handle watcher errors gracefully', async () => { vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce( function () { @@ -749,4 +646,118 @@ describe('useLoad3dViewer', () => { expect(newViewer.backgroundColor.value).toBe('#0000ff') }) }) + + describe('gizmo controls', () => { + it('should initialize gizmo state from node model config', async () => { + ;(mockNode.properties!['Model Config'] as Record).gizmo = + { + enabled: true, + mode: 'rotate' + } + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + expect(viewer.gizmoEnabled.value).toBe(true) + expect(viewer.gizmoMode.value).toBe('rotate') + }) + + it('should default gizmo to disabled translate when no config', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + expect(viewer.gizmoEnabled.value).toBe(false) + expect(viewer.gizmoMode.value).toBe('translate') + }) + + it('should persist gizmo state in applyChanges', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + viewer.gizmoEnabled.value = true + viewer.gizmoMode.value = 'rotate' + + await viewer.applyChanges() + + const modelConfig = mockNode.properties!['Model Config'] as Record< + string, + unknown + > + const gizmo = modelConfig.gizmo as Record + expect(gizmo.enabled).toBe(true) + expect(gizmo.mode).toBe('rotate') + }) + + it('should save gizmo transform from load3d in applyChanges', async () => { + vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue({ + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 }, + scale: { x: 2, y: 2, z: 2 } + }) + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + await viewer.applyChanges() + + const modelConfig = mockNode.properties!['Model Config'] as Record< + string, + unknown + > + const gizmo = modelConfig.gizmo as { + position: { x: number; y: number; z: number } + rotation: { x: number; y: number; z: number } + scale: { x: number; y: number; z: number } + } + expect(gizmo.position).toEqual({ x: 1, y: 2, z: 3 }) + expect(gizmo.rotation).toEqual({ x: 0.1, y: 0.2, z: 0.3 }) + expect(gizmo.scale).toEqual({ x: 2, y: 2, z: 2 }) + }) + + it('should restore gizmo state in restoreInitialState', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + viewer.gizmoEnabled.value = true + viewer.gizmoMode.value = 'rotate' + + viewer.restoreInitialState() + + const modelConfig = mockNode.properties!['Model Config'] as Record< + string, + unknown + > + const gizmo = modelConfig.gizmo as Record + expect(gizmo.enabled).toBe(false) + expect(gizmo.mode).toBe('translate') + }) + + it('should restore gizmo state from standalone config cache', async () => { + const viewer = useLoad3dViewer() + const containerRef = document.createElement('div') + const model1 = 'gizmo_model1.glb' + + await viewer.initializeStandaloneViewer(containerRef, model1) + viewer.gizmoEnabled.value = true + viewer.gizmoMode.value = 'rotate' + await nextTick() + + viewer.cleanup() + + const restoredViewer = useLoad3dViewer() + await restoredViewer.initializeStandaloneViewer(containerRef, model1) + expect(restoredViewer.gizmoEnabled.value).toBe(true) + expect(restoredViewer.gizmoMode.value).toBe('rotate') + }) + }) }) diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts index 301b0bd2a9..f9126e4625 100644 --- a/src/composables/useLoad3dViewer.ts +++ b/src/composables/useLoad3dViewer.ts @@ -9,6 +9,7 @@ import type { CameraConfig, CameraState, CameraType, + GizmoMode, LightConfig, MaterialMode, ModelConfig, @@ -32,6 +33,8 @@ interface Load3dViewerState { backgroundRenderMode: BackgroundRenderModeType upDirection: UpDirection materialMode: MaterialMode + gizmoEnabled: boolean + gizmoMode: GizmoMode } const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = { @@ -44,7 +47,9 @@ const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = { backgroundImage: '', backgroundRenderMode: 'tiled', upDirection: 'original', - materialMode: 'original' + materialMode: 'original', + gizmoEnabled: false, + gizmoMode: 'translate' } const standaloneConfigCache = new QuickLRU({ @@ -69,6 +74,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { const backgroundRenderMode = ref('tiled') const upDirection = ref('original') const materialMode = ref('original') + const gizmoEnabled = ref(false) + const gizmoMode = ref('translate') const needApplyChanges = ref(true) const isPreview = ref(false) const isStandaloneMode = ref(false) @@ -98,7 +105,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundImage: '', backgroundRenderMode: 'tiled', upDirection: 'original', - materialMode: 'original' + materialMode: 'original', + gizmoEnabled: false, + gizmoMode: 'translate' }) watch(backgroundColor, (newColor) => { @@ -273,6 +282,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => { } } + watch(gizmoEnabled, (newValue) => { + if (load3d) { + load3d.setGizmoEnabled(newValue) + } + }) + + watch(gizmoMode, (newValue) => { + if (load3d) { + load3d.setGizmoMode(newValue) + } + }) + /** * Initializes the viewer in node mode using a source Load3d instance. * @@ -367,6 +388,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => { modelConfig.upDirection || source.modelManager.currentUpDirection materialMode.value = modelConfig.materialMode || source.modelManager.materialMode + if (modelConfig.gizmo) { + gizmoEnabled.value = modelConfig.gizmo.enabled + gizmoMode.value = modelConfig.gizmo.mode + } } isSplatModel.value = source.isSplatModel() @@ -382,7 +407,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundImage: backgroundImage.value, backgroundRenderMode: backgroundRenderMode.value, upDirection: upDirection.value, - materialMode: materialMode.value + materialMode: materialMode.value, + gizmoEnabled: gizmoEnabled.value, + gizmoMode: gizmoMode.value } setupAnimationEvents() @@ -475,7 +502,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundImage: backgroundImage.value, backgroundRenderMode: backgroundRenderMode.value, upDirection: upDirection.value, - materialMode: materialMode.value + materialMode: materialMode.value, + gizmoEnabled: gizmoEnabled.value, + gizmoMode: gizmoMode.value }) } @@ -497,6 +526,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundRenderMode.value = config.backgroundRenderMode upDirection.value = config.upDirection materialMode.value = config.materialMode + gizmoEnabled.value = config.gizmoEnabled + gizmoMode.value = config.gizmoMode if (cached?.cameraState && load3d) { load3d.setCameraState(cached.cameraState) } @@ -572,7 +603,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => { nodeValue.properties['Model Config'] = { upDirection: initialState.value.upDirection, - materialMode: initialState.value.materialMode + materialMode: initialState.value.materialMode, + gizmo: { + enabled: initialState.value.gizmoEnabled, + mode: initialState.value.gizmoMode, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } } const currentCameraConfig = nodeValue.properties['Camera Config'] as @@ -614,9 +652,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => { intensity: lightIntensity.value } + const gizmoTransform = load3d.getGizmoTransform() nodeValue.properties['Model Config'] = { upDirection: upDirection.value, - materialMode: materialMode.value + materialMode: materialMode.value, + showSkeleton: false, + gizmo: { + enabled: gizmoEnabled.value, + mode: gizmoMode.value, + position: gizmoTransform.position, + rotation: gizmoTransform.rotation, + scale: gizmoTransform.scale + } } } @@ -757,6 +804,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { backgroundRenderMode, upDirection, materialMode, + gizmoEnabled, + gizmoMode, needApplyChanges, isPreview, isStandaloneMode, @@ -784,6 +833,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => { handleBackgroundImageUpdate, handleModelDrop, handleSeek, + resetGizmoTransform: () => { + load3d?.resetGizmoTransform() + }, cleanup, hasSkeleton: false, diff --git a/src/extensions/core/load3d/CameraManager.ts b/src/extensions/core/load3d/CameraManager.ts index c92190b809..13f529bf3e 100644 --- a/src/extensions/core/load3d/CameraManager.ts +++ b/src/extensions/core/load3d/CameraManager.ts @@ -190,28 +190,40 @@ export class CameraManager implements CameraManagerInterface { } } - setupForModel(size: THREE.Vector3): void { + setupForModel( + size: THREE.Vector3, + center: THREE.Vector3 = new THREE.Vector3(0, size.y / 2, 0) + ): void { + const maxDim = Math.max(size.x, size.y, size.z) const distance = Math.max(size.x, size.z) * 2 - const height = size.y * 2 + const height = center.y + maxDim - this.perspectiveCamera.position.set(distance, height, distance) - this.orthographicCamera.position.set(distance, height, distance) + this.perspectiveCamera.position.set( + center.x + distance, + height, + center.z + distance + ) + this.orthographicCamera.position.set( + center.x + distance, + height, + center.z + distance + ) if (this.activeCamera === this.perspectiveCamera) { - this.perspectiveCamera.lookAt(0, size.y / 2, 0) + this.perspectiveCamera.lookAt(center) this.perspectiveCamera.updateProjectionMatrix() } else { - const frustumSize = Math.max(size.x, size.y, size.z) * 2 + const frustumSize = maxDim * 2 const aspect = this.perspectiveCamera.aspect this.orthographicCamera.left = (-frustumSize * aspect) / 2 this.orthographicCamera.right = (frustumSize * aspect) / 2 this.orthographicCamera.top = frustumSize / 2 this.orthographicCamera.bottom = -frustumSize / 2 - this.orthographicCamera.lookAt(0, size.y / 2, 0) + this.orthographicCamera.lookAt(center) this.orthographicCamera.updateProjectionMatrix() } - this.controls?.target.set(0, size.y / 2, 0) + this.controls?.target.copy(center) this.controls?.update() } diff --git a/src/extensions/core/load3d/GizmoManager.test.ts b/src/extensions/core/load3d/GizmoManager.test.ts new file mode 100644 index 0000000000..10e35b4c5d --- /dev/null +++ b/src/extensions/core/load3d/GizmoManager.test.ts @@ -0,0 +1,368 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { GizmoManager } from './GizmoManager' + +const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } = + vi.hoisted(() => ({ + mockSetMode: vi.fn(), + mockAttach: vi.fn(), + mockDetach: vi.fn(), + mockGetHelper: vi.fn(), + mockDispose: vi.fn() + })) + +vi.mock('three/examples/jsm/controls/TransformControls', () => { + class TransformControls { + enabled = true + camera: THREE.Camera + private listeners = new Map void)[]>() + + constructor(camera: THREE.Camera) { + this.camera = camera + } + + addEventListener(event: string, cb: (e: unknown) => void) { + if (!this.listeners.has(event)) this.listeners.set(event, []) + this.listeners.get(event)!.push(cb) + } + + setMode = mockSetMode + attach = mockAttach + detach = mockDetach + getHelper = mockGetHelper + dispose = mockDispose + + emit(event: string, data: unknown) { + for (const cb of this.listeners.get(event) ?? []) cb(data) + } + } + return { TransformControls } +}) + +vi.mock('three/examples/jsm/controls/OrbitControls', () => { + class OrbitControls { + enabled = true + } + return { OrbitControls } +}) + +function makeMockOrbitControls() { + return { enabled: true } as unknown as InstanceType< + typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls + > +} + +describe('GizmoManager', () => { + let scene: THREE.Scene + let renderer: THREE.WebGLRenderer + let camera: THREE.PerspectiveCamera + let orbitControls: ReturnType + let manager: GizmoManager + let onTransformChange: () => void + let mockHelper: THREE.Object3D + + beforeEach(() => { + vi.clearAllMocks() + + scene = new THREE.Scene() + renderer = { + domElement: document.createElement('canvas') + } as unknown as THREE.WebGLRenderer + camera = new THREE.PerspectiveCamera() + orbitControls = makeMockOrbitControls() + onTransformChange = vi.fn() + + mockHelper = new THREE.Object3D() + mockHelper.name = '' + mockHelper.renderOrder = 0 + mockGetHelper.mockReturnValue(mockHelper) + + manager = new GizmoManager( + scene, + renderer, + orbitControls, + () => camera, + onTransformChange + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('init', () => { + it('adds helper to scene with correct name and render order', () => { + manager.init() + + expect(mockGetHelper).toHaveBeenCalled() + expect(mockHelper.name).toBe('GizmoTransformControls') + expect(mockHelper.renderOrder).toBe(999) + expect(scene.children).toContain(mockHelper) + }) + }) + + describe('setupForModel', () => { + it('attaches to model and stores initial transform when enabled', () => { + manager.init() + manager.setEnabled(true) + + const model = new THREE.Object3D() + model.position.set(1, 2, 3) + model.rotation.set(0.1, 0.2, 0.3) + + manager.setupForModel(model) + + expect(mockDetach).toHaveBeenCalled() + expect(mockAttach).toHaveBeenCalledWith(model) + expect(mockSetMode).toHaveBeenCalledWith('translate') + }) + + it('does not attach when disabled', () => { + manager.init() + + const model = new THREE.Object3D() + manager.setupForModel(model) + + expect(mockAttach).not.toHaveBeenCalled() + }) + + it('does nothing before init', () => { + const model = new THREE.Object3D() + manager.setupForModel(model) + + expect(mockDetach).not.toHaveBeenCalled() + }) + }) + + describe('setEnabled', () => { + it('attaches to target when enabled with a target', () => { + manager.init() + const model = new THREE.Object3D() + manager.setupForModel(model) + + vi.mocked(mockAttach).mockClear() + manager.setEnabled(true) + + expect(mockAttach).toHaveBeenCalledWith(model) + expect(manager.isEnabled()).toBe(true) + }) + + it('detaches when disabled', () => { + manager.init() + const model = new THREE.Object3D() + manager.setupForModel(model) + manager.setEnabled(true) + + vi.mocked(mockDetach).mockClear() + manager.setEnabled(false) + + expect(mockDetach).toHaveBeenCalled() + expect(manager.isEnabled()).toBe(false) + }) + + it('does nothing before init', () => { + manager.setEnabled(true) + expect(mockAttach).not.toHaveBeenCalled() + }) + }) + + describe('detach', () => { + it('detaches and clears target', () => { + manager.init() + const model = new THREE.Object3D() + manager.setupForModel(model) + manager.setEnabled(true) + + vi.mocked(mockDetach).mockClear() + manager.detach() + + expect(mockDetach).toHaveBeenCalled() + expect(manager.isEnabled()).toBe(false) + }) + }) + + describe('setMode / getMode', () => { + it('defaults to translate', () => { + expect(manager.getMode()).toBe('translate') + }) + + it('switches to rotate', () => { + manager.init() + manager.setMode('rotate') + + expect(manager.getMode()).toBe('rotate') + expect(mockSetMode).toHaveBeenCalledWith('rotate') + }) + + it('stores mode before init', () => { + manager.setMode('rotate') + expect(manager.getMode()).toBe('rotate') + }) + }) + + describe('reset', () => { + it('restores initial position, rotation, and scale', () => { + manager.init() + const model = new THREE.Object3D() + model.position.set(1, 2, 3) + model.rotation.set(0.1, 0.2, 0.3) + model.scale.set(2, 2, 2) + + manager.setupForModel(model) + + model.position.set(10, 20, 30) + model.rotation.set(1, 2, 3) + model.scale.set(5, 5, 5) + + manager.reset() + + expect(model.position.x).toBeCloseTo(1) + expect(model.position.y).toBeCloseTo(2) + expect(model.position.z).toBeCloseTo(3) + expect(model.rotation.x).toBeCloseTo(0.1) + expect(model.rotation.y).toBeCloseTo(0.2) + expect(model.rotation.z).toBeCloseTo(0.3) + expect(model.scale.x).toBeCloseTo(2) + expect(model.scale.y).toBeCloseTo(2) + expect(model.scale.z).toBeCloseTo(2) + }) + + it('does nothing without a target', () => { + manager.init() + expect(() => manager.reset()).not.toThrow() + }) + + it('invokes onTransformChange after resetting', () => { + manager.init() + const model = new THREE.Object3D() + model.position.set(1, 2, 3) + manager.setupForModel(model) + + expect(onTransformChange).not.toHaveBeenCalled() + + manager.reset() + + expect(onTransformChange).toHaveBeenCalledOnce() + }) + }) + + describe('applyTransform', () => { + it('sets position and rotation on target', () => { + manager.init() + const model = new THREE.Object3D() + manager.setupForModel(model) + + manager.applyTransform({ x: 5, y: 6, z: 7 }, { x: 0.5, y: 0.6, z: 0.7 }) + + expect(model.position.x).toBeCloseTo(5) + expect(model.position.y).toBeCloseTo(6) + expect(model.position.z).toBeCloseTo(7) + expect(model.rotation.x).toBeCloseTo(0.5) + expect(model.rotation.y).toBeCloseTo(0.6) + expect(model.rotation.z).toBeCloseTo(0.7) + }) + + it('applies scale when provided', () => { + manager.init() + const model = new THREE.Object3D() + manager.setupForModel(model) + + manager.applyTransform( + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: 0 }, + { x: 2, y: 3, z: 4 } + ) + + expect(model.scale.x).toBeCloseTo(2) + expect(model.scale.y).toBeCloseTo(3) + expect(model.scale.z).toBeCloseTo(4) + }) + + it('does nothing without a target', () => { + manager.init() + expect(() => + manager.applyTransform({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0 }) + ).not.toThrow() + }) + }) + + describe('getTransform', () => { + it('returns current target transform', () => { + manager.init() + const model = new THREE.Object3D() + model.position.set(1, 2, 3) + model.rotation.set(0.1, 0.2, 0.3) + model.scale.set(4, 5, 6) + manager.setupForModel(model) + + const transform = manager.getTransform() + + expect(transform.position).toEqual({ x: 1, y: 2, z: 3 }) + expect(transform.rotation.x).toBeCloseTo(0.1) + expect(transform.rotation.y).toBeCloseTo(0.2) + expect(transform.rotation.z).toBeCloseTo(0.3) + expect(transform.scale).toEqual({ x: 4, y: 5, z: 6 }) + }) + + it('returns zero/identity when no target', () => { + const transform = manager.getTransform() + + expect(transform.position).toEqual({ x: 0, y: 0, z: 0 }) + expect(transform.rotation).toEqual({ x: 0, y: 0, z: 0 }) + expect(transform.scale).toEqual({ x: 1, y: 1, z: 1 }) + }) + }) + + describe('removeFromScene / ensureHelperInScene', () => { + it('removes helper from scene', () => { + manager.init() + expect(scene.children).toContain(mockHelper) + + manager.removeFromScene() + + expect(scene.children).not.toContain(mockHelper) + }) + + it('restores helper to scene', () => { + manager.init() + manager.removeFromScene() + + manager.ensureHelperInScene() + + expect(scene.children).toContain(mockHelper) + }) + }) + + describe('dispose', () => { + it('removes helper, detaches, and disposes controls', () => { + manager.init() + scene.add(mockHelper) + + manager.dispose() + + expect(mockDetach).toHaveBeenCalled() + expect(mockDispose).toHaveBeenCalled() + }) + + it('is safe to call before init', () => { + expect(() => manager.dispose()).not.toThrow() + }) + }) + + describe('ensureHelperInScene', () => { + it('re-adds helper if it was removed from its parent', () => { + manager.init() + // Simulate helper being removed from scene + scene.remove(mockHelper) + expect(scene.children).not.toContain(mockHelper) + + // setEnabled triggers ensureHelperInScene internally + const model = new THREE.Object3D() + manager.setupForModel(model) + manager.setEnabled(true) + + expect(scene.children).toContain(mockHelper) + }) + }) +}) diff --git a/src/extensions/core/load3d/GizmoManager.ts b/src/extensions/core/load3d/GizmoManager.ts new file mode 100644 index 0000000000..4089ab9ea0 --- /dev/null +++ b/src/extensions/core/load3d/GizmoManager.ts @@ -0,0 +1,229 @@ +import * as THREE from 'three' +import { TransformControls } from 'three/examples/jsm/controls/TransformControls' + +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' + +import type { GizmoMode } from './interfaces' + +export class GizmoManager { + private transformControls: TransformControls | null = null + private targetObject: THREE.Object3D | null = null + private initialPosition: THREE.Vector3 = new THREE.Vector3() + private initialRotation: THREE.Euler = new THREE.Euler() + private initialScale: THREE.Vector3 = new THREE.Vector3(1, 1, 1) + private enabled: boolean = false + private activeCamera: THREE.Camera + private mode: GizmoMode = 'translate' + private scene: THREE.Scene + private renderer: THREE.WebGLRenderer + private orbitControls: OrbitControls + private onTransformChange?: () => void + + constructor( + scene: THREE.Scene, + renderer: THREE.WebGLRenderer, + orbitControls: OrbitControls, + getActiveCamera: () => THREE.Camera, + onTransformChange?: () => void + ) { + this.scene = scene + this.renderer = renderer + this.orbitControls = orbitControls + this.activeCamera = getActiveCamera() + this.onTransformChange = onTransformChange + } + + init(): void { + this.transformControls = new TransformControls( + this.activeCamera, + this.renderer.domElement + ) + + this.transformControls.addEventListener('dragging-changed', (event) => { + this.orbitControls.enabled = !event.value + if (!event.value && this.onTransformChange) { + this.onTransformChange() + } + }) + + const helper = this.transformControls.getHelper() + helper.name = 'GizmoTransformControls' + helper.renderOrder = 999 + this.scene.add(helper) + } + + setupForModel(model: THREE.Object3D): void { + if (!this.transformControls) return + + this.ensureHelperInScene() + + this.transformControls.detach() + this.transformControls.enabled = false + + this.targetObject = model + this.initialPosition.copy(model.position) + this.initialRotation.copy(model.rotation) + this.initialScale.copy(model.scale) + + if (this.enabled) { + this.transformControls.attach(model) + this.transformControls.setMode(this.mode) + this.transformControls.enabled = true + } + } + + detach(): void { + this.enabled = false + if (this.transformControls) { + this.transformControls.detach() + this.transformControls.enabled = false + } + this.targetObject = null + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled + + if (!this.transformControls) return + + this.ensureHelperInScene() + + if (enabled && this.targetObject) { + this.transformControls.attach(this.targetObject) + this.transformControls.setMode(this.mode) + this.transformControls.enabled = true + } else { + this.transformControls.detach() + this.transformControls.enabled = false + } + } + + ensureHelperInScene(): void { + if (!this.transformControls) return + const helper = this.transformControls.getHelper() + if (!helper.parent) { + this.scene.add(helper) + } + } + + removeFromScene(): void { + if (!this.transformControls) return + const helper = this.transformControls.getHelper() + if (helper.parent) { + helper.parent.remove(helper) + } + } + + isEnabled(): boolean { + return this.enabled + } + + updateCamera(camera: THREE.Camera): void { + this.activeCamera = camera + if (this.transformControls) { + this.transformControls.camera = camera + } + } + + setMode(mode: GizmoMode): void { + this.mode = mode + + if (this.transformControls) { + this.transformControls.setMode(mode) + } + } + + getMode(): GizmoMode { + return this.mode + } + + reset(): void { + if (!this.targetObject) return + + this.targetObject.position.copy(this.initialPosition) + this.targetObject.rotation.copy(this.initialRotation) + this.targetObject.scale.copy(this.initialScale) + this.onTransformChange?.() + } + + applyTransform( + position: { x: number; y: number; z: number }, + rotation: { x: number; y: number; z: number }, + scale?: { x: number; y: number; z: number } + ): void { + if (!this.targetObject) return + this.targetObject.position.set(position.x, position.y, position.z) + this.targetObject.rotation.set(rotation.x, rotation.y, rotation.z) + if (scale) { + this.targetObject.scale.set(scale.x, scale.y, scale.z) + } + } + + getInitialTransform(): { + position: { x: number; y: number; z: number } + rotation: { x: number; y: number; z: number } + scale: { x: number; y: number; z: number } + } { + return { + position: { + x: this.initialPosition.x, + y: this.initialPosition.y, + z: this.initialPosition.z + }, + rotation: { + x: this.initialRotation.x, + y: this.initialRotation.y, + z: this.initialRotation.z + }, + scale: { + x: this.initialScale.x, + y: this.initialScale.y, + z: this.initialScale.z + } + } + } + + getTransform(): { + position: { x: number; y: number; z: number } + rotation: { x: number; y: number; z: number } + scale: { x: number; y: number; z: number } + } { + if (!this.targetObject) { + return { + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } + } + + return { + position: { + x: this.targetObject.position.x, + y: this.targetObject.position.y, + z: this.targetObject.position.z + }, + rotation: { + x: this.targetObject.rotation.x, + y: this.targetObject.rotation.y, + z: this.targetObject.rotation.z + }, + scale: { + x: this.targetObject.scale.x, + y: this.targetObject.scale.y, + z: this.targetObject.scale.z + } + } + } + + dispose(): void { + if (this.transformControls) { + const helper = this.transformControls.getHelper() + this.scene.remove(helper) + this.transformControls.detach() + this.transformControls.dispose() + this.transformControls = null + } + + this.targetObject = null + } +} diff --git a/src/extensions/core/load3d/Load3DConfiguration.test.ts b/src/extensions/core/load3d/Load3DConfiguration.test.ts new file mode 100644 index 0000000000..b26881ec3f --- /dev/null +++ b/src/extensions/core/load3d/Load3DConfiguration.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type Load3d from '@/extensions/core/load3d/Load3d' +import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' +import type { + GizmoConfig, + ModelConfig +} from '@/extensions/core/load3d/interfaces' +import type { Dictionary } from '@/lib/litegraph/src/interfaces' +import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode' + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: vi.fn() + }) +})) + +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: (p: string) => p, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchCustomEvent: vi.fn(), + fetchApi: vi.fn(), + getSystemStats: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { rootGraph: { extra: {} } } +})) + +vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: class {} })) + +vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({ + default: { + splitFilePath: vi.fn(), + getResourceURL: vi.fn() + } +})) + +type WithPrivate = { loadModelConfig(): ModelConfig } + +function createConfig(properties?: Dictionary) { + const load3d = {} as Load3d + return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate +} + +const defaultGizmo: GizmoConfig = { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } +} + +describe('Load3DConfiguration.loadModelConfig', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns full defaults including gizmo when no properties are provided', () => { + const result = createConfig().loadModelConfig() + + expect(result).toEqual({ + upDirection: 'original', + materialMode: 'original', + showSkeleton: false, + gizmo: defaultGizmo + }) + }) + + it('returns full defaults when properties do not contain Model Config', () => { + const result = createConfig({ 'Other Key': 'x' }).loadModelConfig() + + expect(result.gizmo).toEqual(defaultGizmo) + }) + + it('adds default gizmo when Model Config exists but has no gizmo field', () => { + const stored: ModelConfig = { + upDirection: '+y', + materialMode: 'wireframe', + showSkeleton: true + } + const properties = { 'Model Config': stored } as Dictionary< + NodeProperty | undefined + > + + const result = createConfig(properties).loadModelConfig() + + expect(result.upDirection).toBe('+y') + expect(result.materialMode).toBe('wireframe') + expect(result.showSkeleton).toBe(true) + expect(result.gizmo).toEqual(defaultGizmo) + }) + + it('mutates the original Model Config property to persist gizmo defaults', () => { + const stored: ModelConfig = { + upDirection: 'original', + materialMode: 'original', + showSkeleton: false + } + const properties = { 'Model Config': stored } as Dictionary< + NodeProperty | undefined + > + + createConfig(properties).loadModelConfig() + + expect((properties['Model Config'] as ModelConfig).gizmo).toEqual( + defaultGizmo + ) + }) + + it('backfills scale on legacy gizmo config missing the scale field', () => { + const legacyGizmo = { + enabled: true, + mode: 'rotate', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 } + } as unknown as GizmoConfig + const stored: ModelConfig = { + upDirection: 'original', + materialMode: 'original', + showSkeleton: false, + gizmo: legacyGizmo + } + const properties = { 'Model Config': stored } as Dictionary< + NodeProperty | undefined + > + + const result = createConfig(properties).loadModelConfig() + + expect(result.gizmo).toEqual({ + enabled: true, + mode: 'rotate', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 }, + scale: { x: 1, y: 1, z: 1 } + }) + }) + + it('preserves a fully populated gizmo config unchanged', () => { + const fullGizmo: GizmoConfig = { + enabled: true, + mode: 'scale', + position: { x: 5, y: 6, z: 7 }, + rotation: { x: 1, y: 2, z: 3 }, + scale: { x: 2, y: 2, z: 2 } + } + const stored: ModelConfig = { + upDirection: '-z', + materialMode: 'normal', + showSkeleton: false, + gizmo: fullGizmo + } + const properties = { 'Model Config': stored } as Dictionary< + NodeProperty | undefined + > + + const result = createConfig(properties).loadModelConfig() + + expect(result.gizmo).toEqual(fullGizmo) + }) +}) diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts index 816a94b488..4906213abf 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.ts @@ -167,13 +167,32 @@ class Load3DConfiguration { private loadModelConfig(): ModelConfig { if (this.properties && 'Model Config' in this.properties) { - return this.properties['Model Config'] as ModelConfig + const config = this.properties['Model Config'] as ModelConfig + if (!config.gizmo) { + config.gizmo = { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } + } else if (!config.gizmo.scale) { + config.gizmo.scale = { x: 1, y: 1, z: 1 } + } + return config } return { upDirection: 'original', materialMode: 'original', - showSkeleton: false + showSkeleton: false, + gizmo: { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } } } diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts new file mode 100644 index 0000000000..2c3e677379 --- /dev/null +++ b/src/extensions/core/load3d/Load3d.test.ts @@ -0,0 +1,269 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import Load3d from '@/extensions/core/load3d/Load3d' +import type { GizmoMode } from '@/extensions/core/load3d/interfaces' + +type GizmoStub = { + setEnabled: ReturnType + setMode: ReturnType + reset: ReturnType + applyTransform: ReturnType + getTransform: ReturnType + setupForModel: ReturnType + updateCamera: ReturnType + detach: ReturnType + dispose: ReturnType + removeFromScene: ReturnType + ensureHelperInScene: ReturnType + isEnabled: ReturnType + getMode: ReturnType +} + +type ModelManagerStub = { + fitToViewer: ReturnType + clearModel: ReturnType +} + +type CameraManagerStub = { + toggleCamera: ReturnType + setupForModel: ReturnType + reset: ReturnType + activeCamera: THREE.Camera +} + +type SceneManagerStub = { + captureScene: ReturnType + dispose: ReturnType +} + +type Load3dPrivate = { + setGizmo(model: THREE.Object3D): void + setupCamera(size: THREE.Vector3, center: THREE.Vector3): void +} + +function makeGizmoStub(): GizmoStub { + return { + setEnabled: vi.fn(), + setMode: vi.fn(), + reset: vi.fn(), + applyTransform: vi.fn(), + getTransform: vi.fn(() => ({ + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + })), + setupForModel: vi.fn(), + updateCamera: vi.fn(), + detach: vi.fn(), + dispose: vi.fn(), + removeFromScene: vi.fn(), + ensureHelperInScene: vi.fn(), + isEnabled: vi.fn(() => false), + getMode: vi.fn(() => 'translate') + } +} + +function makeInstance() { + const gizmo = makeGizmoStub() + const modelManager: ModelManagerStub = { + fitToViewer: vi.fn(), + clearModel: vi.fn() + } + const cameraManager: CameraManagerStub = { + toggleCamera: vi.fn(), + setupForModel: vi.fn(), + reset: vi.fn(), + activeCamera: new THREE.PerspectiveCamera() + } + const sceneManager: SceneManagerStub = { + captureScene: vi.fn(), + dispose: vi.fn() + } + const controlsManager = { updateCamera: vi.fn() } + const viewHelperManager = { recreateViewHelper: vi.fn() } + const animationManager = { dispose: vi.fn() } + + // Load3d's constructor instantiates THREE.WebGLRenderer, ResizeObserver + // and ViewHelper, none of which are available in happy-dom. Skip it and + // inject stubs directly onto the prototype instance so delegation methods + // can be exercised in isolation. + const load3d = Object.create(Load3d.prototype) as Load3d + Object.assign(load3d, { + gizmoManager: gizmo, + modelManager, + cameraManager, + sceneManager, + controlsManager, + viewHelperManager, + animationManager, + forceRender: vi.fn(), + handleResize: vi.fn() + }) + + return { + load3d, + gizmo, + modelManager, + cameraManager, + sceneManager, + controlsManager, + viewHelperManager, + animationManager, + forceRender: load3d.forceRender as ReturnType + } +} + +describe('Load3d', () => { + let ctx: ReturnType + + beforeEach(() => { + ctx = makeInstance() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('gizmo delegation', () => { + it('getGizmoManager returns the underlying manager', () => { + expect(ctx.load3d.getGizmoManager()).toBe(ctx.gizmo) + }) + + it('setGizmoEnabled delegates to gizmoManager.setEnabled and forces a render', () => { + ctx.load3d.setGizmoEnabled(true) + + expect(ctx.gizmo.setEnabled).toHaveBeenCalledWith(true) + expect(ctx.forceRender).toHaveBeenCalledOnce() + }) + + it.each(['translate', 'rotate', 'scale'] as const)( + 'setGizmoMode delegates "%s" and forces a render', + (mode: GizmoMode) => { + ctx.load3d.setGizmoMode(mode) + + expect(ctx.gizmo.setMode).toHaveBeenCalledWith(mode) + expect(ctx.forceRender).toHaveBeenCalledOnce() + } + ) + + it('resetGizmoTransform delegates to gizmoManager.reset and forces a render', () => { + ctx.load3d.resetGizmoTransform() + + expect(ctx.gizmo.reset).toHaveBeenCalledOnce() + expect(ctx.forceRender).toHaveBeenCalledOnce() + }) + + it('applyGizmoTransform forwards position, rotation and scale', () => { + const pos = { x: 1, y: 2, z: 3 } + const rot = { x: 0.1, y: 0.2, z: 0.3 } + const scale = { x: 2, y: 2, z: 2 } + + ctx.load3d.applyGizmoTransform(pos, rot, scale) + + expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, scale) + expect(ctx.forceRender).toHaveBeenCalledOnce() + }) + + it('applyGizmoTransform forwards undefined scale when not provided', () => { + const pos = { x: 0, y: 0, z: 0 } + const rot = { x: 0, y: 0, z: 0 } + + ctx.load3d.applyGizmoTransform(pos, rot) + + expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined) + }) + + it('getGizmoTransform returns the gizmoManager transform', () => { + const transform = { + position: { x: 5, y: 6, z: 7 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } + ctx.gizmo.getTransform.mockReturnValue(transform) + + expect(ctx.load3d.getGizmoTransform()).toEqual(transform) + }) + + it('fitToViewer delegates to modelManager and forces a render', () => { + ctx.load3d.fitToViewer() + + expect(ctx.modelManager.fitToViewer).toHaveBeenCalledOnce() + expect(ctx.forceRender).toHaveBeenCalledOnce() + }) + }) + + describe('lifecycle interactions', () => { + it('clearModel detaches the gizmo before clearing the model', () => { + const order: string[] = [] + ctx.animationManager.dispose.mockImplementation(() => + order.push('animation') + ) + ctx.gizmo.detach.mockImplementation(() => order.push('detach')) + ctx.modelManager.clearModel.mockImplementation(() => order.push('clear')) + + ctx.load3d.clearModel() + + expect(order).toEqual(['animation', 'detach', 'clear']) + expect(ctx.forceRender).toHaveBeenCalledOnce() + }) + + it('toggleCamera updates both controls and gizmo with the active camera', () => { + ctx.load3d.toggleCamera('orthographic') + + expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith( + 'orthographic' + ) + expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith( + ctx.cameraManager.activeCamera + ) + expect(ctx.gizmo.updateCamera).toHaveBeenCalledWith( + ctx.cameraManager.activeCamera + ) + expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce() + }) + + it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => { + const model = new THREE.Object3D() + + ;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model) + + expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model) + }) + + it('setupCamera (private) forwards size and center to cameraManager', () => { + const size = new THREE.Vector3(1, 2, 3) + const center = new THREE.Vector3(4, 5, 6) + + ;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center) + + expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center) + }) + }) + + describe('captureScene', () => { + it('hides the gizmo helper during capture and restores it after success', async () => { + const captureResult = { scene: 'a', mask: 'b', normal: 'c' } + ctx.sceneManager.captureScene.mockResolvedValue(captureResult) + + const result = await ctx.load3d.captureScene(100, 200) + + expect(ctx.gizmo.removeFromScene).toHaveBeenCalledBefore( + ctx.sceneManager.captureScene + ) + expect(ctx.sceneManager.captureScene).toHaveBeenCalledWith(100, 200) + expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce() + expect(result).toBe(captureResult) + }) + + it('restores the gizmo helper even when capture fails', async () => { + const err = new Error('capture failed') + ctx.sceneManager.captureScene.mockRejectedValue(err) + + await expect(ctx.load3d.captureScene(100, 200)).rejects.toBe(err) + + expect(ctx.gizmo.removeFromScene).toHaveBeenCalledOnce() + expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 5d629cbe58..7d6ecdb6d7 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -7,6 +7,7 @@ import { CameraManager } from './CameraManager' import { ControlsManager } from './ControlsManager' import { EventManager } from './EventManager' import { HDRIManager } from './HDRIManager' +import { GizmoManager } from './GizmoManager' import { LightingManager } from './LightingManager' import { LoaderManager } from './LoaderManager' import { ModelExporter } from './ModelExporter' @@ -14,13 +15,14 @@ import { RecordingManager } from './RecordingManager' import { SceneManager } from './SceneManager' import { SceneModelManager } from './SceneModelManager' import { ViewHelperManager } from './ViewHelperManager' -import { - type CameraState, - type CaptureResult, - type EventCallback, - type Load3DOptions, - type MaterialMode, - type UpDirection +import type { + CameraState, + CaptureResult, + EventCallback, + GizmoMode, + Load3DOptions, + MaterialMode, + UpDirection } from './interfaces' function positionThumbnailCamera( @@ -61,6 +63,7 @@ class Load3d { modelManager: SceneModelManager recordingManager: RecordingManager animationManager: AnimationManager + gizmoManager: GizmoManager STATUS_MOUSE_ON_NODE: boolean STATUS_MOUSE_ON_SCENE: boolean @@ -146,7 +149,8 @@ class Load3d { this.renderer, this.eventManager, this.getActiveCamera.bind(this), - this.setupCamera.bind(this) + this.setupCamera.bind(this), + this.setGizmo.bind(this) ) this.loaderManager = new LoaderManager(this.modelManager, this.eventManager) @@ -158,12 +162,29 @@ class Load3d { ) this.animationManager = new AnimationManager(this.eventManager) + + this.gizmoManager = new GizmoManager( + this.sceneManager.scene, + this.renderer, + this.controlsManager.controls, + this.getActiveCamera.bind(this), + () => { + const transform = this.gizmoManager.getTransform() + this.eventManager.emitEvent('gizmoTransformChange', { + ...transform, + enabled: this.gizmoManager.isEnabled(), + mode: this.gizmoManager.getMode() + }) + } + ) + this.sceneManager.init() this.cameraManager.init() this.controlsManager.init() this.lightingManager.init() this.loaderManager.init() this.animationManager.init() + this.gizmoManager.init() this.viewHelperManager.createViewHelper(container) this.viewHelperManager.init() @@ -287,6 +308,10 @@ class Load3d { return this.recordingManager } + getGizmoManager(): GizmoManager { + return this.gizmoManager + } + getTargetSize(): { width: number; height: number } { return { width: this.targetWidth, @@ -388,8 +413,12 @@ class Load3d { return this.controlsManager.controls } - private setupCamera(size: THREE.Vector3): void { - this.cameraManager.setupForModel(size) + private setGizmo(model: THREE.Object3D): void { + this.gizmoManager.setupForModel(model) + } + + private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void { + this.cameraManager.setupForModel(size, center) } private startAnimation(): void { @@ -551,6 +580,7 @@ class Load3d { this.cameraManager.toggleCamera(cameraType) this.controlsManager.updateCamera(this.cameraManager.activeCamera) + this.gizmoManager.updateCamera(this.cameraManager.activeCamera) this.viewHelperManager.recreateViewHelper() this.handleResize() @@ -601,6 +631,7 @@ class Load3d { ): Promise { this.cameraManager.reset() this.controlsManager.reset() + this.gizmoManager.detach() this.modelManager.clearModel() this.animationManager.dispose() @@ -629,6 +660,7 @@ class Load3d { clearModel(): void { this.animationManager.dispose() + this.gizmoManager.detach() this.modelManager.clearModel() this.forceRender() } @@ -736,7 +768,11 @@ class Load3d { } captureScene(width: number, height: number): Promise { - return this.sceneManager.captureScene(width, height) + this.gizmoManager.removeFromScene() + + return this.sceneManager.captureScene(width, height).finally(() => { + this.gizmoManager.ensureHelperInScene() + }) } public async startRecording(): Promise { @@ -853,7 +889,7 @@ class Load3d { this.controlsManager.controls.update() } - const result = await this.sceneManager.captureScene(width, height) + const result = await this.captureScene(width, height) return result.scene } finally { this.sceneManager.gridHelper.visible = savedGridVisible @@ -866,6 +902,43 @@ class Load3d { } } + public setGizmoEnabled(enabled: boolean): void { + this.gizmoManager.setEnabled(enabled) + this.forceRender() + } + + public setGizmoMode(mode: GizmoMode): void { + this.gizmoManager.setMode(mode) + this.forceRender() + } + + public resetGizmoTransform(): void { + this.gizmoManager.reset() + this.forceRender() + } + + public applyGizmoTransform( + position: { x: number; y: number; z: number }, + rotation: { x: number; y: number; z: number }, + scale?: { x: number; y: number; z: number } + ): void { + this.gizmoManager.applyTransform(position, rotation, scale) + this.forceRender() + } + + public getGizmoTransform(): { + position: { x: number; y: number; z: number } + rotation: { x: number; y: number; z: number } + scale: { x: number; y: number; z: number } + } { + return this.gizmoManager.getTransform() + } + + public fitToViewer(): void { + this.modelManager.fitToViewer() + this.forceRender() + } + public remove(): void { if (this.resizeObserver) { this.resizeObserver.disconnect() @@ -899,6 +972,7 @@ class Load3d { this.modelManager.dispose() this.recordingManager.dispose() this.animationManager.dispose() + this.gizmoManager.dispose() this.renderer.dispose() this.renderer.domElement.remove() diff --git a/src/extensions/core/load3d/SceneManager.ts b/src/extensions/core/load3d/SceneManager.ts index 00a3383d6e..a9600b1f86 100644 --- a/src/extensions/core/load3d/SceneManager.ts +++ b/src/extensions/core/load3d/SceneManager.ts @@ -9,10 +9,10 @@ import { } from './interfaces' export class SceneManager implements SceneManagerInterface { - scene: THREE.Scene + scene!: THREE.Scene gridHelper: THREE.GridHelper - backgroundScene: THREE.Scene + backgroundScene!: THREE.Scene backgroundCamera: THREE.OrthographicCamera backgroundMesh: THREE.Mesh | null = null backgroundTexture: THREE.Texture | null = null @@ -38,6 +38,8 @@ export class SceneManager implements SceneManagerInterface { this.eventManager = eventManager this.scene = new THREE.Scene() + this.scene.name = 'MainScene' + this.getActiveCamera = getActiveCamera this.gridHelper = new THREE.GridHelper(20, 20) @@ -45,6 +47,7 @@ export class SceneManager implements SceneManagerInterface { this.scene.add(this.gridHelper) this.backgroundScene = new THREE.Scene() + this.backgroundScene.name = 'BackgroundScene' this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1) this.initBackgroundScene() @@ -93,6 +96,8 @@ export class SceneManager implements SceneManagerInterface { this.scene.background = null } + this.backgroundScene.clear() + this.scene.clear() } diff --git a/src/extensions/core/load3d/SceneModelManager.ts b/src/extensions/core/load3d/SceneModelManager.ts index a480e4554d..a896fa7741 100644 --- a/src/extensions/core/load3d/SceneModelManager.ts +++ b/src/extensions/core/load3d/SceneModelManager.ts @@ -37,14 +37,16 @@ export class SceneModelManager implements ModelManagerInterface { private renderer: THREE.WebGLRenderer private eventManager: EventManagerInterface private activeCamera: THREE.Camera - private setupCamera: (size: THREE.Vector3) => void + private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void + private setupGizmo: (model: THREE.Object3D) => void constructor( scene: THREE.Scene, renderer: THREE.WebGLRenderer, eventManager: EventManagerInterface, getActiveCamera: () => THREE.Camera, - setupCamera: (size: THREE.Vector3) => void + setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void, + setupGizmo: (model: THREE.Object3D) => void ) { this.scene = scene this.renderer = renderer @@ -52,6 +54,7 @@ export class SceneModelManager implements ModelManagerInterface { this.activeCamera = getActiveCamera() this.setupCamera = setupCamera this.textureLoader = new THREE.TextureLoader() + this.setupGizmo = setupGizmo this.normalMaterial = new THREE.MeshNormalMaterial({ flatShading: false, @@ -371,32 +374,31 @@ export class SceneModelManager implements ModelManagerInterface { clearModel(): void { const objectsToRemove: THREE.Object3D[] = [] - this.scene.traverse((object) => { + for (const object of [...this.scene.children]) { const isEnvironmentObject = object instanceof THREE.GridHelper || object instanceof THREE.Light || - object instanceof THREE.Camera + object instanceof THREE.Camera || + object.name === 'GizmoTransformControls' if (!isEnvironmentObject) { objectsToRemove.push(object) } - }) + } objectsToRemove.forEach((obj) => { - if (obj.parent && obj.parent !== this.scene) { - obj.parent.remove(obj) - } else { - this.scene.remove(obj) - } + this.scene.remove(obj) - if (obj instanceof THREE.Mesh) { - obj.geometry?.dispose() - if (Array.isArray(obj.material)) { - obj.material.forEach((material) => material.dispose()) - } else { - obj.material?.dispose() + obj.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry?.dispose() + if (Array.isArray(child.material)) { + child.material.forEach((material) => material.dispose()) + } else { + child.material?.dispose() + } } - } + }) }) this.reset() @@ -497,25 +499,10 @@ export class SceneModelManager implements ModelManagerInterface { // SplatMesh handles its own rendering, just add to scene this.scene.add(model) // Set a default camera distance for splat models - this.setupCamera(new THREE.Vector3(5, 5, 5)) + this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0)) return } - const box = new THREE.Box3().setFromObject(model) - const size = box.getSize(new THREE.Vector3()) - const center = box.getCenter(new THREE.Vector3()) - - const maxDim = Math.max(size.x, size.y, size.z) - const targetSize = 5 - const scale = targetSize / maxDim - model.scale.multiplyScalar(scale) - - box.setFromObject(model) - box.getCenter(center) - box.getSize(size) - - model.position.set(-center.x, -box.min.y, -center.z) - this.scene.add(model) if (this.materialMode !== 'original') { @@ -527,7 +514,47 @@ export class SceneModelManager implements ModelManagerInterface { } this.setupModelMaterials(model) - this.setupCamera(size) + const box = new THREE.Box3().setFromObject(model) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + this.setupCamera(size, center) + + this.setupGizmo(model) + } + + fitToViewer(): void { + if (!this.currentModel || this.containsSplatMesh()) return + const model = this.currentModel + + // Reset transform to compute from raw geometry (idempotent) + model.scale.set(1, 1, 1) + model.position.set(0, 0, 0) + model.rotation.set(0, 0, 0) + + const box = new THREE.Box3().setFromObject(model) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + const maxDim = Math.max(size.x, size.y, size.z) + if (maxDim === 0) return + + const targetSize = 5 + const scale = targetSize / maxDim + model.scale.set(scale, scale, scale) + + box.setFromObject(model) + box.getCenter(center) + box.getSize(size) + + model.position.set(-center.x, -box.min.y, -center.z) + + const newBox = new THREE.Box3().setFromObject(model) + const newSize = newBox.getSize(new THREE.Vector3()) + const newCenter = newBox.getCenter(new THREE.Vector3()) + + this.setupCamera(newSize, newCenter) + this.setupGizmo(model) } containsSplatMesh(model?: THREE.Object3D | null): boolean { @@ -548,6 +575,8 @@ export class SceneModelManager implements ModelManagerInterface { setUpDirection(direction: UpDirection): void { if (!this.currentModel) return + const directionChanged = this.currentUpDirection !== direction + if (!this.originalRotation && this.currentModel.rotation) { this.originalRotation = this.currentModel.rotation.clone() } @@ -581,5 +610,9 @@ export class SceneModelManager implements ModelManagerInterface { } this.eventManager.emitEvent('upDirectionChange', direction) + + if (directionChanged) { + this.setupGizmo(this.currentModel) + } } } diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index d78fd239dc..ef0ae30f92 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -33,10 +33,21 @@ export interface SceneConfig { backgroundRenderMode?: BackgroundRenderModeType } +export type GizmoMode = 'translate' | 'rotate' | 'scale' + +export interface GizmoConfig { + enabled: boolean + mode: GizmoMode + position: { x: number; y: number; z: number } + rotation: { x: number; y: number; z: number } + scale: { x: number; y: number; z: number } +} + export interface ModelConfig { upDirection: UpDirection materialMode: MaterialMode showSkeleton: boolean + gizmo?: GizmoConfig } export interface CameraConfig { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 6868f54f3c..0f63a681b1 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -129,6 +129,8 @@ "saveAnyway": "Save Anyway", "saving": "Saving", "no": "No", + "on": "On", + "off": "Off", "cancel": "Cancel", "close": "Close", "closeDialog": "Close dialog", @@ -1941,6 +1943,7 @@ "upDirection": "Up Direction", "materialMode": "Material Mode", "showSkeleton": "Show Skeleton", + "fitToViewer": "Fit to Viewer", "scene": "Scene", "model": "Model", "camera": "Camera", @@ -1997,6 +2000,14 @@ "removeFile": "Remove HDRI", "showAsBackground": "Show as Background", "intensity": "Intensity" + }, + "gizmo": { + "label": "Gizmo", + "toggle": "Gizmo", + "translate": "Translate", + "rotate": "Rotate", + "scale": "Scale", + "reset": "Reset Transform" } }, "imageCrop": { @@ -2094,7 +2105,9 @@ "failedToUploadBackgroundImage": "Failed to upload background image", "failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}", "failedToLoadHDRI": "Failed to load HDRI file", - "unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file." + "unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file.", + "failedToToggleGizmo": "Failed to toggle gizmo", + "failedToSetGizmoMode": "Failed to set gizmo mode" }, "nodeErrors": { "render": "Node Render Error", diff --git a/src/services/load3dService.ts b/src/services/load3dService.ts index f851da8331..ef0233a72d 100644 --- a/src/services/load3dService.ts +++ b/src/services/load3dService.ts @@ -216,6 +216,9 @@ class Load3dService { async copyLoad3dState(source: Load3d, target: Load3d) { const sourceModel = source.modelManager.currentModel + const gizmoWasEnabled = target.getGizmoManager().isEnabled() + target.getGizmoManager().detach() + if (sourceModel) { // Remove existing model from target scene before adding new one const existingModel = target.getModelManager().currentModel @@ -256,6 +259,36 @@ class Load3dService { source.getModelManager().appliedTexture } + const sourceInitial = source.getGizmoManager().getInitialTransform() + modelClone.position.set( + sourceInitial.position.x, + sourceInitial.position.y, + sourceInitial.position.z + ) + modelClone.rotation.set( + sourceInitial.rotation.x, + sourceInitial.rotation.y, + sourceInitial.rotation.z + ) + modelClone.scale.set( + sourceInitial.scale.x, + sourceInitial.scale.y, + sourceInitial.scale.z + ) + + target.getGizmoManager().setupForModel(modelClone) + const gizmoTransform = source.getGizmoTransform() + target.applyGizmoTransform( + gizmoTransform.position, + gizmoTransform.rotation, + gizmoTransform.scale + ) + const shouldEnable = + gizmoWasEnabled || source.getGizmoManager().isEnabled() + if (shouldEnable) { + target.setGizmoEnabled(true) + } + // Copy animation state if (source.hasAnimations()) { target.animationManager.setupModelAnimations( From a3893a593d92fceeee3ebc0a0b17bccfcf1f2c12 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 18 Apr 2026 20:00:34 -0700 Subject: [PATCH 012/460] refactor: move select components from input/ to ui/ component library (#11378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary Reconciles `src/components/input/` (older select components) into `src/components/ui/` (internal component library), eliminating the separate `input/` directory entirely. ## Changes - **Move MultiSelect** → `src/components/ui/multi-select/MultiSelect.vue` - **Move SingleSelect** → `src/components/ui/single-select/SingleSelect.vue` - **Extract shared resources** → `src/components/ui/select/types.ts` (SelectOption type) and `src/components/ui/select/select.variants.ts` (CVA styling variants) - **Update 7 consuming files** to use new import paths - **Update 1 test file** (AssetFilterBar.test.ts mock paths) - **Move stories and tests** alongside their components - **Delete `src/components/input/`** directory ## Context The `input/` directory contained only MultiSelect and SingleSelect — two well-built components that already used the same stack as `ui/` (Reka UI, CVA, Tailwind 4, Composition API). MultiSelect even imported `ui/button/Button.vue`. Moving them into `ui/` removes the split and consolidates all reusable components in one place. No API changes — all component props, slots, events, and behavior are preserved exactly. ## Verification - `pnpm typecheck` ✅ - `pnpm build` ✅ - `pnpm lint` (stylelint + oxlint + eslint) ✅ - All 15 relevant tests pass (MultiSelect: 5, SingleSelect: 2, AssetFilterBar: 8) ✅ - `pnpm knip` — no dead exports ✅ - No stale `@/components/input/` references remain ✅ - Pre-commit hooks pass ✅ - Git detected all moves as renames (97-100% similarity) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11378-refactor-move-select-components-from-input-to-ui-component-library-3476d73d3650810e99b4c3e0842e67f3) by [Unito](https://www.unito.io) Co-authored-by: Glary-Bot --- .../custom/widget/WorkflowTemplateSelectorDialog.vue | 4 ++-- .../{input => ui/multi-select}/MultiSelect.stories.ts | 3 ++- .../{input => ui/multi-select}/MultiSelect.test.ts | 0 .../{input => ui/multi-select}/MultiSelect.vue | 9 ++++----- .../{input => ui/select}/SelectDropdown.stories.ts | 5 +++-- src/components/{input => ui/select}/select.variants.ts | 0 src/components/{input => ui/select}/types.ts | 0 .../{input => ui/single-select}/SingleSelect.stories.ts | 0 .../{input => ui/single-select}/SingleSelect.test.ts | 0 .../{input => ui/single-select}/SingleSelect.vue | 9 ++++----- src/components/widget/SampleModelSelector.vue | 4 ++-- src/components/widget/layout/BaseModalLayout.stories.ts | 4 ++-- src/platform/assets/components/AssetFilterBar.test.ts | 4 ++-- src/platform/assets/components/AssetFilterBar.vue | 6 +++--- .../assets/components/UploadModelConfirmation.vue | 2 +- src/platform/assets/composables/useAssetFilterOptions.ts | 2 +- .../manager/components/manager/ManagerDialog.vue | 2 +- 17 files changed, 27 insertions(+), 27 deletions(-) rename src/components/{input => ui/multi-select}/MultiSelect.stories.ts (98%) rename src/components/{input => ui/multi-select}/MultiSelect.test.ts (100%) rename src/components/{input => ui/multi-select}/MultiSelect.vue (98%) rename src/components/{input => ui/select}/SelectDropdown.stories.ts (97%) rename src/components/{input => ui/select}/select.variants.ts (100%) rename src/components/{input => ui/select}/types.ts (100%) rename src/components/{input => ui/single-select}/SingleSelect.stories.ts (100%) rename src/components/{input => ui/single-select}/SingleSelect.test.ts (100%) rename src/components/{input => ui/single-select}/SingleSelect.vue (97%) diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index 3c743d483f..adf85a2685 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -405,8 +405,8 @@ import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' import Tag from '@/components/chip/Tag.vue' import SearchInput from '@/components/ui/search-input/SearchInput.vue' -import MultiSelect from '@/components/input/MultiSelect.vue' -import SingleSelect from '@/components/input/SingleSelect.vue' +import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue' +import SingleSelect from '@/components/ui/single-select/SingleSelect.vue' import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue' import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue' import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue' diff --git a/src/components/input/MultiSelect.stories.ts b/src/components/ui/multi-select/MultiSelect.stories.ts similarity index 98% rename from src/components/input/MultiSelect.stories.ts rename to src/components/ui/multi-select/MultiSelect.stories.ts index bac6fd26b7..c32bcd3844 100644 --- a/src/components/input/MultiSelect.stories.ts +++ b/src/components/ui/multi-select/MultiSelect.stories.ts @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import { ref } from 'vue' +import type { SelectOption } from '@/components/ui/select/types' + import MultiSelect from './MultiSelect.vue' -import type { SelectOption } from './types' const meta: Meta = { title: 'Components/Select/MultiSelect', diff --git a/src/components/input/MultiSelect.test.ts b/src/components/ui/multi-select/MultiSelect.test.ts similarity index 100% rename from src/components/input/MultiSelect.test.ts rename to src/components/ui/multi-select/MultiSelect.test.ts diff --git a/src/components/input/MultiSelect.vue b/src/components/ui/multi-select/MultiSelect.vue similarity index 98% rename from src/components/input/MultiSelect.vue rename to src/components/ui/multi-select/MultiSelect.vue index 2980da6fca..726a83746e 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/ui/multi-select/MultiSelect.vue @@ -155,9 +155,6 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' -import { usePopoverSizing } from '@/composables/usePopoverSizing' -import { cn } from '@/utils/tailwindUtil' - import { selectContentClass, selectDropdownClass, @@ -165,8 +162,10 @@ import { selectItemVariants, selectTriggerVariants, stopEscapeToDocument -} from './select.variants' -import type { SelectOption } from './types' +} from '@/components/ui/select/select.variants' +import type { SelectOption } from '@/components/ui/select/types' +import { usePopoverSizing } from '@/composables/usePopoverSizing' +import { cn } from '@/utils/tailwindUtil' defineOptions({ inheritAttrs: false diff --git a/src/components/input/SelectDropdown.stories.ts b/src/components/ui/select/SelectDropdown.stories.ts similarity index 97% rename from src/components/input/SelectDropdown.stories.ts rename to src/components/ui/select/SelectDropdown.stories.ts index 04c0a46358..69af480853 100644 --- a/src/components/input/SelectDropdown.stories.ts +++ b/src/components/ui/select/SelectDropdown.stories.ts @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import { ref } from 'vue' -import MultiSelect from './MultiSelect.vue' -import SingleSelect from './SingleSelect.vue' +import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue' +import SingleSelect from '@/components/ui/single-select/SingleSelect.vue' + import type { SelectOption } from './types' const meta: Meta = { diff --git a/src/components/input/select.variants.ts b/src/components/ui/select/select.variants.ts similarity index 100% rename from src/components/input/select.variants.ts rename to src/components/ui/select/select.variants.ts diff --git a/src/components/input/types.ts b/src/components/ui/select/types.ts similarity index 100% rename from src/components/input/types.ts rename to src/components/ui/select/types.ts diff --git a/src/components/input/SingleSelect.stories.ts b/src/components/ui/single-select/SingleSelect.stories.ts similarity index 100% rename from src/components/input/SingleSelect.stories.ts rename to src/components/ui/single-select/SingleSelect.stories.ts diff --git a/src/components/input/SingleSelect.test.ts b/src/components/ui/single-select/SingleSelect.test.ts similarity index 100% rename from src/components/input/SingleSelect.test.ts rename to src/components/ui/single-select/SingleSelect.test.ts diff --git a/src/components/input/SingleSelect.vue b/src/components/ui/single-select/SingleSelect.vue similarity index 97% rename from src/components/input/SingleSelect.vue rename to src/components/ui/single-select/SingleSelect.vue index 294a33093c..652530c4ea 100644 --- a/src/components/input/SingleSelect.vue +++ b/src/components/ui/single-select/SingleSelect.vue @@ -84,17 +84,16 @@ import { import { ref } from 'vue' import { useI18n } from 'vue-i18n' -import { usePopoverSizing } from '@/composables/usePopoverSizing' -import { cn } from '@/utils/tailwindUtil' - import { selectContentClass, selectDropdownClass, selectItemVariants, selectTriggerVariants, stopEscapeToDocument -} from './select.variants' -import type { SelectOption } from './types' +} from '@/components/ui/select/select.variants' +import type { SelectOption } from '@/components/ui/select/types' +import { usePopoverSizing } from '@/composables/usePopoverSizing' +import { cn } from '@/utils/tailwindUtil' defineOptions({ inheritAttrs: false diff --git a/src/components/widget/SampleModelSelector.vue b/src/components/widget/SampleModelSelector.vue index 8758218555..dfe590a6b2 100644 --- a/src/components/widget/SampleModelSelector.vue +++ b/src/components/widget/SampleModelSelector.vue @@ -131,8 +131,8 @@ import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' import Tag from '@/components/chip/Tag.vue' import SearchInput from '@/components/ui/search-input/SearchInput.vue' -import MultiSelect from '@/components/input/MultiSelect.vue' -import SingleSelect from '@/components/input/SingleSelect.vue' +import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue' +import SingleSelect from '@/components/ui/single-select/SingleSelect.vue' import Button from '@/components/ui/button/Button.vue' import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue' import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue' diff --git a/src/components/widget/layout/BaseModalLayout.stories.ts b/src/components/widget/layout/BaseModalLayout.stories.ts index fb70e5a133..e0c3aab6a0 100644 --- a/src/components/widget/layout/BaseModalLayout.stories.ts +++ b/src/components/widget/layout/BaseModalLayout.stories.ts @@ -7,9 +7,9 @@ import CardBottom from '@/components/card/CardBottom.vue' import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' import Tag from '@/components/chip/Tag.vue' -import MultiSelect from '@/components/input/MultiSelect.vue' +import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue' import SearchInput from '@/components/ui/search-input/SearchInput.vue' -import SingleSelect from '@/components/input/SingleSelect.vue' +import SingleSelect from '@/components/ui/single-select/SingleSelect.vue' import type { NavGroupData, NavItemData } from '@/types/navTypes' import { OnCloseKey } from '@/types/widgetTypes' import { createGridStyle } from '@/utils/gridUtil' diff --git a/src/platform/assets/components/AssetFilterBar.test.ts b/src/platform/assets/components/AssetFilterBar.test.ts index a1862a4b54..197c77cce1 100644 --- a/src/platform/assets/components/AssetFilterBar.test.ts +++ b/src/platform/assets/components/AssetFilterBar.test.ts @@ -24,7 +24,7 @@ const i18n = createI18n({ }) // Mock components with minimal functionality for business logic testing -vi.mock('@/components/input/MultiSelect.vue', () => ({ +vi.mock('@/components/ui/multi-select/MultiSelect.vue', () => ({ default: { name: 'MultiSelect', props: { @@ -46,7 +46,7 @@ vi.mock('@/components/input/MultiSelect.vue', () => ({ } })) -vi.mock('@/components/input/SingleSelect.vue', () => ({ +vi.mock('@/components/ui/single-select/SingleSelect.vue', () => ({ default: { name: 'SingleSelect', props: { diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue index bb48106f0b..a85322a1e9 100644 --- a/src/platform/assets/components/AssetFilterBar.vue +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -59,9 +59,9 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' -import MultiSelect from '@/components/input/MultiSelect.vue' -import SingleSelect from '@/components/input/SingleSelect.vue' -import type { SelectOption } from '@/components/input/types' +import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue' +import type { SelectOption } from '@/components/ui/select/types' +import SingleSelect from '@/components/ui/single-select/SingleSelect.vue' import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { diff --git a/src/platform/assets/components/UploadModelConfirmation.vue b/src/platform/assets/components/UploadModelConfirmation.vue index 722d566dce..106d1190fb 100644 --- a/src/platform/assets/components/UploadModelConfirmation.vue +++ b/src/platform/assets/components/UploadModelConfirmation.vue @@ -46,7 +46,7 @@ diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts index 91e857f9e8..cdb3016f63 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts @@ -47,7 +47,8 @@ const testState = vi.hoisted(() => ({ })) vi.mock('@vueuse/core', () => ({ - useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible') + useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'), + createSharedComposable: (fn: T) => fn })) vi.mock('@/renderer/core/canvas/canvasStore', () => ({ @@ -264,18 +265,56 @@ describe('useVueNodeResizeTracking', () => { expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId) }) - it('resyncs slot anchors for collapsed nodes without writing bounds', () => { + it('writes collapsed dimensions through the normal bounds path', () => { const nodeId = 'test-node' - const { entry, rectSpy } = createResizeEntry({ + const collapsedWidth = 200 + const collapsedHeight = 40 + const { entry } = createResizeEntry({ nodeId, + width: collapsedWidth, + height: collapsedHeight, + left: 100, + top: 200, collapsed: true }) + const titleHeight = LiteGraph.NODE_TITLE_HEIGHT + + // Seed with larger expanded size so the collapsed write is a real change + seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 }) resizeObserverState.callback?.([entry], createObserverMock()) - expect(rectSpy).not.toHaveBeenCalled() - expect(testState.setSource).not.toHaveBeenCalled() - expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled() + expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM) + expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([ + { + nodeId, + bounds: { + x: 100, + y: 200 + titleHeight, + width: collapsedWidth, + height: collapsedHeight + } + } + ]) expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId) }) + + it('updates bounds with expanded dimensions on collapse-to-expand transition', () => { + const nodeId = 'test-node' + + // Seed with smaller (collapsed) size so expand triggers a real bounds update + seedNodeLayout({ nodeId, left: 100, top: 200, width: 200, height: 10 }) + + const { entry } = createResizeEntry({ + nodeId, + width: 240, + height: 180, + left: 100, + top: 200 + }) + resizeObserverState.callback?.([entry], createObserverMock()) + + expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM) + expect(testState.batchUpdateNodeBounds).toHaveBeenCalled() + }) }) diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts index 780d67ab5b..b503752f3a 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts @@ -8,8 +8,7 @@ * Supports different element types (nodes, slots, widgets, etc.) with * customizable data attributes and update handlers. */ -import { getCurrentInstance, onMounted, onUnmounted, toValue, watch } from 'vue' -import type { MaybeRefOrGetter } from 'vue' +import { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue' import { useDocumentVisibility } from '@vueuse/core' @@ -139,15 +138,6 @@ const resizeObserver = new ResizeObserver((entries) => { const nodeId: NodeId | undefined = elementType === 'node' ? elementId : undefined - // Skip collapsed nodes — their DOM height is just the header, and writing - // that back to the layout store would overwrite the stored expanded size. - if (elementType === 'node' && element.dataset.collapsed != null) { - if (nodeId) { - nodesNeedingSlotResync.add(nodeId) - } - continue - } - // Use borderBoxSize when available; fall back to contentRect for older engines/tests // Border box is the border included FULL wxh DOM value. const borderBox = Array.isArray(entry.borderBoxSize) @@ -158,6 +148,7 @@ const resizeObserver = new ResizeObserver((entries) => { } const width = Math.max(0, borderBox.inlineSize) const height = Math.max(0, borderBox.blockSize) + const nodeLayout = nodeId ? layoutStore.getNodeLayoutRef(nodeId).value : null @@ -281,10 +272,9 @@ const resizeObserver = new ResizeObserver((entries) => { * ``` */ export function useVueElementTracking( - appIdentifierMaybe: MaybeRefOrGetter, + appIdentifier: string, trackingType: string ) { - const appIdentifier = toValue(appIdentifierMaybe) onMounted(() => { const element = getCurrentInstance()?.proxy?.$el if (!(element instanceof HTMLElement) || !appIdentifier) return @@ -309,6 +299,7 @@ export function useVueElementTracking( delete element.dataset[config.dataAttribute] cachedNodeMeasurements.delete(element) elementsNeedingFreshMeasurement.delete(element) + deferredElements.delete(element) resizeObserver.unobserve(element) }) } From 1020e8cf320f793fcb2e3f2ba221a02c776b204e Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Mon, 20 Apr 2026 04:37:51 +0900 Subject: [PATCH 016/460] 1.44.5 (#11213) Patch version increment to 1.44.5 **Base branch:** `main` --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Christian Byrne --- package.json | 2 +- src/locales/ar/main.json | 19 +++ src/locales/ar/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/en/main.json | 4 +- src/locales/en/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/es/main.json | 19 +++ src/locales/es/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/fa/main.json | 19 +++ src/locales/fa/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/fr/main.json | 19 +++ src/locales/fr/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/ja/main.json | 19 +++ src/locales/ja/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/ko/main.json | 19 +++ src/locales/ko/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/pt-BR/main.json | 19 +++ src/locales/pt-BR/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/ru/main.json | 19 +++ src/locales/ru/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/tr/main.json | 19 +++ src/locales/tr/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/zh-TW/main.json | 19 +++ src/locales/zh-TW/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ src/locales/zh/main.json | 19 +++ src/locales/zh/nodeDefs.json | 275 ++++++++++++++++++++++++++++++++ 25 files changed, 3513 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0b832a430b..7b3f089886 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.44.4", + "version": "1.44.5", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index 707f70c745..b9f5b13c9b 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -584,6 +584,8 @@ "publishButton": "النشر على ComfyHub", "publishFailedDescription": "حدث خطأ أثناء نشر سير العمل الخاص بك. يرجى المحاولة مرة أخرى.", "publishFailedTitle": "فشل النشر", + "publishSuccessDescription": "تم نشر سير العمل الخاص بك على ComfyHub.", + "publishSuccessTitle": "تم النشر بنجاح", "removeExampleImage": "إزالة الصورة النموذجية", "selectAThumbnail": "اختر صورة مصغرة", "shareAs": "مشاركة كـ", @@ -1214,7 +1216,9 @@ "nothingToDelete": "لا يوجد ما يمكن حذفه", "nothingToDuplicate": "لا يوجد ما يمكن نسخه", "nothingToRename": "لا يوجد ما يمكن إعادة تسميته", + "off": "إيقاف", "ok": "موافق", + "on": "تشغيل", "openManager": "فتح المدير", "openNewIssue": "فتح مشكلة جديدة", "or": "أو", @@ -1641,7 +1645,16 @@ "exportModel": "تصدير النموذج", "exportRecording": "تصدير التسجيل", "exportingModel": "جارٍ تصدير النموذج...", + "fitToViewer": "تكييف مع العارض", "fov": "مجال الرؤية (FOV)", + "gizmo": { + "label": "أداة التحكم", + "reset": "إعادة ضبط التحويل", + "rotate": "تدوير", + "scale": "تغيير الحجم", + "toggle": "تبديل أداة التحكم", + "translate": "تحريك" + }, "hdri": { "changeFile": "تغيير HDRI", "intensity": "الشدة", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "رودان", "Runway": "رن واي", + "Sonilo": "Sonilo", "Sora": "سورا", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "سلسلة ثابتة", "string": "سلسلة نصية", "style_model": "نموذج النمط", + "supir": "supir", "text": "نص", "textgen": "textgen", "training": "تدريب", @@ -2495,6 +2510,8 @@ "advancedInputs": "مدخلات متقدمة", "bypass": "تجاوز", "color": "لون العقدة", + "editSubgraph": "تعديل الرسم البياني الفرعي", + "editTitle": "تعديل العنوان", "enterSubgraph": "دخول الرسم الفرعي", "errorHelp": "للمزيد من المساعدة، {github} أو {support}", "errorHelpGithub": "إرسال مشكلة على GitHub", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "فشل في شراء الرصيد: {error}", "failedToQueue": "فشل في الإضافة إلى قائمة الانتظار", "failedToSaveDraft": "فشل في حفظ مسودة سير العمل", + "failedToSetGizmoMode": "فشل في تعيين وضع أداة التحكم", "failedToToggleCamera": "فشل في تبديل الكاميرا", + "failedToToggleGizmo": "فشل في تبديل أداة التحكم", "failedToToggleGrid": "فشل في تبديل الشبكة", "failedToUpdateBackgroundColor": "فشل في تحديث لون الخلفية", "failedToUpdateBackgroundImage": "فشل في تحديث صورة الخلفية", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index b496eea956..dc63e61183 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "إنشاء فيديو باستخدام Seedance 2.0 من صورة الإطار الأول وصورة الإطار الأخير (اختياري).", + "display_name": "ByteDance Seedance 2.0 من الإطار الأول/الأخير إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد الإنشاء" + }, + "first_frame": { + "name": "الإطار الأول", + "tooltip": "صورة الإطار الأول للفيديو." + }, + "last_frame": { + "name": "الإطار الأخير", + "tooltip": "صورة الإطار الأخير للفيديو." + }, + "model": { + "name": "النموذج", + "tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة." + }, + "model_duration": { + "name": "المدة" + }, + "model_generate_audio": { + "name": "توليد الصوت" + }, + "model_prompt": { + "name": "الموجه" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "هل تريد إضافة علامة مائية إلى الفيديو." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "إنشاء أو تعديل أو تمديد فيديو باستخدام Seedance 2.0 مع صور أو فيديوهات أو صوتيات مرجعية. يدعم المراجع متعددة الوسائط، وتحرير الفيديو، وتمديد الفيديو.", + "display_name": "ByteDance Seedance 2.0 من مرجع إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد الإنشاء" + }, + "model": { + "name": "النموذج", + "tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة." + }, + "model_duration": { + "name": "المدة" + }, + "model_generate_audio": { + "name": "توليد الصوت" + }, + "model_prompt": { + "name": "الموجه" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "هل تريد إضافة علامة مائية إلى الفيديو." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "إنشاء فيديو باستخدام نماذج Seedance 2.0 بناءً على موجه نصي.", + "display_name": "ByteDance Seedance 2.0 من نص إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد الإنشاء" + }, + "model": { + "name": "النموذج", + "tooltip": "Seedance 2.0 لأعلى جودة؛ Seedance 2.0 Fast لتحسين السرعة." + }, + "model_duration": { + "name": "المدة" + }, + "model_generate_audio": { + "name": "توليد الصوت" + }, + "model_prompt": { + "name": "الموجه" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "هل تريد إضافة علامة مائية إلى الفيديو." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.", "display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "مطابقة ألوان صورة مع أخرى باستخدام خوارزميات متنوعة.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "الصورة أو الصور المرجعية لمطابقة الألوان معها. إذا لم يتم توفيرها، سيتم تخطي المعالجة." + }, + "image_target": { + "name": "image_target", + "tooltip": "الصورة أو الصور التي سيتم تطبيق تحويل الألوان عليها." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: كل إطار يُطابق مع image_ref بشكل فردي. uniform: تجميع إحصائيات جميع الإطارات المصدرية كأساس، والمطابقة مع image_ref. target_frame: استخدام إطار محدد كأساس للتحويل إلى image_ref، ويُطبق بشكل موحد على جميع الإطارات (يحافظ على الفروقات النسبية)." + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "دمج الخطافات [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "استخراج سلسلة من JSON", + "inputs": { + "json_string": { + "name": "سلسلة_json" + }, + "key": { + "name": "مفتاح" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "يستخدم النموذج المقدم، والتوجيه الإيجابي والسلبي لإزالة الضجيج من الصورة الكامنة.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "يسحب المخرجات المنزوعة الضوضاء نحو الإدخال الكامن. قيمة أعلى = تطابق أقوى مع الإدخال. ۰ لتعطيل الميزة." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "عتبة سيغما التي دونها يتم تعطيل restore_cfg." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "التحكم في قوة التأثير في نهاية العينة (سيغما منخفضة). يتم الاستيفاء خطياً من البداية." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "التحكم في قوة التأثير في بداية العينة (سيغما عالية)." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "تهيئة SV3D", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "إنشاء موسيقى من وصف نصي باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. اترك المدة ۰ ليقوم النموذج بتحديدها تلقائياً من الوصف.", + "display_name": "تحويل النص إلى موسيقى بواسطة Sonilo", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد الإنشاء" + }, + "duration": { + "name": "المدة", + "tooltip": "المدة المستهدفة بالثواني. ضع القيمة ۰ ليقوم النموذج بتحديد المدة تلقائياً من الوصف. الحد الأقصى: ٦ دقائق." + }, + "prompt": { + "name": "الوصف", + "tooltip": "وصف نصي يصف الموسيقى المطلوب إنشاؤها." + }, + "seed": { + "name": "البذرة", + "tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "إنشاء موسيقى من محتوى الفيديو باستخدام نموذج الذكاء الاصطناعي الخاص بـ Sonilo. يقوم بتحليل الفيديو وإنشاء موسيقى متوافقة.", + "display_name": "تحويل الفيديو إلى موسيقى بواسطة Sonilo", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد الإنشاء" + }, + "prompt": { + "name": "الوصف", + "tooltip": "وصف نصي اختياري لتوجيه إنشاء الموسيقى. اتركه فارغاً للحصول على أفضل جودة - سيقوم النموذج بتحليل محتوى الفيديو بالكامل." + }, + "seed": { + "name": "البذرة", + "tooltip": "بذرة لضمان إمكانية إعادة الإنتاج. حالياً يتم تجاهلها من قبل خدمة Sonilo ولكنها موجودة للحفاظ على اتساق الرسم البياني." + }, + "video": { + "name": "الفيديو", + "tooltip": "فيديو الإدخال لإنشاء الموسيقى منه. الحد الأقصى للمدة: ٦ دقائق." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "يفصل الصوت إلى القناتين اليسرى واليمنى.", "display_name": "فصل قنوات الصوت", @@ -16025,6 +16292,10 @@ "thinking": { "name": "التفكير", "tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك." + }, + "use_default_template": { + "name": "استخدام القالب الافتراضي", + "tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "التفكير", "tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك." + }, + "use_default_template": { + "name": "استخدام القالب الافتراضي", + "tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه." } }, "outputs": { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 0f63a681b1..6a6e6a2c34 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1670,6 +1670,7 @@ "attention_experiments": "attention_experiments", "flux": "flux", "kandinsky5": "kandinsky5", + "postprocessing": "postprocessing", "hooks": "hooks", "combine": "combine", "math": "math", @@ -1701,7 +1702,6 @@ "HitPaw": "HitPaw", "sd": "sd", "Ideogram": "Ideogram", - "postprocessing": "postprocessing", "transform": "transform", "batch": "batch", "upscaling": "upscaling", @@ -1736,10 +1736,12 @@ "save": "save", "upscale_diffusion": "upscale_diffusion", "clip": "clip", + "Sonilo": "Sonilo", "Stability AI": "Stability AI", "stable_cascade": "stable_cascade", "3d_models": "3d_models", "style_model": "style_model", + "supir": "supir", "Tencent": "Tencent", "textgen": "textgen", "Topaz": "Topaz", diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index 4a59ddd21f..91e3d638c5 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "display_name": "ByteDance Seedance 2.0 First-Last-Frame to Video", + "description": "Generate video using Seedance 2.0 from a first frame image and optional last frame image.", + "inputs": { + "model": { + "name": "model", + "tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization." + }, + "first_frame": { + "name": "first_frame", + "tooltip": "First frame image for the video." + }, + "seed": { + "name": "seed", + "tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add a watermark to the video." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Last frame image for the video." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_generate_audio": { + "name": "generate_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "display_name": "ByteDance Seedance 2.0 Reference to Video", + "description": "Generate, edit, or extend video using Seedance 2.0 with reference images, videos, and audio. Supports multimodal reference, video editing, and video extension.", + "inputs": { + "model": { + "name": "model", + "tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization." + }, + "seed": { + "name": "seed", + "tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add a watermark to the video." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_generate_audio": { + "name": "generate_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "display_name": "ByteDance Seedance 2.0 Text to Video", + "description": "Generate video using Seedance 2.0 models based on a text prompt.", + "inputs": { + "model": { + "name": "model", + "tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization." + }, + "seed": { + "name": "seed", + "tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add a watermark to the video." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_generate_audio": { + "name": "generate_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "display_name": "ByteDance First-Last-Frame to Video", "description": "Generate video using prompt and first and last frames.", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "display_name": "ColorTransfer", + "description": "Match the colors of one image to another using various algorithms.", + "inputs": { + "image_target": { + "name": "image_target", + "tooltip": "Image(s) to apply the color transform to." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: each frame matched to image_ref individually. uniform: pool stats across all source frames as baseline, match to image_ref. target_frame: use one chosen frame as the baseline for the transform to image_ref, applied uniformly to all frames (preserves relative differences)" + }, + "strength": { + "name": "strength" + }, + "image_ref": { + "name": "image_ref", + "tooltip": "Reference image(s) to match colors to. If not provided, processing is skipped" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combine Hooks [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "Extract String from JSON", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "Kandinsky5ImageToVideo": { "display_name": "Kandinsky5ImageToVideo", "inputs": { @@ -14678,6 +14855,58 @@ } } }, + "SoniloTextToMusic": { + "display_name": "Sonilo Text to Music", + "description": "Generate music from a text prompt using Sonilo's AI model. Leave duration at 0 to let the model infer it from the prompt.", + "inputs": { + "prompt": { + "name": "prompt", + "tooltip": "Text prompt describing the music to generate." + }, + "duration": { + "name": "duration", + "tooltip": "Target duration in seconds. Set to 0 to let the model infer the duration from the prompt. Maximum: 6 minutes." + }, + "seed": { + "name": "seed", + "tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency." + }, + "control_after_generate": { + "name": "control after generate" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "display_name": "Sonilo Video to Music", + "description": "Generate music from video content using Sonilo's AI model. Analyzes the video and creates matching music.", + "inputs": { + "video": { + "name": "video", + "tooltip": "Input video to generate music from. Maximum duration: 6 minutes." + }, + "prompt": { + "name": "prompt", + "tooltip": "Optional text prompt to guide music generation. Leave empty for best quality - the model will fully analyze the video content." + }, + "seed": { + "name": "seed", + "tooltip": "Seed for reproducibility. Currently ignored by the Sonilo service but kept for graph consistency." + }, + "control_after_generate": { + "name": "control after generate" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "display_name": "Split Audio Channels", "description": "Separates the audio into left and right channels.", @@ -15421,6 +15650,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "vae": { + "name": "vae" + }, + "image": { + "name": "image" + }, + "strength_start": { + "name": "strength_start", + "tooltip": "Control strength at the start of sampling (high sigma)." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "Control strength at the end of sampling (low sigma). Linearly interpolated from start." + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "Pulls denoised output toward the input latent. Higher = stronger fidelity to input. 0 to disable." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "Sigma threshold below which restore_cfg is disabled." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D_Conditioning", "inputs": { @@ -16005,6 +16272,10 @@ "name": "thinking", "tooltip": "Operate in thinking mode if the model supports it." }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Use the built in system prompt/template if the model has one." + }, "sampling_mode_min_p": { "name": "min_p" }, @@ -16056,6 +16327,10 @@ "name": "thinking", "tooltip": "Operate in thinking mode if the model supports it." }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Use the built in system prompt/template if the model has one." + }, "sampling_mode_min_p": { "name": "min_p" }, diff --git a/src/locales/es/main.json b/src/locales/es/main.json index deb630ba5b..cf11921d07 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -584,6 +584,8 @@ "publishButton": "Publicar en ComfyHub", "publishFailedDescription": "Ocurrió un error al publicar tu flujo de trabajo. Por favor, inténtalo de nuevo.", "publishFailedTitle": "Error al publicar", + "publishSuccessDescription": "Tu flujo de trabajo ya está disponible en ComfyHub.", + "publishSuccessTitle": "Publicado con éxito", "removeExampleImage": "Eliminar imagen de ejemplo", "selectAThumbnail": "Selecciona una miniatura", "shareAs": "Compartir como", @@ -1214,7 +1216,9 @@ "nothingToDelete": "Nada para eliminar", "nothingToDuplicate": "Nada para duplicar", "nothingToRename": "Nada para renombrar", + "off": "Apagado", "ok": "OK", + "on": "Encendido", "openManager": "Abrir administrador", "openNewIssue": "Abrir nuevo problema", "or": "o", @@ -1641,7 +1645,16 @@ "exportModel": "Exportar modelo", "exportRecording": "Exportar grabación", "exportingModel": "Exportando modelo...", + "fitToViewer": "Ajustar al visor", "fov": "FOV", + "gizmo": { + "label": "Gizmo", + "reset": "Restablecer transformación", + "rotate": "Rotar", + "scale": "Escalar", + "toggle": "Gizmo", + "translate": "Trasladar" + }, "hdri": { "changeFile": "Cambiar HDRI", "intensity": "Intensidad", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "stable_cascade", "string": "cadena", "style_model": "modelo_de_estilo", + "supir": "supir", "text": "texto", "textgen": "textgen", "training": "entrenamiento", @@ -2495,6 +2510,8 @@ "advancedInputs": "ENTRADAS AVANZADAS", "bypass": "Omitir", "color": "Color del nodo", + "editSubgraph": "Editar subgrafo", + "editTitle": "Editar título", "enterSubgraph": "Entrar en subgrafo", "errorHelp": "Para más ayuda, {github} o {support}", "errorHelpGithub": "envía un issue en GitHub", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "No se pudo comprar créditos: {error}", "failedToQueue": "Error al encolar", "failedToSaveDraft": "No se pudo guardar el borrador del flujo de trabajo", + "failedToSetGizmoMode": "No se pudo establecer el modo de gizmo", "failedToToggleCamera": "No se pudo alternar la cámara", + "failedToToggleGizmo": "No se pudo alternar el gizmo", "failedToToggleGrid": "No se pudo alternar la cuadrícula", "failedToUpdateBackgroundColor": "No se pudo actualizar el color de fondo", "failedToUpdateBackgroundImage": "No se pudo actualizar la imagen de fondo", diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index babb6438f0..5d23f90761 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "Genera un video usando Seedance 2.0 a partir de una imagen del primer fotograma y, opcionalmente, una imagen del último fotograma.", + "display_name": "ByteDance Seedance 2.0 Primer-Último Fotograma a Video", + "inputs": { + "control_after_generate": { + "name": "control después de generar" + }, + "first_frame": { + "name": "primer_fotograma", + "tooltip": "Imagen del primer fotograma para el video." + }, + "last_frame": { + "name": "último_fotograma", + "tooltip": "Imagen del último fotograma para el video." + }, + "model": { + "name": "modelo", + "tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad." + }, + "model_duration": { + "name": "duración" + }, + "model_generate_audio": { + "name": "generar_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla." + }, + "watermark": { + "name": "marca_de_agua", + "tooltip": "Indica si se debe añadir una marca de agua al video." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "Genera, edita o extiende video usando Seedance 2.0 con imágenes, videos y audio de referencia. Soporta referencia multimodal, edición de video y extensión de video.", + "display_name": "ByteDance Seedance 2.0 Referencia a Video", + "inputs": { + "control_after_generate": { + "name": "control después de generar" + }, + "model": { + "name": "modelo", + "tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad." + }, + "model_duration": { + "name": "duración" + }, + "model_generate_audio": { + "name": "generar_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla." + }, + "watermark": { + "name": "marca_de_agua", + "tooltip": "Indica si se debe añadir una marca de agua al video." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "Genera video usando modelos Seedance 2.0 a partir de un prompt de texto.", + "display_name": "ByteDance Seedance 2.0 Texto a Video", + "inputs": { + "control_after_generate": { + "name": "control después de generar" + }, + "model": { + "name": "modelo", + "tooltip": "Seedance 2.0 para máxima calidad; Seedance 2.0 Fast para optimización de velocidad." + }, + "model_duration": { + "name": "duración" + }, + "model_generate_audio": { + "name": "generar_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla." + }, + "watermark": { + "name": "marca_de_agua", + "tooltip": "Indica si se debe añadir una marca de agua al video." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Generar video usando prompt y primer y último fotograma.", "display_name": "ByteDance Primer-Último-Fotograma a Video", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "Iguala los colores de una imagen con otra utilizando varios algoritmos.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "Imagen(es) de referencia para igualar los colores. Si no se proporciona, se omite el procesamiento." + }, + "image_target": { + "name": "image_target", + "tooltip": "Imagen(es) a las que se aplicará la transferencia de color." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: cada fotograma se iguala individualmente a image_ref. uniform: agrupa estadísticas de todos los fotogramas fuente como referencia, iguala a image_ref. target_frame: usa un fotograma elegido como referencia para la transformación a image_ref, aplicado uniformemente a todos los fotogramas (preserva las diferencias relativas)" + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combinar Hooks [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "Extraer cadena de JSON", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "Utiliza el modelo proporcionado, el acondicionamiento positivo y negativo para deshacer el ruido de la imagen latente.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "Atrae la salida denoised hacia el latent de entrada. Un valor más alto = mayor fidelidad a la entrada. 0 para desactivar." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "Umbral de sigma por debajo del cual restore_cfg se desactiva." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "Controla la intensidad al final del muestreo (sigma baja). Interpolado linealmente desde el inicio." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "Controla la intensidad al inicio del muestreo (sigma alta)." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D_Acondicionamiento", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "Genera música a partir de un texto usando el modelo de IA de Sonilo. Deja la duración en 0 para que el modelo la infiera del texto.", + "display_name": "Sonilo Texto a Música", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "Duración objetivo en segundos. Pon 0 para que el modelo infiera la duración del texto. Máximo: 6 minutos." + }, + "prompt": { + "name": "prompt", + "tooltip": "Texto descriptivo de la música a generar." + }, + "seed": { + "name": "seed", + "tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "Genera música a partir de contenido de video usando el modelo de IA de Sonilo. Analiza el video y crea música acorde.", + "display_name": "Sonilo Video a Música", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "Texto opcional para guiar la generación musical. Déjalo vacío para mejor calidad: el modelo analizará completamente el contenido del video." + }, + "seed": { + "name": "seed", + "tooltip": "Semilla para reproducibilidad. Actualmente ignorada por el servicio de Sonilo pero mantenida para la consistencia del grafo." + }, + "video": { + "name": "video", + "tooltip": "Video de entrada del que generar música. Duración máxima: 6 minutos." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "Separa el audio en canales izquierdo y derecho.", "display_name": "Separar canales de audio", @@ -16025,6 +16292,10 @@ "thinking": { "name": "pensando", "tooltip": "Operar en modo de pensamiento si el modelo lo permite." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "pensando", "tooltip": "Operar en modo de pensamiento si el modelo lo permite." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Usar la plantilla/sistema incorporado si el modelo dispone de uno." } }, "outputs": { diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index b6a265918d..0ae9402eab 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -584,6 +584,8 @@ "publishButton": "انتشار در ComfyHub", "publishFailedDescription": "در هنگام انتشار گردش‌کار شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", "publishFailedTitle": "انتشار ناموفق بود", + "publishSuccessDescription": "گردش‌کار شما اکنون در ComfyHub فعال است.", + "publishSuccessTitle": "با موفقیت منتشر شد", "removeExampleImage": "حذف تصویر نمونه", "selectAThumbnail": "یک تصویر بندانگشتی انتخاب کنید", "shareAs": "اشتراک‌گذاری به عنوان", @@ -1214,7 +1216,9 @@ "nothingToDelete": "موردی برای حذف وجود ندارد", "nothingToDuplicate": "موردی برای تکرار وجود ندارد", "nothingToRename": "موردی برای تغییر نام وجود ندارد", + "off": "خاموش", "ok": "تأیید", + "on": "روشن", "openManager": "باز کردن مدیریت", "openNewIssue": "ایجاد گزارش جدید", "or": "یا", @@ -1641,7 +1645,16 @@ "exportModel": "خروجی گرفتن مدل", "exportRecording": "خروجی گرفتن ضبط", "exportingModel": "در حال خروجی گرفتن مدل...", + "fitToViewer": "تنظیم بر اساس نمایشگر", "fov": "زاویه دید (FOV)", + "gizmo": { + "label": "Gizmo", + "reset": "بازنشانی تغییرات", + "rotate": "چرخش", + "scale": "مقیاس", + "toggle": "فعال/غیرفعال کردن Gizmo", + "translate": "جابجایی" + }, "hdri": { "changeFile": "تغییر HDRI", "intensity": "شدت", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "stable cascade", "string": "رشته", "style_model": "مدل سبک", + "supir": "supir", "text": "متن", "textgen": "textgen", "training": "آموزش", @@ -2495,6 +2510,8 @@ "advancedInputs": "ورودی‌های پیشرفته", "bypass": "عبور", "color": "رنگ نود", + "editSubgraph": "ویرایش زیرگراف", + "editTitle": "ویرایش عنوان", "enterSubgraph": "ورود به زیرگراف", "errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}", "errorHelpGithub": "ثبت یک issue در GitHub", @@ -3457,7 +3474,9 @@ "failedToPurchaseCredits": "خرید اعتبار انجام نشد: {error}", "failedToQueue": "صف‌بندی انجام نشد", "failedToSaveDraft": "ذخیره پیش‌نویس workflow ناموفق بود", + "failedToSetGizmoMode": "تنظیم حالت Gizmo ناموفق بود", "failedToToggleCamera": "تغییر وضعیت دوربین انجام نشد", + "failedToToggleGizmo": "فعال/غیرفعال کردن Gizmo ناموفق بود", "failedToToggleGrid": "تغییر وضعیت شبکه انجام نشد", "failedToUpdateBackgroundColor": "به‌روزرسانی رنگ پس‌زمینه انجام نشد", "failedToUpdateBackgroundImage": "به‌روزرسانی تصویر پس‌زمینه انجام نشد", diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index 6e77f19f65..bdc73569ca 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "تولید ویدیو با استفاده از Seedance 2.0 از تصویر اولین فریم و در صورت نیاز تصویر آخرین فریم.", + "display_name": "ByteDance Seedance 2.0 تبدیل اولین-آخرین فریم به ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "first_frame": { + "name": "اولین فریم", + "tooltip": "تصویر اولین فریم برای ویدیو." + }, + "last_frame": { + "name": "آخرین فریم", + "tooltip": "تصویر آخرین فریم برای ویدیو." + }, + "model": { + "name": "مدل", + "tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت." + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_generate_audio": { + "name": "تولید صدا" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "seed", + "tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "تولید، ویرایش یا گسترش ویدیو با استفاده از Seedance 2.0 و تصاویر مرجع، ویدیوها و صدا. پشتیبانی از مرجع چندرسانه‌ای، ویرایش ویدیو و گسترش ویدیو.", + "display_name": "ByteDance Seedance 2.0 مرجع به ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل", + "tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت." + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_generate_audio": { + "name": "تولید صدا" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "seed", + "tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "تولید ویدیو با استفاده از مدل‌های Seedance 2.0 بر اساس پرامپت متنی.", + "display_name": "ByteDance Seedance 2.0 متن به ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل", + "tooltip": "Seedance 2.0 برای بالاترین کیفیت؛ Seedance 2.0 Fast برای بهینه‌سازی سرعت." + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_generate_audio": { + "name": "تولید صدا" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "seed", + "tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج صرف‌نظر از seed غیرقطعی هستند." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک به ویدیو اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "تولید ویدیو با استفاده از پرامپت و اولین و آخرین فریم.", "display_name": "تبدیل اولین و آخرین فریم به ویدیو ByteDance", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "هماهنگ‌سازی رنگ‌های یک تصویر با تصویر دیگر با استفاده از الگوریتم‌های مختلف.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "تصویر(ها)ی مرجع برای هماهنگ‌سازی رنگ‌ها. در صورت عدم ارائه، پردازش انجام نمی‌شود." + }, + "image_target": { + "name": "image_target", + "tooltip": "تصویر(ها)یی که تبدیل رنگ باید بر روی آن‌ها اعمال شود." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: هر فریم به صورت جداگانه با image_ref هماهنگ می‌شود. uniform: آمار تمام فریم‌های منبع به عنوان مبنا جمع‌آوری شده و با image_ref هماهنگ می‌شود. target_frame: یک فریم انتخابی به عنوان مبنا برای تبدیل به image_ref استفاده می‌شود و به طور یکنواخت بر همه فریم‌ها اعمال می‌گردد (تفاوت‌های نسبی حفظ می‌شوند)." + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "ترکیب هوک‌ها [۲]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "استخراج رشته از JSON", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "با استفاده از مدل ارائه‌شده و شرط‌های مثبت و منفی، تصویر نهفته را از نویز پاک‌سازی می‌کند.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "خروجی نویززدایی‌شده را به سمت ورودی latent می‌کشاند. مقدار بالاتر = وفاداری بیشتر به ورودی. ۰ برای غیرفعال‌سازی." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "آستانه سیگما که زیر آن restore_cfg غیرفعال می‌شود." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "کنترل شدت در انتهای نمونه‌گیری (سیگما پایین). به صورت خطی از مقدار ابتدایی میان‌یابی می‌شود." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "کنترل شدت در ابتدای نمونه‌گیری (سیگما بالا)." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "شرط‌گذاری SV3D", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "تولید موسیقی از یک پرامپت متنی با استفاده از مدل هوش مصنوعی Sonilo. مدت زمان را روی ۰ قرار دهید تا مدل آن را از پرامپت تشخیص دهد.", + "display_name": "تبدیل متن به موسیقی با Sonilo", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "مدت زمان هدف به ثانیه. برای تشخیص خودکار مدت زمان توسط مدل، مقدار را روی ۰ قرار دهید. حداکثر: ۶ دقیقه." + }, + "prompt": { + "name": "prompt", + "tooltip": "پرامپت متنی برای توصیف موسیقی مورد نظر جهت تولید." + }, + "seed": { + "name": "seed", + "tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته می‌شود اما برای سازگاری گراف حفظ شده است." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "تولید موسیقی از محتوای ویدیویی با استفاده از مدل هوش مصنوعی Sonilo. ویدیو را تحلیل کرده و موسیقی متناسب ایجاد می‌کند.", + "display_name": "تبدیل ویدیو به موسیقی با Sonilo", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "پرامپت متنی اختیاری برای راهنمایی تولید موسیقی. برای بهترین کیفیت خالی بگذارید - مدل به طور کامل محتوای ویدیو را تحلیل می‌کند." + }, + "seed": { + "name": "seed", + "tooltip": "Seed برای تکرارپذیری. در حال حاضر توسط سرویس Sonilo نادیده گرفته می‌شود اما برای سازگاری گراف حفظ شده است." + }, + "video": { + "name": "video", + "tooltip": "ویدیوی ورودی برای تولید موسیقی. حداکثر مدت زمان: ۶ دقیقه." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "صدا را به کانال‌های چپ و راست جدا می‌کند.", "display_name": "تقسیم کانال‌های صوتی", @@ -16025,6 +16292,10 @@ "thinking": { "name": "تفکر", "tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "تفکر", "tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "در صورت وجود، از پرامپت/قالب سیستمی داخلی مدل استفاده شود." } }, "outputs": { diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 32e0bae97a..21b0f10078 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -584,6 +584,8 @@ "publishButton": "Publier sur ComfyHub", "publishFailedDescription": "Une erreur s’est produite lors de la publication de votre workflow. Veuillez réessayer.", "publishFailedTitle": "Échec de la publication", + "publishSuccessDescription": "Votre workflow est maintenant en ligne sur ComfyHub.", + "publishSuccessTitle": "Publication réussie", "removeExampleImage": "Supprimer l’image d’exemple", "selectAThumbnail": "Sélectionner une miniature", "shareAs": "Partager en tant que", @@ -1214,7 +1216,9 @@ "nothingToDelete": "Rien à supprimer", "nothingToDuplicate": "Rien à dupliquer", "nothingToRename": "Rien à renommer", + "off": "Désactivé", "ok": "OK", + "on": "Activé", "openManager": "Ouvrir le gestionnaire", "openNewIssue": "Ouvrir un nouveau problème", "or": "ou", @@ -1641,7 +1645,16 @@ "exportModel": "Exportation du modèle", "exportRecording": "Exporter l'enregistrement", "exportingModel": "Exportation du modèle en cours...", + "fitToViewer": "Ajuster à la visionneuse", "fov": "FOV", + "gizmo": { + "label": "Gizmo", + "reset": "Réinitialiser la transformation", + "rotate": "Pivoter", + "scale": "Échelle", + "toggle": "Gizmo", + "translate": "Déplacer" + }, "hdri": { "changeFile": "Changer l'HDRI", "intensity": "Intensité", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "stable_cascade", "string": "chaîne", "style_model": "modèle_de_style", + "supir": "supir", "text": "texte", "textgen": "textgen", "training": "entraînement", @@ -2495,6 +2510,8 @@ "advancedInputs": "ENTRÉES AVANCÉES", "bypass": "Contourner", "color": "Couleur du nœud", + "editSubgraph": "Modifier le sous-graphe", + "editTitle": "Modifier le titre", "enterSubgraph": "Entrer dans le sous-graphe", "errorHelp": "Pour plus d'aide, {github} ou {support}", "errorHelpGithub": "soumettre un ticket GitHub", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "Échec de l'achat de crédits : {error}", "failedToQueue": "Échec de la mise en file d'attente", "failedToSaveDraft": "Échec de l’enregistrement du brouillon du flux de travail", + "failedToSetGizmoMode": "Échec du changement de mode du gizmo", "failedToToggleCamera": "Échec de l’activation/désactivation de la caméra", + "failedToToggleGizmo": "Échec de l’activation du gizmo", "failedToToggleGrid": "Échec de l’activation/désactivation de la grille", "failedToUpdateBackgroundColor": "Échec de la mise à jour de la couleur d’arrière-plan", "failedToUpdateBackgroundImage": "Échec de la mise à jour de l’image d’arrière-plan", diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index 3d0e770bc2..96fd36b907 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "Générez une vidéo avec Seedance 2.0 à partir d'une image de première image et, optionnellement, d'une image de dernière image.", + "display_name": "ByteDance Seedance 2.0 Première-Dernière-Image vers Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "first_frame": { + "name": "première image", + "tooltip": "Image de la première image pour la vidéo." + }, + "last_frame": { + "name": "dernière image", + "tooltip": "Image de la dernière image pour la vidéo." + }, + "model": { + "name": "modèle", + "tooltip": "Seedance 2.0 pour une qualité maximale ; Seedance 2.0 Fast pour une optimisation de la vitesse." + }, + "model_duration": { + "name": "durée" + }, + "model_generate_audio": { + "name": "générer_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "seed", + "tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter ou non un filigrane à la vidéo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "Générez, éditez ou étendez une vidéo avec Seedance 2.0 à l'aide d'images, de vidéos et d'audios de référence. Prend en charge la référence multimodale, l'édition vidéo et l'extension vidéo.", + "display_name": "ByteDance Seedance 2.0 Référence vers Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle", + "tooltip": "Seedance 2.0 pour une qualité maximale ; Seedance 2.0 Fast pour une optimisation de la vitesse." + }, + "model_duration": { + "name": "durée" + }, + "model_generate_audio": { + "name": "générer_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "seed", + "tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter ou non un filigrane à la vidéo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "Générez une vidéo avec les modèles Seedance 2.0 à partir d'un prompt textuel.", + "display_name": "ByteDance Seedance 2.0 Texte vers Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle", + "tooltip": "Seedance 2.0 pour une qualité maximale ; Seedance 2.0 Fast pour une optimisation de la vitesse." + }, + "model_duration": { + "name": "durée" + }, + "model_generate_audio": { + "name": "générer_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "seed", + "tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter ou non un filigrane à la vidéo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Générer une vidéo en utilisant l'invite et les première et dernière images.", "display_name": "ByteDance Première-Dernière Image vers Vidéo", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "Faire correspondre les couleurs d'une image à une autre en utilisant divers algorithmes.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "Image(s) de référence à laquelle faire correspondre les couleurs. Si non fourni, le traitement est ignoré." + }, + "image_target": { + "name": "image_target", + "tooltip": "Image(s) auxquelles appliquer la transformation de couleur." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame : chaque image est ajustée individuellement à image_ref. uniform : les statistiques de toutes les images sources sont regroupées comme référence, puis ajustées à image_ref. target_frame : une image choisie sert de référence pour la transformation vers image_ref, appliquée uniformément à toutes les images (préserve les différences relatives)." + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combiner Hooks [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "Extraire une chaîne du JSON", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "Utilise le modèle fourni, le conditionnement positif et négatif pour débruiter l'image latente.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "Ramène la sortie débruitée vers le latent d'entrée. Plus la valeur est élevée, plus la fidélité à l'entrée est forte. 0 pour désactiver." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "Seuil sigma en dessous duquel restore_cfg est désactivé." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "Contrôle la force à la fin de l'échantillonnage (sigma faible). Interpolé linéairement depuis le début." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "Contrôle la force au début de l'échantillonnage (sigma élevé)." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D_Conditioning", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "Générez de la musique à partir d'une invite textuelle en utilisant le modèle IA de Sonilo. Laissez la durée à 0 pour que le modèle l'infère à partir de l'invite.", + "display_name": "Sonilo Texte en Musique", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "Durée cible en secondes. Mettez 0 pour laisser le modèle inférer la durée à partir de l'invite. Maximum : 6 minutes." + }, + "prompt": { + "name": "prompt", + "tooltip": "Invite textuelle décrivant la musique à générer." + }, + "seed": { + "name": "seed", + "tooltip": "Graine pour la reproductibilité. Actuellement ignorée par le service Sonilo mais conservée pour la cohérence du graphe." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "Générez de la musique à partir d'un contenu vidéo en utilisant le modèle IA de Sonilo. Analyse la vidéo et crée une musique correspondante.", + "display_name": "Sonilo Vidéo en Musique", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "Invite textuelle optionnelle pour guider la génération musicale. Laissez vide pour une qualité optimale - le modèle analysera entièrement le contenu vidéo." + }, + "seed": { + "name": "seed", + "tooltip": "Graine pour la reproductibilité. Actuellement ignorée par le service Sonilo mais conservée pour la cohérence du graphe." + }, + "video": { + "name": "video", + "tooltip": "Vidéo d'entrée à partir de laquelle générer la musique. Durée maximale : 6 minutes." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "Sépare l'audio en canaux gauche et droit.", "display_name": "Séparer les canaux audio", @@ -16025,6 +16292,10 @@ "thinking": { "name": "réflexion", "tooltip": "Fonctionner en mode réflexion si le modèle le permet." + }, + "use_default_template": { + "name": "utiliser le modèle par défaut", + "tooltip": "Utiliser l'invite/le modèle système intégré si le modèle en possède un." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "réflexion", "tooltip": "Fonctionner en mode réflexion si le modèle le permet." + }, + "use_default_template": { + "name": "utiliser le modèle par défaut", + "tooltip": "Utiliser l'invite/le modèle système intégré si le modèle en possède un." } }, "outputs": { diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 6f8caaa9f4..d019b3da34 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -584,6 +584,8 @@ "publishButton": "ComfyHub へ公開", "publishFailedDescription": "ワークフローの公開中に問題が発生しました。もう一度お試しください。", "publishFailedTitle": "公開に失敗しました", + "publishSuccessDescription": "あなたのワークフローがComfyHubで公開されました。", + "publishSuccessTitle": "公開に成功しました", "removeExampleImage": "サンプル画像を削除", "selectAThumbnail": "サムネイルを選択", "shareAs": "次の形式で共有", @@ -1214,7 +1216,9 @@ "nothingToDelete": "削除するものがありません", "nothingToDuplicate": "複製するものがありません", "nothingToRename": "リネームするものがありません", + "off": "オフ", "ok": "OK", + "on": "オン", "openManager": "マネージャーを開く", "openNewIssue": "新しい問題を開く", "or": "または", @@ -1641,7 +1645,16 @@ "exportModel": "モデルをエクスポート", "exportRecording": "録画をエクスポート", "exportingModel": "モデルをエクスポート中...", + "fitToViewer": "ビューアに合わせる", "fov": "FOV", + "gizmo": { + "label": "ギズモ", + "reset": "変換をリセット", + "rotate": "回転", + "scale": "スケール", + "toggle": "ギズモ", + "translate": "移動" + }, "hdri": { "changeFile": "HDRIを変更", "intensity": "強度", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "安定したカスケード", "string": "文字列", "style_model": "スタイルモデル", + "supir": "supir", "text": "テキスト", "textgen": "textgen", "training": "トレーニング", @@ -2495,6 +2510,8 @@ "advancedInputs": "詳細入力", "bypass": "バイパス", "color": "ノードカラー", + "editSubgraph": "サブグラフを編集", + "editTitle": "タイトルを編集", "enterSubgraph": "サブグラフに入る", "errorHelp": "詳細なヘルプについては、{github} または {support} をご利用ください", "errorHelpGithub": "GitHub イシューを提出", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "クレジットの購入に失敗しました: {error}", "failedToQueue": "キューに追加できませんでした", "failedToSaveDraft": "ワークフロードラフトの保存に失敗しました", + "failedToSetGizmoMode": "ギズモモードの設定に失敗しました", "failedToToggleCamera": "カメラの切り替えに失敗しました", + "failedToToggleGizmo": "ギズモの切り替えに失敗しました", "failedToToggleGrid": "グリッドの切り替えに失敗しました", "failedToUpdateBackgroundColor": "背景色の更新に失敗しました", "failedToUpdateBackgroundImage": "背景画像の更新に失敗しました", diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index 2131fb7390..59917635df 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "Seedance 2.0 を使用して、最初のフレーム画像とオプションの最後のフレーム画像から動画を生成します。", + "display_name": "ByteDance Seedance 2.0 ファースト・ラストフレームから動画生成", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "first_frame": { + "name": "最初のフレーム", + "tooltip": "動画の最初のフレーム画像です。" + }, + "last_frame": { + "name": "最後のフレーム", + "tooltip": "動画の最後のフレーム画像です。" + }, + "model": { + "name": "モデル", + "tooltip": "最高品質には Seedance 2.0、速度最適化には Seedance 2.0 Fast を使用します。" + }, + "model_duration": { + "name": "継続時間" + }, + "model_generate_audio": { + "name": "オーディオ生成" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "アスペクト比" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード", + "tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "動画にウォーターマークを追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "Seedance 2.0 を使い、リファレンス画像・動画・音声から動画を生成・編集・拡張します。マルチモーダルリファレンス、動画編集、動画拡張に対応しています。", + "display_name": "ByteDance Seedance 2.0 リファレンスから動画生成", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "モデル", + "tooltip": "最高品質には Seedance 2.0、速度最適化には Seedance 2.0 Fast を使用します。" + }, + "model_duration": { + "name": "継続時間" + }, + "model_generate_audio": { + "name": "オーディオ生成" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "アスペクト比" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード", + "tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "動画にウォーターマークを追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "テキストプロンプトに基づいて Seedance 2.0 モデルで動画を生成します。", + "display_name": "ByteDance Seedance 2.0 テキストから動画生成", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "モデル", + "tooltip": "最高品質には Seedance 2.0、速度最適化には Seedance 2.0 Fast を使用します。" + }, + "model_duration": { + "name": "継続時間" + }, + "model_generate_audio": { + "name": "オーディオ生成" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "アスペクト比" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード", + "tooltip": "シードはノードの再実行を制御しますが、シードに関わらず結果は非決定的です。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "動画にウォーターマークを追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "プロンプトと最初・最後のフレームを使用して動画を生成します。", "display_name": "ByteDance 最初-最後フレームから動画生成", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "さまざまなアルゴリズムを使用して、ある画像の色を別の画像に合わせます。", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "色を合わせる参照画像。指定しない場合は処理をスキップします。" + }, + "image_target": { + "name": "image_target", + "tooltip": "色変換を適用する画像。" + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame:各フレームを個別にimage_refに合わせます。uniform:すべてのソースフレームの統計をまとめて基準とし、image_refに合わせます。target_frame:選択した1フレームを変換の基準として使用し、すべてのフレームに均一に適用します(相対的な違いを保持)。" + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "フックを組み合わせる [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "JSONから文字列を抽出", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "提供されたモデル、正の条件付けと負の条件付けを使用して潜在画像のノイズを除去します。", "display_name": "Kサンプラー", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "ノイズ除去後の出力を入力のlatentに近づけます。値が高いほど入力への忠実度が強くなります。0で無効化。" + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "このシグマ閾値未満ではrestore_cfgが無効になります。" + }, + "strength_end": { + "name": "strength_end", + "tooltip": "サンプリング終了時(低シグマ)の強度を制御します。開始値から線形補間されます。" + }, + "strength_start": { + "name": "strength_start", + "tooltip": "サンプリング開始時(高シグマ)の強度を制御します。" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D条件付け", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "SoniloのAIモデルを使ってテキストプロンプトから音楽を生成します。継続時間を0にすると、プロンプトから自動的に推定されます。", + "display_name": "Sonilo テキストから音楽生成", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "目標の長さ(秒)。0に設定するとプロンプトから自動的に推定されます。最大:6分。" + }, + "prompt": { + "name": "prompt", + "tooltip": "生成する音楽を説明するテキストプロンプト。" + }, + "seed": { + "name": "seed", + "tooltip": "再現性のためのシード値。現在Soniloサービスでは無視されますが、グラフの一貫性のために保持されています。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "SoniloのAIモデルを使って動画コンテンツから音楽を生成します。動画を解析し、マッチする音楽を作成します。", + "display_name": "Sonilo 動画から音楽生成", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "音楽生成をガイドするためのオプションのテキストプロンプト。空欄の場合は最高品質となり、モデルが動画内容を完全に解析します。" + }, + "seed": { + "name": "seed", + "tooltip": "再現性のためのシード値。現在Soniloサービスでは無視されますが、グラフの一貫性のために保持されています。" + }, + "video": { + "name": "video", + "tooltip": "音楽を生成する入力動画。最大長:6分。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "オーディオを左右のチャンネルに分離します。", "display_name": "オーディオチャンネル分割", @@ -16025,6 +16292,10 @@ "thinking": { "name": "思考モード", "tooltip": "モデルが対応している場合、思考モードで動作します。" + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "モデルに組み込まれているシステムプロンプト/テンプレートを使用します(ある場合)。" } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "思考モード", "tooltip": "モデルが対応している場合、思考モードで動作します。" + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "モデルに組み込まれているシステムプロンプト/テンプレートを使用します(ある場合)。" } }, "outputs": { diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index d69456ad05..ce9ee9be57 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -584,6 +584,8 @@ "publishButton": "ComfyHub에 게시하기", "publishFailedDescription": "워크플로우를 게시하는 중에 문제가 발생했습니다. 다시 시도해 주세요.", "publishFailedTitle": "게시 실패", + "publishSuccessDescription": "워크플로우가 이제 ComfyHub에 공개되었습니다.", + "publishSuccessTitle": "성공적으로 게시됨", "removeExampleImage": "예시 이미지 제거", "selectAThumbnail": "썸네일 선택", "shareAs": "다음으로 공유", @@ -1214,7 +1216,9 @@ "nothingToDelete": "삭제할 항목 없음", "nothingToDuplicate": "복제할 항목 없음", "nothingToRename": "이름을 변경할 항목 없음", + "off": "끄기", "ok": "확인", + "on": "켜기", "openManager": "관리자 열기", "openNewIssue": "새 문제 열기", "or": "또는", @@ -1641,7 +1645,16 @@ "exportModel": "모델 내보내기", "exportRecording": "녹화 내보내기", "exportingModel": "모델 내보내기 중...", + "fitToViewer": "뷰어에 맞추기", "fov": "FOV", + "gizmo": { + "label": "Gizmo", + "reset": "변환 초기화", + "rotate": "회전", + "scale": "크기 조절", + "toggle": "Gizmo", + "translate": "이동" + }, "hdri": { "changeFile": "HDRI 변경", "intensity": "강도", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "Stable Cascade", "string": "문자열", "style_model": "스타일 모델", + "supir": "supir", "text": "텍스트", "textgen": "textgen", "training": "학습", @@ -2495,6 +2510,8 @@ "advancedInputs": "고급 입력", "bypass": "우회", "color": "노드 색상", + "editSubgraph": "서브그래프 편집", + "editTitle": "제목 편집", "enterSubgraph": "서브그래프 진입", "errorHelp": "더 많은 도움이 필요하시면 {github} 또는 {support}를 이용하세요.", "errorHelpGithub": "GitHub 이슈 제출", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}", "failedToQueue": "대기열 추가 실패", "failedToSaveDraft": "워크플로우 초안 저장에 실패했습니다", + "failedToSetGizmoMode": "Gizmo 모드 설정에 실패했습니다", "failedToToggleCamera": "카메라 전환 실패", + "failedToToggleGizmo": "Gizmo 전환에 실패했습니다", "failedToToggleGrid": "그리드 전환 실패", "failedToUpdateBackgroundColor": "배경색 업데이트 실패", "failedToUpdateBackgroundImage": "배경 이미지 업데이트 실패", diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index f2e1920251..4017f13798 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "Seedance 2.0을 사용하여 첫 프레임 이미지와 선택적인 마지막 프레임 이미지로 비디오를 생성합니다.", + "display_name": "ByteDance Seedance 2.0 첫-마지막 프레임에서 비디오 생성", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "비디오의 첫 프레임 이미지입니다." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "비디오의 마지막 프레임 이미지입니다." + }, + "model": { + "name": "model", + "tooltip": "최고 품질을 위한 Seedance 2.0; 속도 최적화를 위한 Seedance 2.0 Fast." + }, + "model_duration": { + "name": "duration" + }, + "model_generate_audio": { + "name": "generate_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "비디오에 워터마크를 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "Seedance 2.0을 사용하여 레퍼런스 이미지, 비디오, 오디오로 비디오를 생성, 편집 또는 확장합니다. 멀티모달 레퍼런스, 비디오 편집, 비디오 확장을 지원합니다.", + "display_name": "ByteDance Seedance 2.0 레퍼런스에서 비디오 생성", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model", + "tooltip": "최고 품질을 위한 Seedance 2.0; 속도 최적화를 위한 Seedance 2.0 Fast." + }, + "model_duration": { + "name": "duration" + }, + "model_generate_audio": { + "name": "generate_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "비디오에 워터마크를 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "텍스트 프롬프트를 기반으로 Seedance 2.0 모델로 비디오를 생성합니다.", + "display_name": "ByteDance Seedance 2.0 텍스트에서 비디오 생성", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model", + "tooltip": "최고 품질을 위한 Seedance 2.0; 속도 최적화를 위한 Seedance 2.0 Fast." + }, + "model_duration": { + "name": "duration" + }, + "model_generate_audio": { + "name": "generate_audio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "비디오에 워터마크를 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "프롬프트와 첫 번째 및 마지막 프레임을 사용하여 비디오를 생성합니다.", "display_name": "ByteDance 첫-마지막-프레임에서 비디오 생성", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "여러 알고리즘을 사용하여 한 이미지의 색상을 다른 이미지와 일치시킵니다.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "색상을 맞출 기준 이미지(들)입니다. 제공되지 않으면 처리가 건너뜁니다." + }, + "image_target": { + "name": "image_target", + "tooltip": "색상 변환을 적용할 이미지(들)입니다." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: 각 프레임을 image_ref에 개별적으로 맞춥니다. uniform: 모든 소스 프레임의 통계를 풀링하여 기준선으로 사용하고, image_ref에 맞춥니다. target_frame: 선택한 한 프레임을 변환의 기준선으로 사용하여 image_ref에 일괄 적용합니다(상대적 차이 유지)." + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "후크 결합 [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "JSON에서 문자열 추출", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "제공된 모델, 긍정 및 부정 조건을 사용하여 잠재 데이터를 디노이즈합니다.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "디노이즈된 출력을 입력 latent로 끌어당깁니다. 값이 높을수록 입력에 더 충실합니다. 0은 비활성화." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "restore_cfg가 비활성화되는 시그마 임계값입니다." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "샘플링 종료 시(낮은 시그마) 강도 제어. 시작값에서 선형 보간됩니다." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "샘플링 시작 시(높은 시그마) 강도 제어." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D 조건 설정", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "Sonilo의 AI 모델을 사용하여 텍스트 프롬프트로부터 음악을 생성합니다. 지속 시간을 0으로 두면 프롬프트에서 자동으로 추론합니다.", + "display_name": "Sonilo 텍스트로 음악 생성", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "목표 지속 시간(초). 0으로 설정하면 프롬프트에서 자동으로 추론합니다. 최대: 6분." + }, + "prompt": { + "name": "prompt", + "tooltip": "생성할 음악을 설명하는 텍스트 프롬프트입니다." + }, + "seed": { + "name": "seed", + "tooltip": "재현성을 위한 시드 값입니다. 현재 Sonilo 서비스에서는 무시되지만 그래프 일관성을 위해 유지됩니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "Sonilo의 AI 모델을 사용하여 비디오 콘텐츠로부터 음악을 생성합니다. 비디오를 분석하여 어울리는 음악을 만듭니다.", + "display_name": "Sonilo 비디오로 음악 생성", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "음악 생성에 참고할 선택적 텍스트 프롬프트입니다. 비워두면 최상의 품질로 모델이 비디오 내용을 완전히 분석합니다." + }, + "seed": { + "name": "seed", + "tooltip": "재현성을 위한 시드 값입니다. 현재 Sonilo 서비스에서는 무시되지만 그래프 일관성을 위해 유지됩니다." + }, + "video": { + "name": "video", + "tooltip": "음악을 생성할 입력 비디오입니다. 최대 지속 시간: 6분." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "오디오를 좌우 채널로 분리합니다.", "display_name": "오디오 채널 분리", @@ -16025,6 +16292,10 @@ "thinking": { "name": "생각 중", "tooltip": "모델이 지원하는 경우 생각 모드로 작동합니다." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "모델에 내장된 시스템 프롬프트/템플릿이 있으면 사용합니다." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "생각 중", "tooltip": "모델이 지원하는 경우 생각 모드로 작동합니다." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "모델에 내장된 시스템 프롬프트/템플릿이 있으면 사용합니다." } }, "outputs": { diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 7f0f75be17..8003f232ec 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -584,6 +584,8 @@ "publishButton": "Publicar no ComfyHub", "publishFailedDescription": "Algo deu errado ao publicar seu fluxo de trabalho. Por favor, tente novamente.", "publishFailedTitle": "Falha ao publicar", + "publishSuccessDescription": "Seu fluxo de trabalho está agora disponível no ComfyHub.", + "publishSuccessTitle": "Publicado com sucesso", "removeExampleImage": "Remover imagem de exemplo", "selectAThumbnail": "Selecione uma miniatura", "shareAs": "Compartilhar como", @@ -1214,7 +1216,9 @@ "nothingToDelete": "Nada para excluir", "nothingToDuplicate": "Nada para duplicar", "nothingToRename": "Nada para renomear", + "off": "Desligado", "ok": "OK", + "on": "Ligado", "openManager": "Abrir gerenciador", "openNewIssue": "Abrir novo problema", "or": "ou", @@ -1641,7 +1645,16 @@ "exportModel": "Exportar Modelo", "exportRecording": "Exportar Gravação", "exportingModel": "Exportando modelo...", + "fitToViewer": "Ajustar ao Visualizador", "fov": "Campo de Visão (FOV)", + "gizmo": { + "label": "Gizmo", + "reset": "Redefinir Transformação", + "rotate": "Rotacionar", + "scale": "Escalar", + "toggle": "Alternar Gizmo", + "translate": "Mover" + }, "hdri": { "changeFile": "Alterar HDRI", "intensity": "Intensidade", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "stable_cascade", "string": "string", "style_model": "modelo_de_estilo", + "supir": "supir", "text": "texto", "textgen": "textgen", "training": "treinamento", @@ -2495,6 +2510,8 @@ "advancedInputs": "ENTRADAS AVANÇADAS", "bypass": "Ignorar", "color": "Cor do nó", + "editSubgraph": "Editar subgrafo", + "editTitle": "Editar título", "enterSubgraph": "Entrar no subgrafo", "errorHelp": "Para mais ajuda, {github} ou {support}", "errorHelpGithub": "enviar um issue no GitHub", @@ -3457,7 +3474,9 @@ "failedToPurchaseCredits": "Falha ao comprar créditos: {error}", "failedToQueue": "Falha ao enfileirar", "failedToSaveDraft": "Falha ao salvar o rascunho do fluxo de trabalho", + "failedToSetGizmoMode": "Falha ao definir o modo do gizmo", "failedToToggleCamera": "Falha ao alternar câmera", + "failedToToggleGizmo": "Falha ao alternar o gizmo", "failedToToggleGrid": "Falha ao alternar grade", "failedToUpdateBackgroundColor": "Falha ao atualizar cor de fundo", "failedToUpdateBackgroundImage": "Falha ao atualizar imagem de fundo", diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 90e48dfc45..ab50d8380b 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "Gere vídeo usando Seedance 2.0 a partir de uma imagem do primeiro frame e, opcionalmente, uma imagem do último frame.", + "display_name": "ByteDance Seedance 2.0 Primeiro-Último-Frame para Vídeo", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "first_frame": { + "name": "primeiro_frame", + "tooltip": "Imagem do primeiro frame para o vídeo." + }, + "last_frame": { + "name": "último_frame", + "tooltip": "Imagem do último frame para o vídeo." + }, + "model": { + "name": "modelo", + "tooltip": "Seedance 2.0 para máxima qualidade; Seedance 2.0 Fast para otimização de velocidade." + }, + "model_duration": { + "name": "duração" + }, + "model_generate_audio": { + "name": "gerar_áudio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "proporção" + }, + "model_resolution": { + "name": "resolução" + }, + "seed": { + "name": "semente", + "tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente." + }, + "watermark": { + "name": "marca_d'água", + "tooltip": "Se deve adicionar uma marca d'água ao vídeo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "Gere, edite ou estenda vídeos usando Seedance 2.0 com imagens, vídeos e áudios de referência. Suporta referência multimodal, edição de vídeo e extensão de vídeo.", + "display_name": "ByteDance Seedance 2.0 Referência para Vídeo", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "model": { + "name": "modelo", + "tooltip": "Seedance 2.0 para máxima qualidade; Seedance 2.0 Fast para otimização de velocidade." + }, + "model_duration": { + "name": "duração" + }, + "model_generate_audio": { + "name": "gerar_áudio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "proporção" + }, + "model_resolution": { + "name": "resolução" + }, + "seed": { + "name": "semente", + "tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente." + }, + "watermark": { + "name": "marca_d'água", + "tooltip": "Se deve adicionar uma marca d'água ao vídeo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "Gere vídeo usando modelos Seedance 2.0 a partir de um prompt de texto.", + "display_name": "ByteDance Seedance 2.0 Texto para Vídeo", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "model": { + "name": "modelo", + "tooltip": "Seedance 2.0 para máxima qualidade; Seedance 2.0 Fast para otimização de velocidade." + }, + "model_duration": { + "name": "duração" + }, + "model_generate_audio": { + "name": "gerar_áudio" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "proporção" + }, + "model_resolution": { + "name": "resolução" + }, + "seed": { + "name": "semente", + "tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente." + }, + "watermark": { + "name": "marca_d'água", + "tooltip": "Se deve adicionar uma marca d'água ao vídeo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Gere vídeo usando um prompt e os quadros inicial e final.", "display_name": "ByteDance Primeiro-Último-Frame para Vídeo", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "Combine as cores de uma imagem com outra usando vários algoritmos.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "Imagem(ns) de referência para combinar as cores. Se não fornecido, o processamento é ignorado." + }, + "image_target": { + "name": "image_target", + "tooltip": "Imagem(ns) para aplicar a transformação de cor." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: cada quadro é ajustado individualmente à image_ref. uniform: agrupa estatísticas de todos os quadros de origem como base, ajusta para image_ref. target_frame: usa um quadro escolhido como base para a transformação para image_ref, aplicado uniformemente a todos os quadros (preserva diferenças relativas)" + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Combinar Hooks [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "Extrair String do JSON", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "Usa o modelo fornecido, condicionamento positivo e negativo para remover o ruído da imagem latente.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "Puxa a saída denoised em direção ao latent de entrada. Quanto maior, maior a fidelidade ao input. 0 para desabilitar." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "Limite de sigma abaixo do qual restore_cfg é desabilitado." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "Controla a intensidade no final da amostragem (sigma baixo). Interpolado linearmente a partir do início." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "Controla a intensidade no início da amostragem (sigma alto)." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D_Conditioning", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "Gere música a partir de um prompt de texto usando o modelo de IA do Sonilo. Deixe a duração em 0 para que o modelo infira a duração a partir do prompt.", + "display_name": "Sonilo Texto para Música", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "Duração alvo em segundos. Defina como 0 para que o modelo infira a duração a partir do prompt. Máximo: 6 minutos." + }, + "prompt": { + "name": "prompt", + "tooltip": "Prompt de texto descrevendo a música a ser gerada." + }, + "seed": { + "name": "seed", + "tooltip": "Seed para reprodutibilidade. Atualmente ignorado pelo serviço Sonilo, mas mantido para consistência do grafo." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "Gere música a partir de conteúdo de vídeo usando o modelo de IA do Sonilo. Analisa o vídeo e cria música correspondente.", + "display_name": "Sonilo Vídeo para Música", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "Prompt de texto opcional para guiar a geração da música. Deixe em branco para melhor qualidade - o modelo irá analisar completamente o conteúdo do vídeo." + }, + "seed": { + "name": "seed", + "tooltip": "Seed para reprodutibilidade. Atualmente ignorado pelo serviço Sonilo, mas mantido para consistência do grafo." + }, + "video": { + "name": "video", + "tooltip": "Vídeo de entrada para gerar música. Duração máxima: 6 minutos." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "Separa o áudio em canais esquerdo e direito.", "display_name": "Dividir Canais de Áudio", @@ -16025,6 +16292,10 @@ "thinking": { "name": "pensando", "tooltip": "Operar no modo de pensamento se o modelo suportar." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Use o prompt/modelo de sistema padrão se o modelo possuir um." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "pensando", "tooltip": "Operar no modo de pensamento se o modelo suportar." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Use o prompt/modelo de sistema padrão se o modelo possuir um." } }, "outputs": { diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 750898e8f5..9d02e3a6f3 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -584,6 +584,8 @@ "publishButton": "Опубликовать в ComfyHub", "publishFailedDescription": "Произошла ошибка при публикации вашего рабочего процесса. Пожалуйста, попробуйте еще раз.", "publishFailedTitle": "Ошибка публикации", + "publishSuccessDescription": "Ваш рабочий процесс теперь доступен на ComfyHub.", + "publishSuccessTitle": "Успешно опубликовано", "removeExampleImage": "Удалить пример изображения", "selectAThumbnail": "Выберите миниатюру", "shareAs": "Поделиться как", @@ -1214,7 +1216,9 @@ "nothingToDelete": "Нечего удалять", "nothingToDuplicate": "Нечего дублировать", "nothingToRename": "Нечего переименовывать", + "off": "Выкл", "ok": "ОК", + "on": "Вкл", "openManager": "Открыть менеджер", "openNewIssue": "Открыть новую проблему", "or": "или", @@ -1641,7 +1645,16 @@ "exportModel": "Экспорт модели", "exportRecording": "Экспортировать запись", "exportingModel": "Экспорт модели...", + "fitToViewer": "Подогнать к просмотру", "fov": "Угол обзора", + "gizmo": { + "label": "Гизмо", + "reset": "Сбросить трансформацию", + "rotate": "Повернуть", + "scale": "Масштабировать", + "toggle": "Гизмо", + "translate": "Переместить" + }, "hdri": { "changeFile": "Сменить HDRI", "intensity": "Интенсивность", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "стабильная_каскадная", "string": "строка", "style_model": "модель_стиля", + "supir": "supir", "text": "текст", "textgen": "textgen", "training": "обучение", @@ -2495,6 +2510,8 @@ "advancedInputs": "РАСШИРЕННЫЕ ВХОДНЫЕ ДАННЫЕ", "bypass": "Обход", "color": "Цвет узла", + "editSubgraph": "Редактировать подграф", + "editTitle": "Редактировать название", "enterSubgraph": "Войти в подграф", "errorHelp": "Для получения дополнительной помощи {github} или {support}", "errorHelpGithub": "создайте issue на GitHub", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "Не удалось купить кредиты: {error}", "failedToQueue": "Не удалось поставить в очередь", "failedToSaveDraft": "Не удалось сохранить черновик рабочего процесса", + "failedToSetGizmoMode": "Не удалось установить режим гизмо", "failedToToggleCamera": "Не удалось переключить камеру", + "failedToToggleGizmo": "Не удалось переключить гизмо", "failedToToggleGrid": "Не удалось переключить сетку", "failedToUpdateBackgroundColor": "Не удалось обновить цвет фона", "failedToUpdateBackgroundImage": "Не удалось обновить фоновое изображение", diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index ed14451dfe..ecb48fd265 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "Генерируйте видео с помощью Seedance 2.0, используя изображение первого кадра и, при необходимости, последнего кадра.", + "display_name": "ByteDance Seedance 2.0: от первого и последнего кадра к видео", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "first_frame": { + "name": "первый кадр", + "tooltip": "Изображение первого кадра для видео." + }, + "last_frame": { + "name": "последний кадр", + "tooltip": "Изображение последнего кадра для видео." + }, + "model": { + "name": "модель", + "tooltip": "Seedance 2.0 для максимального качества; Seedance 2.0 Fast для оптимизации скорости." + }, + "model_duration": { + "name": "длительность" + }, + "model_generate_audio": { + "name": "генерировать аудио" + }, + "model_prompt": { + "name": "промпт" + }, + "model_ratio": { + "name": "соотношение сторон" + }, + "model_resolution": { + "name": "разрешение" + }, + "seed": { + "name": "seed", + "tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed." + }, + "watermark": { + "name": "водяной знак", + "tooltip": "Добавлять ли водяной знак к видео." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "Генерируйте, редактируйте или расширяйте видео с помощью Seedance 2.0, используя референсные изображения, видео и аудио. Поддерживает мультимодальные референсы, видеомонтаж и расширение видео.", + "display_name": "ByteDance Seedance 2.0: референс в видео", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "модель", + "tooltip": "Seedance 2.0 для максимального качества; Seedance 2.0 Fast для оптимизации скорости." + }, + "model_duration": { + "name": "длительность" + }, + "model_generate_audio": { + "name": "генерировать аудио" + }, + "model_prompt": { + "name": "промпт" + }, + "model_ratio": { + "name": "соотношение сторон" + }, + "model_resolution": { + "name": "разрешение" + }, + "seed": { + "name": "seed", + "tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed." + }, + "watermark": { + "name": "водяной знак", + "tooltip": "Добавлять ли водяной знак к видео." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "Генерируйте видео с помощью моделей Seedance 2.0 на основе текстового промпта.", + "display_name": "ByteDance Seedance 2.0: текст в видео", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "модель", + "tooltip": "Seedance 2.0 для максимального качества; Seedance 2.0 Fast для оптимизации скорости." + }, + "model_duration": { + "name": "длительность" + }, + "model_generate_audio": { + "name": "генерировать аудио" + }, + "model_prompt": { + "name": "промпт" + }, + "model_ratio": { + "name": "соотношение сторон" + }, + "model_resolution": { + "name": "разрешение" + }, + "seed": { + "name": "seed", + "tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed." + }, + "watermark": { + "name": "водяной знак", + "tooltip": "Добавлять ли водяной знак к видео." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "Создать видео с использованием промпта и первого и последнего кадров.", "display_name": "ByteDance - Преобразование первого-последнего кадра в видео", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "Сопоставьте цвета одного изображения с другим с помощью различных алгоритмов.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "Референсное изображение(я), с которым нужно сопоставить цвета. Если не указано, обработка пропускается" + }, + "image_target": { + "name": "image_target", + "tooltip": "Изображение(я), к которым будет применено преобразование цвета." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: каждый кадр сопоставляется с image_ref отдельно. uniform: статистика всех исходных кадров объединяется как базовая, сопоставляется с image_ref. target_frame: выбранный кадр используется как базовая линия для преобразования к image_ref, применяется ко всем кадрам (сохраняет относительные различия)" + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Объединить хуки [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "Извлечь строку из JSON", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "Использует предоставленную модель, положительное и отрицательное кондиционирование для удаления шума из латентного изображения.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "Смещает денойз-выход к входному latent. Чем выше значение, тем сильнее сохраняется сходство с входом. 0 — отключить." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "Порог sigma, ниже которого restore_cfg отключается." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "Контроль силы на финальном этапе сэмплирования (низкое sigma). Линейная интерполяция от начального значения." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "Контроль силы на начальном этапе сэмплирования (высокое sigma)." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D_Кондиционирование", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "Генерируйте музыку по текстовому запросу с помощью AI-модели Sonilo. Оставьте длительность 0, чтобы модель определила её из запроса.", + "display_name": "Sonilo: Текст в музыку", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "Желаемая длительность в секундах. Установите 0, чтобы модель определила длительность из запроса. Максимум: 6 минут." + }, + "prompt": { + "name": "prompt", + "tooltip": "Текстовый запрос, описывающий музыку для генерации." + }, + "seed": { + "name": "seed", + "tooltip": "Seed для воспроизводимости. В настоящее время игнорируется сервисом Sonilo, но сохраняется для согласованности графа." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "Генерируйте музыку из видеоконтента с помощью AI-модели Sonilo. Анализирует видео и создает подходящую музыку.", + "display_name": "Sonilo: Видео в музыку", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "Необязательный текстовый запрос для направления генерации музыки. Оставьте пустым для наилучшего качества — модель полностью проанализирует содержимое видео." + }, + "seed": { + "name": "seed", + "tooltip": "Seed для воспроизводимости. В настоящее время игнорируется сервисом Sonilo, но сохраняется для согласованности графа." + }, + "video": { + "name": "video", + "tooltip": "Входное видео для генерации музыки. Максимальная длительность: 6 минут." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "Разделяет аудио на левый и правый каналы.", "display_name": "Разделить аудиоканалы", @@ -16025,6 +16292,10 @@ "thinking": { "name": "мышление", "tooltip": "Работать в режиме мышления, если модель это поддерживает." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Использовать встроенный системный шаблон/подсказку, если он есть у модели." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "мышление", "tooltip": "Работать в режиме мышления, если модель это поддерживает." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Использовать встроенный системный шаблон/подсказку, если он есть у модели." } }, "outputs": { diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index f8f70857af..6083d37611 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -584,6 +584,8 @@ "publishButton": "ComfyHub'da Yayınla", "publishFailedDescription": "Çalışma akışınızı yayınlarken bir hata oluştu. Lütfen tekrar deneyin.", "publishFailedTitle": "Yayınlama başarısız", + "publishSuccessDescription": "Çalışma akışınız artık ComfyHub'da yayında.", + "publishSuccessTitle": "Başarıyla yayınlandı", "removeExampleImage": "Örnek görseli kaldır", "selectAThumbnail": "Bir küçük resim seçin", "shareAs": "Olarak paylaş", @@ -1214,7 +1216,9 @@ "nothingToDelete": "Silinecek bir şey yok", "nothingToDuplicate": "Çoğaltılacak bir şey yok", "nothingToRename": "Yeniden adlandırılacak bir şey yok", + "off": "Kapalı", "ok": "Tamam", + "on": "Açık", "openManager": "Yöneticiyi Aç", "openNewIssue": "Yeni Sorun Aç", "or": "veya", @@ -1641,7 +1645,16 @@ "exportModel": "Modeli Dışa Aktar", "exportRecording": "Kaydı Dışa Aktar", "exportingModel": "Model dışa aktarılıyor...", + "fitToViewer": "Görüntüleyiciye Sığdır", "fov": "FOV", + "gizmo": { + "label": "Gizmo", + "reset": "Dönüşümü Sıfırla", + "rotate": "Döndür", + "scale": "Ölçekle", + "toggle": "Gizmo", + "translate": "Taşı" + }, "hdri": { "changeFile": "HDRI Değiştir", "intensity": "Yoğunluk", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "stabil_çağlayan", "string": "dize", "style_model": "stil_modeli", + "supir": "supir", "text": "metin", "textgen": "textgen", "training": "eğitim", @@ -2495,6 +2510,8 @@ "advancedInputs": "GELİŞMİŞ GİRDİLER", "bypass": "Atla", "color": "Düğüm rengi", + "editSubgraph": "Alt grafiği düzenle", + "editTitle": "Başlığı düzenle", "enterSubgraph": "Alt grafiğe gir", "errorHelp": "Daha fazla yardım için {github} veya {support}", "errorHelpGithub": "bir GitHub sorunu gönderin", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "Kredi satın alınamadı: {error}", "failedToQueue": "Kuyruğa alınamadı", "failedToSaveDraft": "Çalışma taslağı kaydedilemedi", + "failedToSetGizmoMode": "Gizmo modu ayarlanamadı", "failedToToggleCamera": "Kamera açılıp kapatılamadı", + "failedToToggleGizmo": "Gizmo geçişi başarısız oldu", "failedToToggleGrid": "Izgara açılıp kapatılamadı", "failedToUpdateBackgroundColor": "Arka plan rengi güncellenemedi", "failedToUpdateBackgroundImage": "Arka plan görseli güncellenemedi", diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 01b1f5efe5..00edb84eb5 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "Seedance 2.0 kullanarak bir ilk kare görseli ve isteğe bağlı son kare görseli ile video oluşturun.", + "display_name": "ByteDance Seedance 2.0 İlk-Son-Kareden Videoya", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol et" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "Video için ilk kare görseli." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Video için son kare görseli." + }, + "model": { + "name": "model", + "tooltip": "Maksimum kalite için Seedance 2.0; hız optimizasyonu için Seedance 2.0 Fast." + }, + "model_duration": { + "name": "süre" + }, + "model_generate_audio": { + "name": "ses oluştur" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "seed", + "tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir." + }, + "watermark": { + "name": "watermark", + "tooltip": "Videoya filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "Seedance 2.0 ile referans görseller, videolar ve sesler kullanarak video oluşturun, düzenleyin veya uzatın. Çok modlu referans, video düzenleme ve video uzatma desteklenir.", + "display_name": "ByteDance Seedance 2.0 Referanstan Videoya", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol et" + }, + "model": { + "name": "model", + "tooltip": "Maksimum kalite için Seedance 2.0; hız optimizasyonu için Seedance 2.0 Fast." + }, + "model_duration": { + "name": "süre" + }, + "model_generate_audio": { + "name": "ses oluştur" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "seed", + "tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir." + }, + "watermark": { + "name": "watermark", + "tooltip": "Videoya filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "Seedance 2.0 modelleriyle metin istemine dayalı video oluşturun.", + "display_name": "ByteDance Seedance 2.0 Metinden Videoya", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol et" + }, + "model": { + "name": "model", + "tooltip": "Maksimum kalite için Seedance 2.0; hız optimizasyonu için Seedance 2.0 Fast." + }, + "model_duration": { + "name": "süre" + }, + "model_generate_audio": { + "name": "ses oluştur" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "seed", + "tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir." + }, + "watermark": { + "name": "watermark", + "tooltip": "Videoya filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "İlk ve son kareleri kullanarak video oluşturun.", "display_name": "ByteDance İlk-Son-Kare'den Videoya", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "Bir görüntünün renklerini çeşitli algoritmalar kullanarak başka bir görüntüye eşleştirir.", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "Renklerin eşleştirileceği referans görüntü(ler). Sağlanmazsa işleme atlanır." + }, + "image_target": { + "name": "image_target", + "tooltip": "Renk dönüşümünün uygulanacağı görüntü(ler)." + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame: Her kare ayrı ayrı image_ref ile eşleştirilir. uniform: Tüm kaynak karelerin istatistikleri havuzlanır ve temel olarak alınır, image_ref ile eşleştirilir. target_frame: Seçilen bir kare dönüşüm için temel alınır ve tüm karelere eşit şekilde uygulanır (göreli farklılıkları korur)." + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "Kancaları Birleştir [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "JSON'dan Dize Çıkar", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "Gizli görüntünün gürültüsünü azaltmak için sağlanan modeli, pozitif ve negatif koşullandırmayı kullanır.", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "Gürültüsüz çıktıyı giriş latent'ine yaklaştırır. Yüksek değer = girdiye daha yüksek sadakat. 0 devre dışı bırakır." + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "restore_cfg'nin devre dışı bırakıldığı sigma eşiği." + }, + "strength_end": { + "name": "strength_end", + "tooltip": "Örnekleme sonunda (düşük sigma) kontrol gücü. Başlangıçtan sona doğrusal olarak enterpole edilir." + }, + "strength_start": { + "name": "strength_start", + "tooltip": "Örnekleme başlangıcında (yüksek sigma) kontrol gücü." + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D_Koşullandırma", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "Sonilo'nun yapay zeka modeliyle bir metin isteminden müzik oluşturun. Süreyi 0 olarak bırakın, model süreyi istemden çıkarsın.", + "display_name": "Sonilo Metinden Müzik Üret", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "Hedef süre (saniye cinsinden). 0 olarak ayarlayın, model süreyi istemden çıkarsın. Maksimum: 6 dakika." + }, + "prompt": { + "name": "prompt", + "tooltip": "Oluşturulacak müziği tanımlayan metin istemi." + }, + "seed": { + "name": "seed", + "tooltip": "Tekrarlanabilirlik için tohum değeri. Şu anda Sonilo servisi tarafından dikkate alınmıyor, ancak grafik tutarlılığı için korunuyor." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "Sonilo'nun yapay zeka modeliyle video içeriğinden müzik oluşturun. Videoyu analiz eder ve uyumlu müzik üretir.", + "display_name": "Sonilo Videodan Müzik Üret", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "Müzik üretimini yönlendirmek için isteğe bağlı metin istemi. En iyi kalite için boş bırakın - model video içeriğini tamamen analiz edecektir." + }, + "seed": { + "name": "seed", + "tooltip": "Tekrarlanabilirlik için tohum değeri. Şu anda Sonilo servisi tarafından dikkate alınmıyor, ancak grafik tutarlılığı için korunuyor." + }, + "video": { + "name": "video", + "tooltip": "Müzik üretilecek giriş videosu. Maksimum süre: 6 dakika." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "Sesi sol ve sağ kanallara ayırır.", "display_name": "Ses Kanallarını Ayır", @@ -16025,6 +16292,10 @@ "thinking": { "name": "düşünme", "tooltip": "Model destekliyorsa düşünme modunda çalış." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Modelde yerleşik bir sistem istemi/şablonu varsa bunu kullan." } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "düşünme", "tooltip": "Model destekliyorsa düşünme modunda çalış." + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "Modelde yerleşik bir sistem istemi/şablonu varsa bunu kullan." } }, "outputs": { diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 8755f6c83a..3e1d49b59d 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -584,6 +584,8 @@ "publishButton": "發佈到 ComfyHub", "publishFailedDescription": "發佈您的工作流程時發生錯誤。請再試一次。", "publishFailedTitle": "發佈失敗", + "publishSuccessDescription": "您的工作流程已在 ComfyHub 上上線。", + "publishSuccessTitle": "發佈成功", "removeExampleImage": "移除範例圖片", "selectAThumbnail": "選擇縮圖", "shareAs": "分享為", @@ -1214,7 +1216,9 @@ "nothingToDelete": "沒有可刪除的項目", "nothingToDuplicate": "沒有可複製的項目", "nothingToRename": "沒有可重新命名的項目", + "off": "關閉", "ok": "確定", + "on": "開啟", "openManager": "開啟管理器", "openNewIssue": "開啟新問題", "or": "或", @@ -1641,7 +1645,16 @@ "exportModel": "匯出模型", "exportRecording": "匯出錄影", "exportingModel": "正在匯出模型...", + "fitToViewer": "適合檢視器", "fov": "視野角度", + "gizmo": { + "label": "控制器", + "reset": "重設變換", + "rotate": "旋轉", + "scale": "縮放", + "toggle": "切換控制器", + "translate": "平移" + }, "hdri": { "changeFile": "更換 HDRI", "intensity": "強度", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "羅丹", "Runway": "跑道", + "Sonilo": "Sonilo", "Sora": "蒼穹", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "stable_cascade", "string": "字串", "style_model": "風格模型", + "supir": "supir", "text": "文字", "textgen": "文字生成", "training": "訓練", @@ -2495,6 +2510,8 @@ "advancedInputs": "進階輸入", "bypass": "繞過", "color": "節點顏色", + "editSubgraph": "編輯子圖", + "editTitle": "編輯標題", "enterSubgraph": "進入子圖", "errorHelp": "如需更多協助,請{github}或{support}", "errorHelpGithub": "提交 GitHub 問題", @@ -3445,7 +3462,9 @@ "failedToPurchaseCredits": "購買點數失敗:{error}", "failedToQueue": "加入佇列失敗", "failedToSaveDraft": "無法儲存工作流程草稿", + "failedToSetGizmoMode": "設定控制器模式失敗", "failedToToggleCamera": "切換相機失敗", + "failedToToggleGizmo": "切換控制器失敗", "failedToToggleGrid": "切換格線失敗", "failedToUpdateBackgroundColor": "更新背景顏色失敗", "failedToUpdateBackgroundImage": "更新背景圖像失敗", diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index b93cfb6ae8..ed9366a023 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "使用 Seedance 2.0,從首幀圖像與可選的末幀圖像產生影片。", + "display_name": "ByteDance Seedance 2.0 首末幀轉影片", + "inputs": { + "control_after_generate": { + "name": "產生後控制" + }, + "first_frame": { + "name": "首幀圖像", + "tooltip": "影片的首幀圖像。" + }, + "last_frame": { + "name": "末幀圖像", + "tooltip": "影片的末幀圖像。" + }, + "model": { + "name": "模型", + "tooltip": "Seedance 2.0 提供最佳品質;Seedance 2.0 Fast 提供速度優化。" + }, + "model_duration": { + "name": "時長" + }, + "model_generate_audio": { + "name": "產生音訊" + }, + "model_prompt": { + "name": "提示詞" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "解析度" + }, + "seed": { + "name": "種子", + "tooltip": "種子決定此節點是否重新執行;無論種子為何,結果皆為非決定性。" + }, + "watermark": { + "name": "浮水印", + "tooltip": "是否在影片中加入浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "使用 Seedance 2.0,透過參考圖像、影片與音訊產生、編輯或延伸影片。支援多模態參考、影片編輯與影片延伸。", + "display_name": "ByteDance Seedance 2.0 參考轉影片", + "inputs": { + "control_after_generate": { + "name": "產生後控制" + }, + "model": { + "name": "模型", + "tooltip": "Seedance 2.0 提供最佳品質;Seedance 2.0 Fast 提供速度優化。" + }, + "model_duration": { + "name": "時長" + }, + "model_generate_audio": { + "name": "產生音訊" + }, + "model_prompt": { + "name": "提示詞" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "解析度" + }, + "seed": { + "name": "種子", + "tooltip": "種子決定此節點是否重新執行;無論種子為何,結果皆為非決定性。" + }, + "watermark": { + "name": "浮水印", + "tooltip": "是否在影片中加入浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "根據文字提示,使用 Seedance 2.0 模型產生影片。", + "display_name": "ByteDance Seedance 2.0 文字轉影片", + "inputs": { + "control_after_generate": { + "name": "產生後控制" + }, + "model": { + "name": "模型", + "tooltip": "Seedance 2.0 提供最佳品質;Seedance 2.0 Fast 提供速度優化。" + }, + "model_duration": { + "name": "時長" + }, + "model_generate_audio": { + "name": "產生音訊" + }, + "model_prompt": { + "name": "提示詞" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "解析度" + }, + "seed": { + "name": "種子", + "tooltip": "種子決定此節點是否重新執行;無論種子為何,結果皆為非決定性。" + }, + "watermark": { + "name": "浮水印", + "tooltip": "是否在影片中加入浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "使用提示詞和首尾幀生成影片。", "display_name": "字節跳動首尾幀轉影片", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "使用各種演算法將一張圖片的色彩匹配到另一張圖片。", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "要匹配色彩的參考圖片。如果未提供,將跳過處理。" + }, + "image_target": { + "name": "image_target", + "tooltip": "要套用色彩轉換的圖片。" + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame:每一幀分別與 image_ref 匹配。uniform:將所有來源幀的統計數據合併作為基準,並與 image_ref 匹配。target_frame:選擇一幀作為轉換到 image_ref 的基準,並均勻套用到所有幀(保留相對差異)" + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "合併 Hooks [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "從 JSON 擷取字串", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "使用提供的模型,以及正向與負向條件來去除潛在空間影像中的雜訊。", "display_name": "KSampler", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "將去噪後的輸出拉回輸入 latent。數值越高,越忠於輸入。設為 0 則停用。" + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "低於此 sigma 閾值時,restore_cfg 會停用。" + }, + "strength_end": { + "name": "strength_end", + "tooltip": "控制取樣結束時(低 sigma)的強度。從開始值線性插值。" + }, + "strength_start": { + "name": "strength_start", + "tooltip": "控制取樣開始時(高 sigma)的強度。" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D_Conditioning", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "使用 Sonilo 的 AI 模型,根據文字提示產生音樂。將時長設為 0 可讓模型自動從提示中推斷時長。", + "display_name": "Sonilo 文字轉音樂", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "目標時長(秒)。設為 0 讓模型自動從提示推斷時長。最長 6 分鐘。" + }, + "prompt": { + "name": "prompt", + "tooltip": "描述要產生音樂的文字提示。" + }, + "seed": { + "name": "seed", + "tooltip": "用於重現性的種子。目前 Sonilo 服務會忽略此值,但為了流程一致性保留。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "使用 Sonilo 的 AI 模型,從影片內容產生音樂。分析影片並創作相符的音樂。", + "display_name": "Sonilo 影片轉音樂", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "可選的文字提示,用於引導音樂生成。留空可獲得最佳品質——模型將完整分析影片內容。" + }, + "seed": { + "name": "seed", + "tooltip": "用於重現性的種子。目前 Sonilo 服務會忽略此值,但為了流程一致性保留。" + }, + "video": { + "name": "video", + "tooltip": "輸入要產生音樂的影片。最長 6 分鐘。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "將音訊分離為左右聲道。", "display_name": "分離音訊聲道", @@ -16025,6 +16292,10 @@ "thinking": { "name": "思考模式", "tooltip": "若模型支援,則以思考模式運作。" + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "若模型有內建系統提示/模板則使用。" } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "思考模式", "tooltip": "若模型支援,則以思考模式運作。" + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "若模型有內建系統提示/模板則使用。" } }, "outputs": { diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index b7dc2c7d85..ae191cb2a6 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -584,6 +584,8 @@ "publishButton": "发布到 ComfyHub", "publishFailedDescription": "发布您的工作流时出现问题。请重试。", "publishFailedTitle": "发布失败", + "publishSuccessDescription": "您的工作流已在 ComfyHub 上上线。", + "publishSuccessTitle": "发布成功", "removeExampleImage": "移除示例图像", "selectAThumbnail": "选择缩略图", "shareAs": "分享为", @@ -1214,7 +1216,9 @@ "nothingToDelete": "没有可以删除的内容", "nothingToDuplicate": "Nothing to duplicate", "nothingToRename": "没有可以重命名的内容", + "off": "关", "ok": "确定", + "on": "开", "openManager": "打开管理器", "openNewIssue": "打开新问题", "or": "或", @@ -1641,7 +1645,16 @@ "exportModel": "导出模型", "exportRecording": "导出录制", "exportingModel": "正在导出模型...", + "fitToViewer": "适应视图", "fov": "视场", + "gizmo": { + "label": "控件", + "reset": "重置变换", + "rotate": "旋转", + "scale": "缩放", + "toggle": "控件", + "translate": "移动" + }, "hdri": { "changeFile": "更换HDRI", "intensity": "强度", @@ -2236,6 +2249,7 @@ "Reve": "Reve", "Rodin": "Rodin", "Runway": "Runway", + "Sonilo": "Sonilo", "Sora": "Sora", "Stability AI": "Stability AI", "Tencent": "Tencent", @@ -2309,6 +2323,7 @@ "stable_cascade": "StableCascade", "string": "字符串", "style_model": "风格模型", + "supir": "supir", "text": "文本", "textgen": "textgen", "training": "训练", @@ -2495,6 +2510,8 @@ "advancedInputs": "高级输入", "bypass": "忽略", "color": "节点颜色", + "editSubgraph": "编辑子图", + "editTitle": "编辑标题", "enterSubgraph": "进入子图", "errorHelp": "如需更多帮助,请{github}或{support}", "errorHelpGithub": "提交 GitHub 问题", @@ -3457,7 +3474,9 @@ "failedToPurchaseCredits": "购买积分失败:{error}", "failedToQueue": "排队失败", "failedToSaveDraft": "保存工作流草稿失败", + "failedToSetGizmoMode": "设置控件模式失败", "failedToToggleCamera": "切换镜头失败", + "failedToToggleGizmo": "切换控件失败", "failedToToggleGrid": "切换网格失败", "failedToUpdateBackgroundColor": "更新背景色失败", "failedToUpdateBackgroundImage": "更新背景图像失败", diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index f29ed73a12..059e39cd65 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -472,6 +472,137 @@ } } }, + "ByteDance2FirstLastFrameNode": { + "description": "使用 Seedance 2.0 从首帧图像和可选的末帧图像生成视频。", + "display_name": "ByteDance Seedance 2.0 首帧-末帧生成视频", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "first_frame": { + "name": "首帧图像", + "tooltip": "视频的首帧图像。" + }, + "last_frame": { + "name": "末帧图像", + "tooltip": "视频的末帧图像。" + }, + "model": { + "name": "模型", + "tooltip": "Seedance 2.0 提供最高质量;Seedance 2.0 Fast 优化速度。" + }, + "model_duration": { + "name": "时长" + }, + "model_generate_audio": { + "name": "生成音频" + }, + "model_prompt": { + "name": "提示词" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "分辨率" + }, + "seed": { + "name": "种子", + "tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。" + }, + "watermark": { + "name": "水印", + "tooltip": "是否为视频添加水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2ReferenceNode": { + "description": "使用 Seedance 2.0 结合参考图像、视频和音频生成、编辑或扩展视频。支持多模态参考、视频编辑和视频扩展。", + "display_name": "ByteDance Seedance 2.0 参考生成视频", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "模型", + "tooltip": "Seedance 2.0 提供最高质量;Seedance 2.0 Fast 优化速度。" + }, + "model_duration": { + "name": "时长" + }, + "model_generate_audio": { + "name": "生成音频" + }, + "model_prompt": { + "name": "提示词" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "分辨率" + }, + "seed": { + "name": "种子", + "tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。" + }, + "watermark": { + "name": "水印", + "tooltip": "是否为视频添加水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "ByteDance2TextToVideoNode": { + "description": "基于文本提示词,使用 Seedance 2.0 模型生成视频。", + "display_name": "ByteDance Seedance 2.0 文本生成视频", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "模型", + "tooltip": "Seedance 2.0 提供最高质量;Seedance 2.0 Fast 优化速度。" + }, + "model_duration": { + "name": "时长" + }, + "model_generate_audio": { + "name": "生成音频" + }, + "model_prompt": { + "name": "提示词" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "分辨率" + }, + "seed": { + "name": "种子", + "tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。" + }, + "watermark": { + "name": "水印", + "tooltip": "是否为视频添加水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "ByteDanceFirstLastFrameNode": { "description": "使用提示词和首尾帧生成视频。", "display_name": "字节跳动首尾帧转视频", @@ -1362,6 +1493,36 @@ } } }, + "ColorTransfer": { + "description": "使用多种算法将一张图像的颜色匹配到另一张图像。", + "display_name": "ColorTransfer", + "inputs": { + "image_ref": { + "name": "image_ref", + "tooltip": "要匹配颜色的参考图像。如果未提供,则跳过处理。" + }, + "image_target": { + "name": "image_target", + "tooltip": "要应用颜色变换的图像。" + }, + "method": { + "name": "method" + }, + "source_stats": { + "name": "source_stats", + "tooltip": "per_frame:每一帧分别与 image_ref 匹配。uniform:将所有源帧的统计数据汇总作为基线,与 image_ref 匹配。target_frame:选择一帧作为变换到 image_ref 的基线,统一应用到所有帧(保留相对差异)。" + }, + "strength": { + "name": "strength" + } + }, + "outputs": { + "0": { + "name": "image", + "tooltip": null + } + } + }, "CombineHooks2": { "display_name": "组合约束 [2]", "inputs": { @@ -5461,6 +5622,22 @@ } } }, + "JsonExtractString": { + "display_name": "从JSON提取字符串", + "inputs": { + "json_string": { + "name": "json_string" + }, + "key": { + "name": "key" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "KSampler": { "description": "使用提供的模型、正面条件和负面条件条件降噪Latent图像。", "display_name": "K采样器", @@ -13755,6 +13932,44 @@ } } }, + "SUPIRApply": { + "display_name": "SUPIRApply", + "inputs": { + "image": { + "name": "image" + }, + "model": { + "name": "model" + }, + "model_patch": { + "name": "model_patch" + }, + "restore_cfg": { + "name": "restore_cfg", + "tooltip": "将去噪输出拉向输入 latent。数值越高,对输入的保真度越强。0 表示禁用。" + }, + "restore_cfg_s_tmin": { + "name": "restore_cfg_s_tmin", + "tooltip": "低于该 sigma 阈值时,restore_cfg 被禁用。" + }, + "strength_end": { + "name": "strength_end", + "tooltip": "控制采样结束时(低 sigma)的强度。从开始值线性插值。" + }, + "strength_start": { + "name": "strength_start", + "tooltip": "控制采样开始时(高 sigma)的强度。" + }, + "vae": { + "name": "vae" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SV3D_Conditioning": { "display_name": "SV3D条件", "inputs": { @@ -14761,6 +14976,58 @@ } } }, + "SoniloTextToMusic": { + "description": "使用 Sonilo 的AI模型根据文本提示生成音乐。将时长设置为0可让模型根据提示自动推断时长。", + "display_name": "Sonilo 文本生成音乐", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "duration": { + "name": "duration", + "tooltip": "目标时长(秒)。设置为0可让模型根据提示自动推断时长。最大时长:6分钟。" + }, + "prompt": { + "name": "prompt", + "tooltip": "描述要生成音乐的文本提示。" + }, + "seed": { + "name": "seed", + "tooltip": "用于结果复现的种子。目前Sonilo服务会忽略此项,但为保持流程一致性而保留。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "SoniloVideoToMusic": { + "description": "使用 Sonilo 的AI模型根据视频内容生成音乐。分析视频并创作匹配的音乐。", + "display_name": "Sonilo 视频生成音乐", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "prompt": { + "name": "prompt", + "tooltip": "可选文本提示,用于引导音乐生成。留空可获得最佳质量——模型将完全分析视频内容。" + }, + "seed": { + "name": "seed", + "tooltip": "用于结果复现的种子。目前Sonilo服务会忽略此项,但为保持流程一致性而保留。" + }, + "video": { + "name": "video", + "tooltip": "输入要生成音乐的视频。最大时长:6分钟。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "SplitAudioChannels": { "description": "将音频分离为左右声道。", "display_name": "分离音频通道", @@ -16025,6 +16292,10 @@ "thinking": { "name": "思考模式", "tooltip": "如果模型支持,则以思考模式运行。" + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "如果模型自带系统提示/模板,则使用内置模板。" } }, "outputs": { @@ -16076,6 +16347,10 @@ "thinking": { "name": "思考模式", "tooltip": "如果模型支持,则以思考模式运行。" + }, + "use_default_template": { + "name": "use_default_template", + "tooltip": "如果模型自带系统提示/模板,则使用内置模板。" } }, "outputs": { From 799ffcf4b6bab9fa6af1beaf578a7fd400d8971a Mon Sep 17 00:00:00 2001 From: Dante Date: Mon, 20 Apr 2026 08:23:23 +0900 Subject: [PATCH 017/460] test: cover useWorkspaceUI and useWorkspaceBilling (#11319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes coverage gaps in \`src/platform/workspace/composables/\` as part of the unit-test backfill. ## Testing focus \`useWorkspaceUI\` is wrapped in \`createSharedComposable\` (shared instance across all callers). \`useWorkspaceBilling\` is a large stateful composable: parallel API calls, exponential-backoff polling, computed mappers, lifecycle cleanup. Both need careful state isolation and real lifecycle behavior — not faked hooks. ### \`useWorkspaceUI\` (8 tests) - **Permission / UI-config matrix.** Three role/type combinations — (personal × any), (team × owner), (team × member) — plus the no-active-workspace default. Assertions target concrete flags that differ per role (the table itself is the contract), not the return shape. - **\`createSharedComposable\` identity invariant.** Multiple calls return the same instance. - **Isolation.** Each test uses \`vi.resetModules()\` to get a fresh shared instance so the memoization doesn't leak between cases. ### \`useWorkspaceBilling\` (23 tests) - **Parallel init.** \`initialize\` runs \`Promise.all([status, balance, plans])\` concurrently, then re-fetches balance when free-tier shows a zero amount (lazy credit grant path). - **Polling with fake timers.** \`cancelSubscription\`'s exponential backoff (\`1000 * 2^attempt\`, max 5000ms) driven by \`vi.useFakeTimers()\` + \`advanceTimersByTimeAsync()\`. Covers success, failure, and the unmount-stops-polling case. - **Real lifecycle.** \`onBeforeUnmount\` only fires inside a component instance — not inside a raw \`effectScope\`. The unmount test mounts a minimal Vue app via \`createApp\` / \`app.unmount\` so the production cleanup path actually runs. - **Computed getter mapping.** \`subscription\`, \`balance\`, \`isActiveSubscription\`, \`isFreeTier\` assert the snake_case API shape is remapped to the camelCase UI shape correctly. - **\`window\` effects.** \`window.open\` stubbed via \`vi.spyOn\`, \`window.location.href\` via \`vi.stubGlobal\`. Restored in \`afterEach\`. ## Principles applied - No mocks of \`vue\` or \`@vueuse/core\` — only our own workspace API, stores, and sibling composables. - Behavioral assertions only. - All 31 tests pass; typecheck/lint/format clean. Test-only; no production code touched. --- .../subscription/utils/tierBenefits.test.ts | 37 +- .../composables/useWorkspaceBilling.test.ts | 689 ++++++++++++++++++ .../composables/useWorkspaceUI.test.ts | 208 ++++++ 3 files changed, 917 insertions(+), 17 deletions(-) create mode 100644 src/platform/workspace/composables/useWorkspaceBilling.test.ts create mode 100644 src/platform/workspace/composables/useWorkspaceUI.test.ts diff --git a/src/platform/cloud/subscription/utils/tierBenefits.test.ts b/src/platform/cloud/subscription/utils/tierBenefits.test.ts index 2f085a479b..81adab9d82 100644 --- a/src/platform/cloud/subscription/utils/tierBenefits.test.ts +++ b/src/platform/cloud/subscription/utils/tierBenefits.test.ts @@ -45,10 +45,9 @@ describe('getCommonTierBenefits', () => { expect(benefits.some((b) => b.key === 'monthlyCredits')).toBe(false) }) - it('includes a tier-scoped maxDuration metric for every tier', () => { - const tiers = ['free', 'standard', 'creator', 'pro', 'founder'] as const - - for (const tier of tiers) { + it.each(['free', 'standard', 'creator', 'pro', 'founder'] as const)( + 'includes a tier-scoped maxDuration metric for %s', + (tier) => { const benefits = getCommonTierBenefits(tier, translate, formatNumber) const maxDuration = benefits.find((b) => b.key === 'maxDuration') @@ -59,7 +58,7 @@ describe('getCommonTierBenefits', () => { label: 't:subscription.maxDurationLabel' }) } - }) + ) it('always includes the gpu feature benefit', () => { const benefits = getCommonTierBenefits('creator', translate, formatNumber) @@ -71,30 +70,34 @@ describe('getCommonTierBenefits', () => { }) }) - it('adds the addCredits benefit for every tier except free', () => { - const paidTiers = ['standard', 'creator', 'pro', 'founder'] as const - - for (const tier of paidTiers) { + it.each(['standard', 'creator', 'pro', 'founder'] as const)( + 'adds the addCredits benefit for %s tier', + (tier) => { const benefits = getCommonTierBenefits(tier, translate, formatNumber) expect(benefits.some((b) => b.key === 'addCredits')).toBe(true) } + ) + it('omits the addCredits benefit for the free tier', () => { const freeBenefits = getCommonTierBenefits('free', translate, formatNumber) expect(freeBenefits.some((b) => b.key === 'addCredits')).toBe(false) }) - it('includes customLoRAs only when the tier has it enabled', () => { - const creator = getCommonTierBenefits('creator', translate, formatNumber) - const pro = getCommonTierBenefits('pro', translate, formatNumber) - expect(creator.some((b) => b.key === 'customLoRAs')).toBe(true) - expect(pro.some((b) => b.key === 'customLoRAs')).toBe(true) + it.each(['creator', 'pro'] as const)( + 'includes customLoRAs for %s tier', + (tier) => { + const benefits = getCommonTierBenefits(tier, translate, formatNumber) + expect(benefits.some((b) => b.key === 'customLoRAs')).toBe(true) + } + ) - const tiersWithoutLoRAs = ['free', 'standard', 'founder'] as const - for (const tier of tiersWithoutLoRAs) { + it.each(['free', 'standard', 'founder'] as const)( + 'omits customLoRAs for %s tier', + (tier) => { const benefits = getCommonTierBenefits(tier, translate, formatNumber) expect(benefits.some((b) => b.key === 'customLoRAs')).toBe(false) } - }) + ) it('forwards translation params via the provided helpers', () => { const tSpy = vi.fn((key: string) => key) diff --git a/src/platform/workspace/composables/useWorkspaceBilling.test.ts b/src/platform/workspace/composables/useWorkspaceBilling.test.ts new file mode 100644 index 0000000000..bc49ddd174 --- /dev/null +++ b/src/platform/workspace/composables/useWorkspaceBilling.test.ts @@ -0,0 +1,689 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, defineComponent, effectScope, h } from 'vue' + +import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling' +import type { BillingActions, BillingState } from '@/composables/billing/types' + +const mockWorkspaceApi = vi.hoisted(() => ({ + getBillingStatus: vi.fn(), + getBillingBalance: vi.fn(), + getBillingOpStatus: vi.fn(), + subscribe: vi.fn(), + previewSubscribe: vi.fn(), + getPaymentPortalUrl: vi.fn(), + cancelSubscription: vi.fn() +})) + +const mockBillingPlans = vi.hoisted(() => ({ + plans: { value: [] as unknown[] }, + currentPlanSlug: { value: null as string | null }, + error: { value: null as string | null }, + fetchPlans: vi.fn() +})) + +const mockShow = vi.hoisted(() => vi.fn()) +const mockUpdateActiveWorkspace = vi.hoisted(() => vi.fn()) + +vi.mock('@/platform/workspace/api/workspaceApi', () => ({ + workspaceApi: mockWorkspaceApi +})) + +vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({ + useBillingPlans: () => mockBillingPlans +})) + +vi.mock( + '@/platform/cloud/subscription/composables/useSubscriptionDialog', + () => ({ + useSubscriptionDialog: () => ({ + show: mockShow + }) + }) +) + +vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({ + useTeamWorkspaceStore: () => ({ + updateActiveWorkspace: mockUpdateActiveWorkspace + }) +})) + +let scope: ReturnType | undefined + +function setupBilling() { + scope?.stop() + scope = effectScope() + const billing = scope.run(() => useWorkspaceBilling()) + if (!billing) { + throw new Error('Failed to create billing composable') + } + return billing +} + +const activeStatus = { + is_active: true, + has_funds: true, + subscription_status: 'active' as const, + subscription_tier: 'CREATOR' as const, + subscription_duration: 'MONTHLY' as const, + plan_slug: 'creator-monthly', + renewal_date: '2026-05-01T00:00:00Z' +} + +const freeStatus = { + is_active: true, + has_funds: true, + subscription_tier: 'FREE' as const, + plan_slug: 'free' +} + +const zeroBalance = { + amount_micros: 0, + currency: 'USD' +} + +const positiveBalance = { + amount_micros: 5_000_000, + currency: 'USD', + effective_balance_micros: 5_000_000, + prepaid_balance_micros: 3_000_000, + cloud_credit_balance_micros: 2_000_000 +} + +describe('useWorkspaceBilling', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBillingPlans.plans.value = [] + mockBillingPlans.currentPlanSlug.value = null + mockBillingPlans.error.value = null + mockBillingPlans.fetchPlans.mockResolvedValue(undefined) + }) + + afterEach(() => { + scope?.stop() + scope = undefined + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + describe('initialize', () => { + it('fetches status, balance, and plans in parallel then marks initialized', async () => { + mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus) + mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance) + + const billing = setupBilling() + await billing.initialize() + + expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1) + expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(1) + expect(mockBillingPlans.fetchPlans).toHaveBeenCalledTimes(1) + expect(billing.isInitialized.value).toBe(true) + expect(billing.isLoading.value).toBe(false) + }) + + it('is a no-op after first successful initialize', async () => { + mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus) + mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance) + + const billing = setupBilling() + await billing.initialize() + await billing.initialize() + + expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1) + }) + + it('re-fetches balance when free tier starts with zero credits', async () => { + mockWorkspaceApi.getBillingStatus.mockResolvedValue(freeStatus) + mockWorkspaceApi.getBillingBalance + .mockResolvedValueOnce(zeroBalance) + .mockResolvedValueOnce({ + amount_micros: 1_000_000, + currency: 'USD' + }) + + const billing = setupBilling() + await billing.initialize() + + expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(2) + expect(billing.balance.value?.amountMicros).toBe(1_000_000) + }) + + it('records an error message when initialization fails', async () => { + mockWorkspaceApi.getBillingStatus.mockRejectedValue( + new Error('network down') + ) + mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance) + + const billing = setupBilling() + + await expect(billing.initialize()).rejects.toThrow('network down') + expect(billing.error.value).toBe('network down') + expect(billing.isInitialized.value).toBe(false) + }) + }) + + describe('fetchStatus / computed subscription', () => { + it('exposes a null subscription before any status fetch', () => { + const billing = setupBilling() + expect(billing.subscription.value).toBeNull() + expect(billing.isActiveSubscription.value).toBe(false) + expect(billing.isFreeTier.value).toBe(false) + }) + + it('maps status response into subscription info', async () => { + mockWorkspaceApi.getBillingStatus.mockResolvedValue({ + ...activeStatus, + subscription_status: 'canceled', + cancel_at: '2026-06-01T00:00:00Z' + }) + + const billing = setupBilling() + await billing.fetchStatus() + + expect(billing.subscription.value).toMatchObject({ + isActive: true, + tier: 'CREATOR', + duration: 'MONTHLY', + planSlug: 'creator-monthly', + renewalDate: '2026-05-01T00:00:00Z', + endDate: '2026-06-01T00:00:00Z', + isCancelled: true, + hasFunds: true + }) + expect(billing.isActiveSubscription.value).toBe(true) + expect(billing.isFreeTier.value).toBe(false) + }) + + it('reports free tier when status tier is FREE', async () => { + mockWorkspaceApi.getBillingStatus.mockResolvedValue(freeStatus) + + const billing = setupBilling() + await billing.fetchStatus() + + expect(billing.isFreeTier.value).toBe(true) + }) + + it('sets error and rethrows when fetchStatus fails', async () => { + mockWorkspaceApi.getBillingStatus.mockRejectedValue(new Error('boom')) + + const billing = setupBilling() + + await expect(billing.fetchStatus()).rejects.toThrow('boom') + expect(billing.error.value).toBe('boom') + }) + }) + + describe('fetchBalance / computed balance', () => { + it('maps balance response into balance info', async () => { + mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance) + + const billing = setupBilling() + await billing.fetchBalance() + + expect(billing.balance.value).toEqual({ + amountMicros: 5_000_000, + currency: 'USD', + effectiveBalanceMicros: 5_000_000, + prepaidBalanceMicros: 3_000_000, + cloudCreditBalanceMicros: 2_000_000 + }) + }) + + it('sets error and rethrows when fetchBalance fails', async () => { + mockWorkspaceApi.getBillingBalance.mockRejectedValue( + new Error('balance failed') + ) + + const billing = setupBilling() + + await expect(billing.fetchBalance()).rejects.toThrow('balance failed') + expect(billing.error.value).toBe('balance failed') + }) + }) + + describe('subscribe', () => { + it('exposes refreshed status and balance after a successful subscribe', async () => { + mockWorkspaceApi.subscribe.mockResolvedValue({ + billing_op_id: 'op-1', + status: 'subscribed' + }) + // Pre-subscribe state: free tier with zero balance. + mockWorkspaceApi.getBillingStatus + .mockResolvedValueOnce(freeStatus) + .mockResolvedValueOnce(activeStatus) + mockWorkspaceApi.getBillingBalance + .mockResolvedValueOnce(zeroBalance) + .mockResolvedValueOnce(positiveBalance) + + const billing = setupBilling() + await billing.fetchStatus() + await billing.fetchBalance() + expect(billing.isFreeTier.value).toBe(true) + expect(billing.balance.value?.amountMicros).toBe(0) + + await billing.subscribe('pro', 'return', 'cancel') + + expect(mockWorkspaceApi.subscribe).toHaveBeenCalledWith( + 'pro', + 'return', + 'cancel' + ) + // State reflects the refreshed post-subscribe responses. + expect(billing.subscription.value?.tier).toBe('CREATOR') + expect(billing.isFreeTier.value).toBe(false) + expect(billing.balance.value?.amountMicros).toBe(5_000_000) + }) + + it('propagates error and records message when subscribe fails', async () => { + mockWorkspaceApi.subscribe.mockRejectedValue(new Error('denied')) + + const billing = setupBilling() + + await expect(billing.subscribe('pro')).rejects.toThrow('denied') + expect(billing.error.value).toBe('denied') + expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled() + }) + + it('falls back to a generic error message for non-Error rejections', async () => { + mockWorkspaceApi.subscribe.mockRejectedValue('string failure') + + const billing = setupBilling() + + await expect(billing.subscribe('pro')).rejects.toBe('string failure') + expect(billing.error.value).toBe('Failed to subscribe') + }) + }) + + describe('previewSubscribe', () => { + it('returns preview response on success', async () => { + const preview = { + allowed: true, + transition_type: 'new', + effective_at: 'now', + is_immediate: true, + cost_today_cents: 0, + cost_next_period_cents: 0, + credits_today_cents: 0, + credits_next_period_cents: 0, + new_plan: {} + } + mockWorkspaceApi.previewSubscribe.mockResolvedValue(preview) + + const billing = setupBilling() + const result = await billing.previewSubscribe('pro') + + expect(result).toBe(preview) + expect(billing.error.value).toBeNull() + }) + + it('sets error and rethrows when preview fails', async () => { + mockWorkspaceApi.previewSubscribe.mockRejectedValue( + new Error('preview failed') + ) + + const billing = setupBilling() + + await expect(billing.previewSubscribe('pro')).rejects.toThrow( + 'preview failed' + ) + expect(billing.error.value).toBe('preview failed') + }) + }) + + describe('manageSubscription', () => { + let originalLocation: Location + + beforeEach(() => { + originalLocation = window.location + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { ...originalLocation, href: 'https://app.example/settings' } + }) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: originalLocation + }) + }) + + it('opens the payment portal URL returned by the API', async () => { + const openSpy = vi.fn() + vi.stubGlobal('open', openSpy) + + mockWorkspaceApi.getPaymentPortalUrl.mockResolvedValue({ + url: 'https://billing.example/portal' + }) + + const billing = setupBilling() + await billing.manageSubscription() + + expect(mockWorkspaceApi.getPaymentPortalUrl).toHaveBeenCalledWith( + 'https://app.example/settings' + ) + expect(openSpy).toHaveBeenCalledWith( + 'https://billing.example/portal', + '_blank' + ) + }) + + it.each([ + ['empty string', ''], + ['null', null] + ])( + 'does not open a window when API returns %s url', + async (_label, url) => { + const openSpy = vi.fn() + vi.stubGlobal('open', openSpy) + + mockWorkspaceApi.getPaymentPortalUrl.mockResolvedValue({ + url: url as string + }) + + const billing = setupBilling() + await billing.manageSubscription() + + expect(openSpy).not.toHaveBeenCalled() + } + ) + + it('records error when API call fails', async () => { + mockWorkspaceApi.getPaymentPortalUrl.mockRejectedValue( + new Error('portal down') + ) + + const billing = setupBilling() + + await expect(billing.manageSubscription()).rejects.toThrow('portal down') + expect(billing.error.value).toBe('portal down') + }) + }) + + describe('cancelSubscription polling', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('updates workspace store when op succeeds', async () => { + mockWorkspaceApi.cancelSubscription.mockResolvedValue({ + billing_op_id: 'op-cancel', + cancel_at: '2026-06-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({ + id: 'op-cancel', + status: 'succeeded', + started_at: '2026-04-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingStatus.mockResolvedValue({ + ...activeStatus, + is_active: false, + subscription_status: 'canceled' + }) + + const billing = setupBilling() + await billing.cancelSubscription() + + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledWith( + 'op-cancel' + ) + expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({ + isSubscribed: false + }) + expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalled() + }) + + it('rethrows when the op reports failure', async () => { + mockWorkspaceApi.cancelSubscription.mockResolvedValue({ + billing_op_id: 'op-fail', + cancel_at: '2026-06-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({ + id: 'op-fail', + status: 'failed', + started_at: '2026-04-01T00:00:00Z', + error_message: 'processor rejected' + }) + + const billing = setupBilling() + + await expect(billing.cancelSubscription()).rejects.toThrow( + 'processor rejected' + ) + expect(billing.error.value).toBe('processor rejected') + expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled() + }) + + it('schedules the second poll at the 2000ms backoff boundary', async () => { + mockWorkspaceApi.cancelSubscription.mockResolvedValue({ + billing_op_id: 'op-slow', + cancel_at: '2026-06-01T00:00:00Z' + }) + const pendingResponse = { + id: 'op-slow', + status: 'pending' as const, + started_at: '2026-04-01T00:00:00Z' + } + mockWorkspaceApi.getBillingOpStatus + .mockResolvedValueOnce(pendingResponse) + .mockResolvedValueOnce({ + id: 'op-slow', + status: 'succeeded', + started_at: '2026-04-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingStatus.mockResolvedValue({ + ...activeStatus, + is_active: false + }) + + const billing = setupBilling() + const cancelPromise = billing.cancelSubscription() + + // First poll runs synchronously inside cancelSubscription. + await cancelPromise + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1) + + // Boundary check: still only 1 call just before the 2000ms mark. + await vi.advanceTimersByTimeAsync(1999) + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1) + + // Crossing 2000ms total fires the scheduled retry. + await vi.advanceTimersByTimeAsync(1) + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2) + expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({ + isSubscribed: false + }) + }) + + it('caps the backoff at 5000ms once 2^attempt exceeds the cap', async () => { + mockWorkspaceApi.cancelSubscription.mockResolvedValue({ + billing_op_id: 'op-cap', + cancel_at: '2026-06-01T00:00:00Z' + }) + const pending = { + id: 'op-cap', + status: 'pending' as const, + started_at: '2026-04-01T00:00:00Z' + } + mockWorkspaceApi.getBillingOpStatus + .mockResolvedValueOnce(pending) // #1, schedules +2000ms + .mockResolvedValueOnce(pending) // #2 at t=2000, schedules +4000ms + .mockResolvedValueOnce(pending) // #3 at t=6000, schedules capped +5000ms + .mockResolvedValueOnce({ + id: 'op-cap', + status: 'succeeded', + started_at: '2026-04-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus) + + const billing = setupBilling() + await billing.cancelSubscription() + + await vi.advanceTimersByTimeAsync(2000) // fires #2 + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(4000) // fires #3 at t=6000 + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3) + + // After #3 attempt=3, next delay should be capped at 5000ms (not 8000). + await vi.advanceTimersByTimeAsync(4999) + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(4) + }) + + it('propagates error before polling when the cancel API itself fails', async () => { + mockWorkspaceApi.cancelSubscription.mockRejectedValue( + new Error('API down') + ) + + const billing = setupBilling() + + await expect(billing.cancelSubscription()).rejects.toThrow('API down') + expect(billing.error.value).toBe('API down') + expect(mockWorkspaceApi.getBillingOpStatus).not.toHaveBeenCalled() + expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled() + }) + + it('falls back to a generic error message when cancel rejects with a non-Error', async () => { + mockWorkspaceApi.cancelSubscription.mockRejectedValue('boom') + + const billing = setupBilling() + + await expect(billing.cancelSubscription()).rejects.toBe('boom') + expect(billing.error.value).toBe('Failed to cancel subscription') + }) + + it('stops polling after 30 attempts and refreshes status without marking unsubscribed', async () => { + mockWorkspaceApi.cancelSubscription.mockResolvedValue({ + billing_op_id: 'op-stuck', + cancel_at: '2026-06-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({ + id: 'op-stuck', + status: 'pending', + started_at: '2026-04-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus) + + const billing = setupBilling() + await billing.cancelSubscription() + + // Advance well past all scheduled polls (worst-case ~146s). + await vi.advanceTimersByTimeAsync(200_000) + + expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(30) + expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1) + expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled() + }) + + it('stops polling when the host component is unmounted', async () => { + mockWorkspaceApi.cancelSubscription.mockResolvedValue({ + billing_op_id: 'op-dispose', + cancel_at: '2026-06-01T00:00:00Z' + }) + mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({ + id: 'op-dispose', + status: 'pending', + started_at: '2026-04-01T00:00:00Z' + }) + + let billing: (BillingState & BillingActions) | undefined + const HostComponent = defineComponent({ + setup() { + billing = useWorkspaceBilling() + return () => h('div') + } + }) + const host = document.createElement('div') + const app = createApp(HostComponent) + app.mount(host) + + if (!billing) throw new Error('composable not initialized') + const cancelPromise = billing.cancelSubscription().catch(() => undefined) + await cancelPromise + + // Cross one backoff interval so the second poll is actually scheduled + // and then confirm that unmount freezes the counter across subsequent ticks. + await vi.advanceTimersByTimeAsync(2000) + const callsBeforeUnmount = + mockWorkspaceApi.getBillingOpStatus.mock.calls.length + expect(callsBeforeUnmount).toBeGreaterThanOrEqual(2) + + app.unmount() + + await vi.advanceTimersByTimeAsync(20_000) + + expect(mockWorkspaceApi.getBillingOpStatus.mock.calls.length).toBe( + callsBeforeUnmount + ) + }) + }) + + describe('plans / currentPlanSlug / fetchPlans', () => { + it('prefers the plan slug from status over the billingPlans fallback', async () => { + mockBillingPlans.currentPlanSlug.value = 'plans-fallback' + mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus) + + const billing = setupBilling() + await billing.fetchStatus() + + expect(billing.currentPlanSlug.value).toBe('creator-monthly') + }) + + it('falls back to billingPlans.currentPlanSlug when status has no plan slug', async () => { + mockBillingPlans.currentPlanSlug.value = 'plans-fallback' + + const billing = setupBilling() + + expect(billing.currentPlanSlug.value).toBe('plans-fallback') + }) + + it('propagates a soft error from billingPlans into billing.error', async () => { + mockBillingPlans.fetchPlans.mockResolvedValue(undefined) + mockBillingPlans.error.value = 'plans lookup failed' + + const billing = setupBilling() + await billing.fetchPlans() + + expect(billing.error.value).toBe('plans lookup failed') + }) + }) + + describe('requireActiveSubscription', () => { + it('opens the subscription dialog when not active', async () => { + mockWorkspaceApi.getBillingStatus.mockResolvedValue({ + ...activeStatus, + is_active: false + }) + + const billing = setupBilling() + await billing.requireActiveSubscription() + + expect(mockShow).toHaveBeenCalledTimes(1) + }) + + it('does nothing when subscription is active', async () => { + mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus) + + const billing = setupBilling() + await billing.requireActiveSubscription() + + expect(mockShow).not.toHaveBeenCalled() + }) + }) + + describe('showSubscriptionDialog', () => { + it('delegates to the subscription dialog', () => { + const billing = setupBilling() + billing.showSubscriptionDialog() + + expect(mockShow).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/platform/workspace/composables/useWorkspaceUI.test.ts b/src/platform/workspace/composables/useWorkspaceUI.test.ts new file mode 100644 index 0000000000..2c88030e24 --- /dev/null +++ b/src/platform/workspace/composables/useWorkspaceUI.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi' + +const mockActiveWorkspace = vi.hoisted(() => ({ + value: null as WorkspaceWithRole | null +})) + +vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({ + useTeamWorkspaceStore: () => ({ + get activeWorkspace() { + return mockActiveWorkspace.value + } + }) +})) + +const personalWorkspace: WorkspaceWithRole = { + id: 'ws-personal', + name: 'Personal', + type: 'personal', + role: 'owner', + created_at: '2026-01-01T00:00:00Z', + joined_at: '2026-01-01T00:00:00Z' +} + +const teamOwnerWorkspace: WorkspaceWithRole = { + id: 'ws-team-owner', + name: 'Team Alpha', + type: 'team', + role: 'owner', + created_at: '2026-02-01T00:00:00Z', + joined_at: '2026-02-01T00:00:00Z' +} + +const teamMemberWorkspace: WorkspaceWithRole = { + id: 'ws-team-member', + name: 'Team Beta', + type: 'team', + role: 'member', + created_at: '2026-03-01T00:00:00Z', + joined_at: '2026-03-01T00:00:00Z' +} + +async function loadComposable() { + const module = await import('@/platform/workspace/composables/useWorkspaceUI') + return module.useWorkspaceUI() +} + +describe('useWorkspaceUI', () => { + beforeEach(() => { + vi.resetModules() + mockActiveWorkspace.value = null + }) + + afterEach(() => { + mockActiveWorkspace.value = null + }) + + describe('when no active workspace', () => { + it('defaults to personal workspace behavior', async () => { + const ui = await loadComposable() + + expect(ui.workspaceType.value).toBe('personal') + expect(ui.workspaceRole.value).toBe('owner') + expect(ui.permissions.value.canManageSubscription).toBe(true) + expect(ui.permissions.value.canTopUp).toBe(true) + expect(ui.permissions.value.canViewOtherMembers).toBe(false) + expect(ui.uiConfig.value.showMembersList).toBe(false) + }) + }) + + describe('personal workspace', () => { + beforeEach(() => { + mockActiveWorkspace.value = personalWorkspace + }) + + it('grants billing access but disables team management', async () => { + const ui = await loadComposable() + + expect(ui.workspaceType.value).toBe('personal') + expect(ui.permissions.value).toMatchObject({ + canManageSubscription: true, + canTopUp: true, + canViewOtherMembers: false, + canViewPendingInvites: false, + canInviteMembers: false, + canManageInvites: false, + canRemoveMembers: false, + canLeaveWorkspace: false, + canAccessWorkspaceMenu: false + }) + }) + + it('hides multi-member UI elements', async () => { + const ui = await loadComposable() + + expect(ui.uiConfig.value).toMatchObject({ + showMembersList: false, + showPendingTab: false, + showSearch: false, + showDateColumn: false, + showRoleBadge: false, + showEditWorkspaceMenuItem: false, + workspaceMenuAction: null, + workspaceMenuDisabledTooltip: null + }) + }) + + it('uses single-column grids for the collapsed personal layout', async () => { + const ui = await loadComposable() + + expect(ui.uiConfig.value.membersGridCols).toBe('grid-cols-1') + expect(ui.uiConfig.value.headerGridCols).toBe('grid-cols-1') + expect(ui.uiConfig.value.pendingGridCols).toBe( + 'grid-cols-[50%_20%_20%_10%]' + ) + }) + }) + + describe('team workspace as owner', () => { + beforeEach(() => { + mockActiveWorkspace.value = teamOwnerWorkspace + }) + + it('grants full management permissions', async () => { + const ui = await loadComposable() + + expect(ui.workspaceType.value).toBe('team') + expect(ui.workspaceRole.value).toBe('owner') + expect(ui.permissions.value).toMatchObject({ + canViewOtherMembers: true, + canViewPendingInvites: true, + canInviteMembers: true, + canManageInvites: true, + canRemoveMembers: true, + canLeaveWorkspace: true, + canAccessWorkspaceMenu: true, + canManageSubscription: true, + canTopUp: true + }) + }) + + it('exposes owner-specific UI chrome including delete action', async () => { + const ui = await loadComposable() + + expect(ui.uiConfig.value.showPendingTab).toBe(true) + expect(ui.uiConfig.value.showEditWorkspaceMenuItem).toBe(true) + expect(ui.uiConfig.value.workspaceMenuAction).toBe('delete') + expect(ui.uiConfig.value.workspaceMenuDisabledTooltip).toBe( + 'workspacePanel.menu.deleteWorkspaceDisabledTooltip' + ) + expect(ui.uiConfig.value.membersGridCols).toBe('grid-cols-[50%_40%_10%]') + expect(ui.uiConfig.value.headerGridCols).toBe('grid-cols-[50%_40%_10%]') + expect(ui.uiConfig.value.pendingGridCols).toBe( + 'grid-cols-[50%_20%_20%_10%]' + ) + }) + }) + + describe('team workspace as member', () => { + beforeEach(() => { + mockActiveWorkspace.value = teamMemberWorkspace + }) + + it('restricts management actions while allowing leave', async () => { + const ui = await loadComposable() + + expect(ui.workspaceRole.value).toBe('member') + expect(ui.permissions.value).toMatchObject({ + canViewOtherMembers: true, + canViewPendingInvites: false, + canInviteMembers: false, + canManageInvites: false, + canRemoveMembers: false, + canLeaveWorkspace: true, + canAccessWorkspaceMenu: true, + canManageSubscription: false, + canTopUp: false + }) + }) + + it('shows members but hides invite management and uses leave action', async () => { + const ui = await loadComposable() + + expect(ui.uiConfig.value.showMembersList).toBe(true) + expect(ui.uiConfig.value.showPendingTab).toBe(false) + expect(ui.uiConfig.value.showEditWorkspaceMenuItem).toBe(false) + expect(ui.uiConfig.value.workspaceMenuAction).toBe('leave') + expect(ui.uiConfig.value.workspaceMenuDisabledTooltip).toBeNull() + expect(ui.uiConfig.value.membersGridCols).toBe('grid-cols-[1fr_auto]') + expect(ui.uiConfig.value.headerGridCols).toBe('grid-cols-[1fr_auto]') + expect(ui.uiConfig.value.pendingGridCols).toBe( + 'grid-cols-[50%_20%_20%_10%]' + ) + }) + }) + + describe('shared instance', () => { + it('returns the same composable state for multiple callers within a test', async () => { + mockActiveWorkspace.value = teamOwnerWorkspace + const first = await loadComposable() + const second = await loadComposable() + + expect(second.permissions).toBe(first.permissions) + expect(second.uiConfig).toBe(first.uiConfig) + }) + }) +}) From 07ce7123c897496093c5b9a02716472067ff8d9a Mon Sep 17 00:00:00 2001 From: Dante Date: Mon, 20 Apr 2026 08:49:36 +0900 Subject: [PATCH 018/460] test: cover useErrorActions and useErrorReport (#11320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes coverage gaps in \`src/components/rightSidePanel/errors/\` as part of the unit-test backfill. ## Testing focus \`useErrorActions\` is thin (telemetry + command + \`window.open\`), but \`useErrorReport\` is a real async watcher with multiple store dependencies, \`@vueuse/core\`'s \`until(...)\`, and a cancellation guard. The tricky part is keeping \`until\` reactive without mocking \`@vueuse/core\`. ### \`useErrorActions\` (8 tests) - Three functions × telemetry-fired × command/window invocation × the \`telemetry?.\` null-safe branch. - \`findOnGitHub\` encoding: verifies \`encodeURIComponent\` runs on the error message and \` is:issue\` is appended. - \`window.open\` stubbed via \`vi.spyOn\`, restored in \`afterEach\`. ### \`useErrorReport\` (9 tests) - **Reactive \`until()\`.** \`@vueuse/core\` is **not** mocked. The \`useSystemStatsStore\` mock creates real Vue \`ref\`s and exposes them via getter/setter so \`until(() => isLoading).toBe(false)\` resolves through actual reactivity. - **\`__setSystemStats\` / \`__setIsLoading\` helpers** on the mocked store let tests mutate state from the outside without leaking global mutable state beyond \`vi.hoisted\`. - **Cancellation guard.** Manually-resolvable deferred \`getLogs\` promise — while it's pending, the \`cardSource\` ref is swapped. The previous run's results must **not** mutate \`enrichedDetails\`. Regressions here would cause race-dependent UI state when users switch between error cards quickly. - **Fallback paths.** Missing \`exceptionType\` → \`FALLBACK_EXCEPTION_TYPE\` ('Runtime Error'). \`serialize()\` throws → early return. \`generateErrorReport\` throws → \`displayedDetailsMap\` falls back to the raw \`error.details\`. - **Watcher cleanup.** Swapping the card ref clears stale \`enrichedDetails\` before re-enrichment. - \`console.warn\` spy suppresses noise; restored in \`afterEach\`. ## Principles applied - No mocks of \`vue\` or \`@vueuse/core\` — only our own modules (\`api\`, \`app\`, \`systemStatsStore\`, \`errorReportUtil\`). - \`@vue/test-utils\` isn't installed; a local \`flushPromises\` helper is used (matches the existing pattern in \`useNodeHelpContent.test.ts\`). - All 17 tests pass; typecheck/lint/format clean. Test-only; no production code touched. --- .../errors/useErrorActions.test.ts | 159 +++++++ .../errors/useErrorReport.test.ts | 392 ++++++++++++++++++ 2 files changed, 551 insertions(+) create mode 100644 src/components/rightSidePanel/errors/useErrorActions.test.ts create mode 100644 src/components/rightSidePanel/errors/useErrorReport.test.ts diff --git a/src/components/rightSidePanel/errors/useErrorActions.test.ts b/src/components/rightSidePanel/errors/useErrorActions.test.ts new file mode 100644 index 0000000000..2b913f6e7d --- /dev/null +++ b/src/components/rightSidePanel/errors/useErrorActions.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useErrorActions } from './useErrorActions' + +const mocks = vi.hoisted(() => ({ + trackUiButtonClicked: vi.fn(), + trackHelpResourceClicked: vi.fn(), + execute: vi.fn(), + telemetry: null as { + trackUiButtonClicked: ReturnType + trackHelpResourceClicked: ReturnType + } | null, + staticUrls: { + githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues' + } +})) + +vi.mock('@/stores/commandStore', () => ({ + useCommandStore: () => ({ + execute: mocks.execute + }) +})) + +vi.mock('@/composables/useExternalLink', () => ({ + useExternalLink: () => ({ + staticUrls: mocks.staticUrls + }) +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => mocks.telemetry +})) + +describe('useErrorActions', () => { + let windowOpenSpy: ReturnType + + beforeEach(() => { + mocks.telemetry = { + trackUiButtonClicked: mocks.trackUiButtonClicked, + trackHelpResourceClicked: mocks.trackHelpResourceClicked + } + mocks.trackUiButtonClicked.mockReset() + mocks.trackHelpResourceClicked.mockReset() + mocks.execute.mockReset() + windowOpenSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => null as unknown as Window) + }) + + afterEach(() => { + windowOpenSpy.mockRestore() + }) + + describe('openGitHubIssues', () => { + it('tracks the button click and opens the GitHub issues URL in a new tab', () => { + const { openGitHubIssues } = useErrorActions() + + openGitHubIssues() + + expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({ + button_id: 'error_tab_github_issues_clicked' + }) + expect(windowOpenSpy).toHaveBeenCalledWith( + mocks.staticUrls.githubIssues, + '_blank', + 'noopener,noreferrer' + ) + }) + + it('still opens the link when telemetry is unavailable', () => { + mocks.telemetry = null + const { openGitHubIssues } = useErrorActions() + + openGitHubIssues() + + expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled() + expect(windowOpenSpy).toHaveBeenCalledWith( + mocks.staticUrls.githubIssues, + '_blank', + 'noopener,noreferrer' + ) + }) + }) + + describe('contactSupport', () => { + it('tracks the help resource click and executes the contact support command', () => { + mocks.execute.mockReturnValue('executed') + const { contactSupport } = useErrorActions() + + const result = contactSupport() + + expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({ + resource_type: 'help_feedback', + is_external: true, + source: 'error_dialog' + }) + expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport') + expect(result).toBe('executed') + }) + + it('returns the execute promise when the command is async', async () => { + mocks.execute.mockResolvedValue('done') + const { contactSupport } = useErrorActions() + + await expect(contactSupport()).resolves.toBe('done') + }) + + it('still executes the command when telemetry is unavailable', () => { + mocks.telemetry = null + const { contactSupport } = useErrorActions() + + void contactSupport() + + expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled() + expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport') + }) + }) + + describe('findOnGitHub', () => { + it('tracks the click and opens a URL-encoded issue search with " is:issue" appended', () => { + const { findOnGitHub } = useErrorActions() + + findOnGitHub('CUDA out of memory') + + expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({ + button_id: 'error_tab_find_existing_issues_clicked' + }) + const expectedQuery = encodeURIComponent('CUDA out of memory is:issue') + expect(windowOpenSpy).toHaveBeenCalledWith( + `${mocks.staticUrls.githubIssues}?q=${expectedQuery}`, + '_blank', + 'noopener,noreferrer' + ) + }) + + it('URL-encodes messages with special characters', () => { + const { findOnGitHub } = useErrorActions() + + findOnGitHub('error with spaces & symbols?') + + const [[url]] = windowOpenSpy.mock.calls as unknown as [[string]] + expect(url).toContain('?q=') + const queryPart = url.split('?q=')[1] + expect(decodeURIComponent(queryPart)).toBe( + 'error with spaces & symbols? is:issue' + ) + }) + + it('still opens the link when telemetry is unavailable', () => { + mocks.telemetry = null + const { findOnGitHub } = useErrorActions() + + findOnGitHub('boom') + + expect(mocks.trackUiButtonClicked).not.toHaveBeenCalled() + expect(windowOpenSpy).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/components/rightSidePanel/errors/useErrorReport.test.ts b/src/components/rightSidePanel/errors/useErrorReport.test.ts new file mode 100644 index 0000000000..1eac1febd9 --- /dev/null +++ b/src/components/rightSidePanel/errors/useErrorReport.test.ts @@ -0,0 +1,392 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import type { useSystemStatsStore } from '@/stores/systemStatsStore' + +import type { ErrorCardData } from './types' +import { useErrorReport } from './useErrorReport' + +async function flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)) + await nextTick() +} + +const mocks = vi.hoisted(() => { + // Helpers only — imports happen inside factories below. + return { + getLogs: vi.fn(), + serialize: vi.fn(), + refetchSystemStats: vi.fn(), + generateErrorReport: vi.fn() + } +}) + +const storeState = vi.hoisted(() => { + // Plain objects wired up in beforeEach. Tests use setStoreState to swap values. + return { + systemStats: null as unknown, + isLoading: false + } +}) + +vi.mock('@/scripts/api', () => ({ + api: { + getLogs: mocks.getLogs + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { + rootGraph: { + serialize: mocks.serialize + } + } +})) + +vi.mock('@/utils/errorReportUtil', () => ({ + generateErrorReport: mocks.generateErrorReport +})) + +vi.mock('@/stores/systemStatsStore', async () => { + const { ref: vueRef } = await import('vue') + const systemStatsRef = vueRef(null) + const isLoadingRef = vueRef(false) + + return { + useSystemStatsStore: () => ({ + get systemStats() { + return systemStatsRef.value + }, + set systemStats(value: unknown) { + systemStatsRef.value = value + }, + get isLoading() { + return isLoadingRef.value + }, + set isLoading(value: boolean) { + isLoadingRef.value = value + }, + refetchSystemStats: mocks.refetchSystemStats, + __setSystemStats(value: unknown) { + systemStatsRef.value = value + }, + __setIsLoading(value: boolean) { + isLoadingRef.value = value + } + }) + } +}) + +type TestStore = ReturnType & { + __setSystemStats: (value: unknown) => void + __setIsLoading: (value: boolean) => void +} + +async function getStore(): Promise { + const mod = await import('@/stores/systemStatsStore') + return mod.useSystemStatsStore() as unknown as TestStore +} + +const sampleSystemStats = { + system: { + os: 'Linux', + comfyui_version: '1.0.0', + argv: [], + python_version: '3.11', + embedded_python: false, + pytorch_version: '2.3.0' + }, + devices: [] +} + +function makeCard(overrides: Partial = {}): ErrorCardData { + return { + id: 'card-1', + title: 'KSampler', + nodeId: '42', + errors: [], + ...overrides + } +} + +describe('useErrorReport', () => { + let warnSpy: ReturnType + + beforeEach(async () => { + mocks.getLogs.mockReset() + mocks.serialize.mockReset() + mocks.refetchSystemStats.mockReset() + mocks.generateErrorReport.mockReset() + storeState.systemStats = null + storeState.isLoading = false + const store = await getStore() + store.__setSystemStats(null) + store.__setIsLoading(false) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('returns early without enrichment when the card has no runtime errors', async () => { + const card = makeCard({ + errors: [{ message: 'static', details: 'details' }] + }) + + const { displayedDetailsMap } = useErrorReport(card) + await flushPromises() + + expect(mocks.getLogs).not.toHaveBeenCalled() + expect(mocks.generateErrorReport).not.toHaveBeenCalled() + expect(displayedDetailsMap.value).toEqual({ 0: 'details' }) + }) + + it('enriches each runtime error with a generated report when systemStats is present', async () => { + const store = await getStore() + store.__setSystemStats(sampleSystemStats) + mocks.getLogs.mockResolvedValue('server logs') + mocks.serialize.mockReturnValue({ nodes: [] }) + mocks.generateErrorReport.mockImplementation( + ({ exceptionType }: { exceptionType: string }) => + `report:${exceptionType}` + ) + + const card = makeCard({ + errors: [ + { + message: 'CUDA oom', + details: 'trace-0', + isRuntimeError: true, + exceptionType: 'RuntimeError' + }, + { + message: 'static', + details: 'skip-me' + }, + { + message: 'Other runtime error', + details: 'trace-2', + isRuntimeError: true + } + ] + }) + + const { displayedDetailsMap } = useErrorReport(card) + await flushPromises() + + expect(mocks.getLogs).toHaveBeenCalledTimes(1) + expect(mocks.generateErrorReport).toHaveBeenCalledTimes(2) + expect(mocks.generateErrorReport).toHaveBeenNthCalledWith(1, { + exceptionType: 'RuntimeError', + exceptionMessage: 'CUDA oom', + traceback: 'trace-0', + nodeId: '42', + nodeType: 'KSampler', + systemStats: sampleSystemStats, + serverLogs: 'server logs', + workflow: { nodes: [] } + }) + expect(mocks.generateErrorReport).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + exceptionType: 'Runtime Error', + exceptionMessage: 'Other runtime error', + traceback: 'trace-2' + }) + ) + + expect(displayedDetailsMap.value).toEqual({ + 0: 'report:RuntimeError', + 1: 'skip-me', + 2: 'report:Runtime Error' + }) + }) + + it('awaits the systemStats loading flag before proceeding', async () => { + const store = await getStore() + store.__setIsLoading(true) + mocks.getLogs.mockResolvedValue('logs') + mocks.serialize.mockReturnValue({ nodes: [] }) + mocks.generateErrorReport.mockReturnValue('report') + + const card = makeCard({ + errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }] + }) + + const { displayedDetailsMap } = useErrorReport(card) + await flushPromises() + + expect(mocks.getLogs).not.toHaveBeenCalled() + expect(displayedDetailsMap.value).toEqual({ 0: 'trace' }) + + store.__setSystemStats(sampleSystemStats) + store.__setIsLoading(false) + await flushPromises() + + expect(mocks.getLogs).toHaveBeenCalledTimes(1) + expect(displayedDetailsMap.value).toEqual({ 0: 'report' }) + }) + + it('calls refetchSystemStats when not loading and stats are missing', async () => { + const store = await getStore() + mocks.refetchSystemStats.mockImplementation(async () => { + store.__setSystemStats(sampleSystemStats) + }) + mocks.getLogs.mockResolvedValue('logs') + mocks.serialize.mockReturnValue({ nodes: [] }) + mocks.generateErrorReport.mockReturnValue('report') + + const card = makeCard({ + errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }] + }) + + useErrorReport(card) + await flushPromises() + + expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1) + expect(mocks.generateErrorReport).toHaveBeenCalledTimes(1) + }) + + it('returns early and warns when refetchSystemStats throws', async () => { + mocks.refetchSystemStats.mockRejectedValue(new Error('boom')) + mocks.getLogs.mockResolvedValue('logs') + + const card = makeCard({ + errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }] + }) + + useErrorReport(card) + await flushPromises() + + expect(mocks.refetchSystemStats).toHaveBeenCalledTimes(1) + expect(mocks.getLogs).not.toHaveBeenCalled() + expect(mocks.generateErrorReport).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + }) + + it('returns early and warns when workflow serialization throws', async () => { + const store = await getStore() + store.__setSystemStats(sampleSystemStats) + mocks.getLogs.mockResolvedValue('logs') + mocks.serialize.mockImplementation(() => { + throw new Error('serialize failed') + }) + + const card = makeCard({ + errors: [{ message: 'runtime', details: 'trace', isRuntimeError: true }] + }) + + const { displayedDetailsMap } = useErrorReport(card) + await flushPromises() + + expect(mocks.generateErrorReport).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + expect(displayedDetailsMap.value).toEqual({ 0: 'trace' }) + }) + + it('falls back to original error.details when generateErrorReport throws', async () => { + const store = await getStore() + store.__setSystemStats(sampleSystemStats) + mocks.getLogs.mockResolvedValue('logs') + mocks.serialize.mockReturnValue({ nodes: [] }) + mocks.generateErrorReport.mockImplementation(() => { + throw new Error('generate failed') + }) + + const card = makeCard({ + errors: [ + { message: 'runtime', details: 'fallback', isRuntimeError: true } + ] + }) + + const { displayedDetailsMap } = useErrorReport(card) + await flushPromises() + + expect(warnSpy).toHaveBeenCalled() + expect(displayedDetailsMap.value).toEqual({ 0: 'fallback' }) + }) + + it('re-enriches and clears stale enriched details when the card ref changes', async () => { + const store = await getStore() + store.__setSystemStats(sampleSystemStats) + mocks.getLogs.mockResolvedValue('logs') + mocks.serialize.mockReturnValue({ nodes: [] }) + mocks.generateErrorReport.mockImplementation( + ({ exceptionMessage }: { exceptionMessage: string }) => + `report:${exceptionMessage}` + ) + + const cardRef = ref( + makeCard({ + id: 'first', + errors: [ + { message: 'first-err', details: 'first', isRuntimeError: true } + ] + }) + ) + + const { displayedDetailsMap } = useErrorReport(cardRef) + await flushPromises() + + expect(displayedDetailsMap.value).toEqual({ 0: 'report:first-err' }) + + cardRef.value = makeCard({ + id: 'second', + errors: [{ message: 'plain', details: 'plain-details' }] + }) + await nextTick() + await flushPromises() + + expect(displayedDetailsMap.value).toEqual({ 0: 'plain-details' }) + }) + + it('drops stale results when the card changes mid-flight', async () => { + const store = await getStore() + store.__setSystemStats(sampleSystemStats) + mocks.serialize.mockReturnValue({ nodes: [] }) + mocks.generateErrorReport.mockImplementation( + ({ exceptionMessage }: { exceptionMessage: string }) => + `report:${exceptionMessage}` + ) + + const firstLogsDeferred: { + resolve: (value: string) => void + promise: Promise + } = (() => { + let resolve: (value: string) => void = () => {} + const promise = new Promise((r) => { + resolve = r + }) + return { resolve, promise } + })() + mocks.getLogs.mockImplementationOnce(() => firstLogsDeferred.promise) + mocks.getLogs.mockImplementationOnce(async () => 'second-logs') + + const cardRef = ref( + makeCard({ + id: 'first', + errors: [ + { message: 'first-err', details: 'first', isRuntimeError: true } + ] + }) + ) + + const { displayedDetailsMap } = useErrorReport(cardRef) + await flushPromises() + + cardRef.value = makeCard({ + id: 'second', + errors: [ + { message: 'second-err', details: 'second', isRuntimeError: true } + ] + }) + await nextTick() + await flushPromises() + + firstLogsDeferred.resolve('stale-logs') + await flushPromises() + + expect(displayedDetailsMap.value).toEqual({ 0: 'report:second-err' }) + }) +}) From 0638e8e99327aef100fd16a73e9e2fce1fc48fdd Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 19 Apr 2026 17:48:34 -0700 Subject: [PATCH 019/460] test: add unit tests for SceneModelManager (#11392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add 44 unit tests for `SceneModelManager` in the 3D viewer (`src/extensions/core/load3d/`). ## Changes - **What**: New test file `SceneModelManager.test.ts` covering constructor, dispose, createSTLMaterial, addModelToScene, setupModel, setOriginalModel, clearModel, reset, setMaterialMode (all 5 modes), setupModelMaterials, setUpDirection (all 7 directions), hasSkeleton, setShowSkeleton, containsSplatMesh, and PLY mode switching (point cloud, wireframe, vertex colors, cleanup). ## Review Focus - Test coverage of PLY mode switching edge cases (vertex colors, old model cleanup) - Mock strategy for WebGLRenderer (happy-dom cannot instantiate it) - SplatMesh mock leverages the existing global mock in `vitest.setup.ts` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11392-test-add-unit-tests-for-SceneModelManager-3476d73d3650819097f3f6d73d8fbe02) by [Unito](https://www.unito.io) --- .../core/load3d/SceneModelManager.test.ts | 699 ++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 src/extensions/core/load3d/SceneModelManager.test.ts diff --git a/src/extensions/core/load3d/SceneModelManager.test.ts b/src/extensions/core/load3d/SceneModelManager.test.ts new file mode 100644 index 0000000000..88613848b4 --- /dev/null +++ b/src/extensions/core/load3d/SceneModelManager.test.ts @@ -0,0 +1,699 @@ +import * as THREE from 'three' +import { describe, expect, it, vi } from 'vitest' + +import type { EventManagerInterface } from './interfaces' +import { SceneModelManager } from './SceneModelManager' + +function createMockRenderer(): THREE.WebGLRenderer { + return { + outputColorSpace: THREE.SRGBColorSpace, + dispose: vi.fn() + } as unknown as THREE.WebGLRenderer +} + +function createMockEventManager(): EventManagerInterface { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } +} + +function createManager( + overrides: { + scene?: THREE.Scene + eventManager?: EventManagerInterface + } = {} +) { + const scene = overrides.scene ?? new THREE.Scene() + const renderer = createMockRenderer() + const eventManager = overrides.eventManager ?? createMockEventManager() + const camera = new THREE.PerspectiveCamera() + const getActiveCamera = () => camera + const setupCamera = vi.fn() + const setupGizmo = vi.fn() + + const manager = new SceneModelManager( + scene, + renderer, + eventManager, + getActiveCamera, + setupCamera, + setupGizmo + ) + + return { + manager, + scene, + renderer, + eventManager, + camera, + setupCamera, + setupGizmo + } +} + +function createMeshModel(name = 'TestModel'): THREE.Group { + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshStandardMaterial({ color: 0xff0000 }) + const mesh = new THREE.Mesh(geometry, material) + const group = new THREE.Group() + group.name = name + group.add(mesh) + return group +} + +describe('SceneModelManager', () => { + describe('constructor', () => { + it('initializes default state', () => { + const { manager } = createManager() + + expect(manager.currentModel).toBeNull() + expect(manager.originalModel).toBeNull() + expect(manager.originalRotation).toBeNull() + expect(manager.currentUpDirection).toBe('original') + expect(manager.materialMode).toBe('original') + expect(manager.originalFileName).toBeNull() + expect(manager.originalURL).toBeNull() + expect(manager.appliedTexture).toBeNull() + expect(manager.skeletonHelper).toBeNull() + expect(manager.showSkeleton).toBe(false) + }) + + it('creates material instances', () => { + const { manager } = createManager() + + expect(manager.normalMaterial).toBeInstanceOf(THREE.MeshNormalMaterial) + expect(manager.wireframeMaterial).toBeInstanceOf(THREE.MeshBasicMaterial) + expect(manager.wireframeMaterial.wireframe).toBe(true) + expect(manager.depthMaterial).toBeInstanceOf(THREE.MeshDepthMaterial) + expect(manager.standardMaterial).toBeInstanceOf( + THREE.MeshStandardMaterial + ) + }) + }) + + describe('dispose', () => { + it('disposes all materials', () => { + const { manager } = createManager() + + const normalDispose = vi.spyOn(manager.normalMaterial, 'dispose') + const standardDispose = vi.spyOn(manager.standardMaterial, 'dispose') + const wireframeDispose = vi.spyOn(manager.wireframeMaterial, 'dispose') + const depthDispose = vi.spyOn(manager.depthMaterial, 'dispose') + + manager.dispose() + + expect(normalDispose).toHaveBeenCalled() + expect(standardDispose).toHaveBeenCalled() + expect(wireframeDispose).toHaveBeenCalled() + expect(depthDispose).toHaveBeenCalled() + }) + + it('disposes applied texture', () => { + const { manager } = createManager() + const texture = new THREE.Texture() + const textureDispose = vi.spyOn(texture, 'dispose') + manager.appliedTexture = texture + + manager.dispose() + + expect(textureDispose).toHaveBeenCalled() + expect(manager.appliedTexture).toBeNull() + }) + }) + + describe('createSTLMaterial', () => { + it('returns a MeshStandardMaterial with expected properties', () => { + const { manager } = createManager() + const mat = manager.createSTLMaterial() + + expect(mat).toBeInstanceOf(THREE.MeshStandardMaterial) + expect(mat.color.getHex()).toBe(0x808080) + expect(mat.metalness).toBe(0.1) + expect(mat.roughness).toBe(0.8) + expect(mat.side).toBe(THREE.DoubleSide) + }) + }) + + describe('addModelToScene', () => { + it('adds the model to the scene and sets currentModel', () => { + const { manager, scene } = createManager() + const model = createMeshModel() + + manager.addModelToScene(model) + + expect(manager.currentModel).toBe(model) + expect(model.name).toBe('MainModel') + expect(scene.children).toContain(model) + }) + }) + + describe('setupModel', () => { + it('scales and positions the model, then adds to scene', async () => { + const { manager, scene, setupCamera } = createManager() + const model = createMeshModel() + + await manager.setupModel(model) + + expect(manager.currentModel).toBe(model) + expect(model.name).toBe('MainModel') + expect(scene.children).toContain(model) + expect(setupCamera).toHaveBeenCalled() + }) + + it('does not skip materialMode when it differs from original', async () => { + const { manager } = createManager() + const model = createMeshModel() + + // setupModel checks materialMode !== 'original' and calls + // setMaterialMode, but the guard `mode === this.materialMode` + // causes it to no-op. Then setupModelMaterials resets to 'original'. + manager.materialMode = 'wireframe' + const spy = vi.spyOn(manager, 'setMaterialMode') + await manager.setupModel(model) + + // setMaterialMode is called with the stored mode and then 'original' + expect(spy).toHaveBeenCalledWith('wireframe') + expect(spy).toHaveBeenCalledWith('original') + }) + + it('applies current up direction if not original', async () => { + const { manager, eventManager } = createManager() + const model = createMeshModel() + + manager.currentUpDirection = '+z' + await manager.setupModel(model) + + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'upDirectionChange', + '+z' + ) + }) + }) + + describe('setOriginalModel', () => { + it('stores the original model reference', () => { + const { manager } = createManager() + const model = new THREE.Group() + + manager.setOriginalModel(model) + + expect(manager.originalModel).toBe(model) + }) + }) + + describe('clearModel', () => { + it('removes non-environment objects from scene', async () => { + const { manager, scene } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + const light = new THREE.DirectionalLight() + scene.add(light) + + manager.clearModel() + + expect(manager.currentModel).toBeNull() + expect(scene.children).toContain(light) + }) + + it('disposes mesh geometry and materials', async () => { + const { manager } = createManager() + const model = createMeshModel() + const mesh = model.children[0] as THREE.Mesh + const geoDispose = vi.spyOn(mesh.geometry, 'dispose') + const matDispose = vi.spyOn(mesh.material as THREE.Material, 'dispose') + + await manager.setupModel(model) + manager.clearModel() + + expect(geoDispose).toHaveBeenCalled() + expect(matDispose).toHaveBeenCalled() + }) + }) + + describe('reset', () => { + it('resets all state to defaults', async () => { + const { manager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + manager.originalFileName = 'test.glb' + manager.originalURL = 'http://example.com/test.glb' + manager.originalModel = model + + manager.reset() + + expect(manager.currentModel).toBeNull() + expect(manager.originalModel).toBeNull() + expect(manager.originalRotation).toBeNull() + expect(manager.currentUpDirection).toBe('original') + expect(manager.originalFileName).toBeNull() + expect(manager.originalURL).toBeNull() + }) + + it('disposes applied texture', () => { + const { manager } = createManager() + const texture = new THREE.Texture() + const textureDispose = vi.spyOn(texture, 'dispose') + manager.appliedTexture = texture + + manager.reset() + + expect(textureDispose).toHaveBeenCalled() + expect(manager.appliedTexture).toBeNull() + }) + + it('removes and disposes skeleton helper', async () => { + const { manager, scene } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + const mockHelper = new THREE.SkeletonHelper(model) + const helperDispose = vi.spyOn(mockHelper, 'dispose') + manager.skeletonHelper = mockHelper + scene.add(mockHelper) + + manager.reset() + + expect(helperDispose).toHaveBeenCalled() + expect(manager.skeletonHelper).toBeNull() + expect(manager.showSkeleton).toBe(false) + }) + }) + + describe('setMaterialMode', () => { + it('does nothing when no current model', () => { + const { manager, eventManager } = createManager() + + manager.setMaterialMode('normal') + + expect(eventManager.emitEvent).not.toHaveBeenCalled() + }) + + it('does nothing when mode is unchanged', async () => { + const { manager, eventManager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + vi.mocked(eventManager.emitEvent).mockClear() + + manager.setMaterialMode('original') + + expect(eventManager.emitEvent).not.toHaveBeenCalled() + }) + + it('switches to normal material', async () => { + const { manager, eventManager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.setMaterialMode('normal') + + const mesh = model.children[0] as THREE.Mesh + expect(mesh.material).toBeInstanceOf(THREE.MeshNormalMaterial) + expect(manager.materialMode).toBe('normal') + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'materialModeChange', + 'normal' + ) + }) + + it('switches to wireframe material', async () => { + const { manager, eventManager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.setMaterialMode('wireframe') + + const mesh = model.children[0] as THREE.Mesh + expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial) + expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true) + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'materialModeChange', + 'wireframe' + ) + }) + + it('switches to depth material', async () => { + const { manager, renderer } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.setMaterialMode('depth') + + const mesh = model.children[0] as THREE.Mesh + expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial) + expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) + }) + + it('restores original material when switching back', async () => { + const { manager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + const mesh = model.children[0] as THREE.Mesh + const originalMat = mesh.material + + manager.setMaterialMode('normal') + manager.setMaterialMode('original') + + expect(mesh.material).toBe(originalMat) + }) + + it('uses appliedTexture when no original material stored', async () => { + const { manager } = createManager() + const model = createMeshModel() + const texture = new THREE.Texture() + manager.appliedTexture = texture + + manager.addModelToScene(model) + manager.materialMode = 'normal' + manager.setMaterialMode('original') + + const mesh = model.children[0] as THREE.Mesh + expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial) + expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture) + }) + + it('sets renderer color space to SRGB for non-depth modes', async () => { + const { manager, renderer } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.setMaterialMode('depth') + expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace) + + manager.setMaterialMode('normal') + expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace) + }) + + it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => { + const { manager, eventManager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.originalModel = new THREE.BufferGeometry() + ;(manager.originalModel as THREE.BufferGeometry).setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 2, 2], 3) + ) + + manager.setMaterialMode('wireframe') + + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'materialModeChange', + 'wireframe' + ) + }) + }) + + describe('setupModelMaterials', () => { + it('stores original materials in the WeakMap', () => { + const { manager } = createManager() + const model = createMeshModel() + const mesh = model.children[0] as THREE.Mesh + const originalMat = mesh.material + + manager.currentModel = model + manager.setupModelMaterials(model) + + expect(manager.originalMaterials.get(mesh)).toBe(originalMat) + }) + }) + + describe('setUpDirection', () => { + it('does nothing when no current model', () => { + const { manager, eventManager } = createManager() + + manager.setUpDirection('+x') + + expect(eventManager.emitEvent).not.toHaveBeenCalled() + }) + + it('stores the original rotation on first call', async () => { + const { manager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.setUpDirection('+x') + + expect(manager.originalRotation).not.toBeNull() + }) + + it('applies correct rotation for each direction', async () => { + const { manager, eventManager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + const directions: Array<{ + dir: '-x' | '+x' | '-y' | '+y' | '-z' | '+z' + axis: 'x' | 'z' + value: number + }> = [ + { dir: '-x', axis: 'z', value: Math.PI / 2 }, + { dir: '+x', axis: 'z', value: -Math.PI / 2 }, + { dir: '-y', axis: 'x', value: Math.PI }, + { dir: '-z', axis: 'x', value: Math.PI / 2 }, + { dir: '+z', axis: 'x', value: -Math.PI / 2 } + ] + + for (const { dir, axis, value } of directions) { + manager.setUpDirection(dir) + expect(model.rotation[axis]).toBeCloseTo(value) + expect(manager.currentUpDirection).toBe(dir) + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'upDirectionChange', + dir + ) + } + }) + + it('restores original rotation before applying new direction', async () => { + const { manager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.setUpDirection('+x') + const zAfterX = model.rotation.z + + manager.setUpDirection('-z') + expect(model.rotation.x).toBeCloseTo(Math.PI / 2) + expect(model.rotation.z).not.toBeCloseTo(zAfterX) + }) + + it('emits upDirectionChange event', async () => { + const { manager, eventManager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + manager.setUpDirection('original') + + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'upDirectionChange', + 'original' + ) + }) + }) + + describe('hasSkeleton', () => { + it('returns false when no current model', () => { + const { manager } = createManager() + expect(manager.hasSkeleton()).toBe(false) + }) + + it('returns false for model without skeleton', async () => { + const { manager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + expect(manager.hasSkeleton()).toBe(false) + }) + + it('returns true for model with SkinnedMesh', () => { + const { manager } = createManager() + const group = new THREE.Group() + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshStandardMaterial() + const bones = [new THREE.Bone(), new THREE.Bone()] + bones[0].add(bones[1]) + const skeleton = new THREE.Skeleton(bones) + const skinnedMesh = new THREE.SkinnedMesh(geometry, material) + skinnedMesh.add(bones[0]) + skinnedMesh.bind(skeleton) + group.add(skinnedMesh) + + manager.currentModel = group + + expect(manager.hasSkeleton()).toBe(true) + }) + }) + + describe('setShowSkeleton', () => { + it('sets showSkeleton flag', () => { + const { manager } = createManager() + manager.setShowSkeleton(true) + expect(manager.showSkeleton).toBe(true) + }) + + it('emits skeletonVisibilityChange event', () => { + const { manager, eventManager } = createManager() + + manager.setShowSkeleton(true) + + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'skeletonVisibilityChange', + true + ) + }) + + it('hides existing skeleton helper when set to false', async () => { + const { manager, scene } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + const helper = new THREE.SkeletonHelper(model) + manager.skeletonHelper = helper + scene.add(helper) + + manager.setShowSkeleton(false) + + expect(helper.visible).toBe(false) + }) + + it('shows existing skeleton helper when set to true', async () => { + const { manager, scene } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + const helper = new THREE.SkeletonHelper(model) + helper.visible = false + manager.skeletonHelper = helper + scene.add(helper) + + manager.setShowSkeleton(true) + + expect(helper.visible).toBe(true) + }) + }) + + describe('containsSplatMesh', () => { + it('returns false when no model', () => { + const { manager } = createManager() + expect(manager.containsSplatMesh()).toBe(false) + }) + + it('returns false for regular model', async () => { + const { manager } = createManager() + const model = createMeshModel() + await manager.setupModel(model) + + expect(manager.containsSplatMesh()).toBe(false) + }) + + it('returns false for explicit null argument', () => { + const { manager } = createManager() + expect(manager.containsSplatMesh(null)).toBe(false) + }) + }) + + describe('PLY mode switching', () => { + function createPLYManager() { + const ctx = createManager() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3) + ) + + const mesh = new THREE.Mesh( + geometry.clone(), + ctx.manager.standardMaterial.clone() + ) + const group = new THREE.Group() + group.name = 'MainModel' + group.add(mesh) + ctx.scene.add(group) + + ctx.manager.currentModel = group + ctx.manager.originalModel = geometry + + return ctx + } + + it('recreates model as point cloud', () => { + const { manager, scene, eventManager } = createPLYManager() + + manager.setMaterialMode('pointCloud') + + const mainModel = scene.children.find((c) => c.name === 'MainModel') + expect(mainModel).toBeDefined() + const points = mainModel!.children.find((c) => c instanceof THREE.Points) + expect(points).toBeInstanceOf(THREE.Points) + expect(eventManager.emitEvent).toHaveBeenCalledWith( + 'materialModeChange', + 'pointCloud' + ) + }) + + it('recreates model as wireframe mesh', () => { + const { manager, scene } = createPLYManager() + + manager.setMaterialMode('wireframe') + + const mainModel = scene.children.find((c) => c.name === 'MainModel') + expect(mainModel).toBeDefined() + + let foundWireframe = false + mainModel!.traverse((child) => { + if ( + child instanceof THREE.Mesh && + child.material instanceof THREE.MeshBasicMaterial + ) { + foundWireframe = child.material.wireframe + } + }) + expect(foundWireframe).toBe(true) + }) + + it('uses vertex colors when available', () => { + const { manager, scene } = createManager() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3) + ) + geometry.setAttribute( + 'color', + new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3) + ) + + const mesh = new THREE.Mesh( + geometry.clone(), + new THREE.MeshBasicMaterial() + ) + const group = new THREE.Group() + group.name = 'MainModel' + group.add(mesh) + scene.add(group) + + manager.currentModel = group + manager.originalModel = geometry + + manager.setMaterialMode('pointCloud') + + const mainModel = scene.children.find((c) => c.name === 'MainModel') + const points = mainModel!.children.find( + (c) => c instanceof THREE.Points + ) as THREE.Points + expect((points.material as THREE.PointsMaterial).vertexColors).toBe(true) + }) + + it('removes old MainModel objects before adding new one', () => { + const { manager, scene } = createPLYManager() + + manager.setMaterialMode('wireframe') + + const mainModels = scene.children.filter((c) => c.name === 'MainModel') + expect(mainModels).toHaveLength(1) + }) + }) +}) From 8a5a8f0a6e2d4eced4814d44c036a23468d74b97 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Sun, 19 Apr 2026 18:34:08 -0700 Subject: [PATCH 020/460] docs: add hyperlinks to all supporting files in ADR 008 (#11256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary ADR 008 (Entity Component System) referenced only 3 of 10 companion architecture documents, making the rest undiscoverable to readers browsing the design. - Add inline contextual links in Context, Systems, and Migration Strategy sections so readers encounter them while reading - Add a comprehensive Supporting Documents table before Notes as a complete index of all 10 companion docs Previously unlinked files now referenced: - `entity-interactions.md` — current entity relationship map - `entity-problems.md` — structural problem catalog - `proto-ecs-stores.md` — existing stores partially implementing ECS - `ecs-target-architecture.md` — full target architecture - `ecs-migration-plan.md` — phased migration roadmap - `ecs-lifecycle-scenarios.md` — lifecycle operation walkthroughs - `appendix-critical-analysis.md` — document accuracy verification - `change-tracker.md` — current undo/redo system ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11256-docs-add-hyperlinks-to-all-supporting-files-in-ADR-008-3436d73d365081828cf9ffa77e034f2d) by [Unito](https://www.unito.io) Co-authored-by: Glary-Bot --- docs/adr/0008-entity-component-system.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/adr/0008-entity-component-system.md b/docs/adr/0008-entity-component-system.md index 31965e242a..42b427f0f6 100644 --- a/docs/adr/0008-entity-component-system.md +++ b/docs/adr/0008-entity-component-system.md @@ -26,6 +26,8 @@ An Entity Component System (ECS) separates **identity** (entities), **data** (co - **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types) - **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management +For the full problem catalog with line-level code references, see [Entity System Structural Problems](../architecture/entity-problems.md). For a map of all current entity relationships, see [Entity Interactions](../architecture/entity-interactions.md). + ## Decision Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise. @@ -172,7 +174,7 @@ Systems are pure functions that query the World for entities with specific compo - **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout - **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing -System design is deferred to a future ADR. +System design is deferred to a future ADR. For detailed before/after walkthroughs of how lifecycle operations (node removal, link creation, subgraph nesting, etc.) transform under ECS, see [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md). ### Migration Strategy @@ -182,6 +184,8 @@ System design is deferred to a future ADR. 4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility 5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated +For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md). + ### Relationship to ADR 0003 (Command Pattern / CRDT) [ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers: @@ -231,6 +235,23 @@ Planned mitigations for the ECS render path: The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets. +## Supporting Documents + +Companion architecture documents that expand on the design in this ADR: + +| Document | Description | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline | +| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration | +| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns | +| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS | +| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria | +| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) | +| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API | +| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities | +| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents | +| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace | + ## Notes - The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data. From fc61b19cb985a60c9569b70cc9977c522d472963 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Mon, 20 Apr 2026 10:56:11 +0900 Subject: [PATCH 021/460] docs: Weekly Documentation Update (#10739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Documentation Accuracy Audit - PR Summary ## Summary Conducted a comprehensive audit of all documentation files against the current codebase. The documentation is **exceptionally well-maintained** with 99%+ accuracy. Only one minor enhancement was needed. - Added missing `pnpm dev:cloud` command to AGENTS.md - Verified all 70+ documentation files for accuracy - Confirmed all API examples, file paths, and configuration references are correct - Validated all script commands match package.json ## Changes Made ### Documentation Updates **File: `AGENTS.md`** - Added `pnpm dev:cloud` to the "Build, Test, and Development Commands" section - This command was documented in CONTRIBUTING.md but missing from AGENTS.md - Command connects dev server to cloud backend (testcloud.comfy.org) ## Audit Scope and Findings ### Areas Audited (All ✅ Verified Accurate) **Core Documentation:** - ✅ `README.md` - All extension API examples verified against source code - ✅ `AGENTS.md` - All scripts, file paths, and patterns verified - ✅ `CLAUDE.md` - References to AGENTS.md confirmed valid - ✅ `CONTRIBUTING.md` - All commands and workflows verified **Configuration Files:** - ✅ `vite.config.mts` - Exists and matches documentation - ✅ `playwright.config.ts` - Exists and matches documentation - ✅ `eslint.config.ts` - Exists and matches documentation - ✅ `.oxfmtrc.json` - Exists and matches documentation - ✅ `.oxlintrc.json` - Exists and matches documentation **Documentation Directories:** - ✅ `docs/guidance/*.md` (6 files) - All code patterns match actual implementations - ✅ `docs/testing/*.md` (5 files) - All testing patterns validated - ✅ `docs/extensions/*.md` (3 files) - Extension APIs verified - ✅ `docs/adr/*.md` (9 files) - All ADRs present and referenced correctly - ✅ `docs/architecture/*.md` (8 files) - Architecture documentation accurate - ✅ `.claude/commands/*.md` (8 files) - All skill documentation verified **README Files:** - ✅ 19 README files throughout repository verified for accuracy **Key Verifications:** 1. **Package.json Scripts** - All documented commands exist: - ✅ `pnpm dev`, `dev:electron`, `build`, `preview` - ✅ `test:unit`, `test:browser:local` - ✅ `lint`, `lint:fix`, `format`, `format:check` - ✅ `typecheck`, `storybook` 2. **File Paths** - All referenced paths verified: - ✅ `src/router.ts`, `src/i18n.ts`, `src/main.ts` - ✅ `src/locales/en/main.json` - ✅ `browser_tests/**/*.spec.ts` - ✅ All component and composable paths 3. **API Examples in README.md** - All validated against source: - ✅ `window['app'].extensionManager.dialog` (v1.6.13 API) - ✅ `app.extensionManager.registerSidebarTab` (v1.2.4 API) - ✅ `bottomPanelTabs` extension field (v1.3.22 API) - ✅ `aboutPageBadges` extension field (v1.3.34 API) - ✅ `getSelectionToolboxCommands` method (v1.10.9 API) - ✅ Settings API migration (v1.3.22) - ✅ Commands and keybindings API (v1.3.7) 4. **Code Patterns** - Documentation matches implementation: - ✅ Vue 3.5+ Composition API patterns - ✅ TypeScript strict mode usage - ✅ Tailwind 4 utility-first approach - ✅ Pinia store patterns - ✅ VueUse composables - ✅ Playwright testing patterns ## Review Notes ### Documentation Quality Assessment The ComfyUI Frontend documentation demonstrates **exceptional quality** across all categories: **Strengths:** 1. **Accuracy** - 99%+ of documented information matches current codebase 2. **Comprehensive Coverage** - All major systems documented 3. **Cross-Referencing** - Documents properly reference each other 4. **Code Examples** - All API examples are working and tested 5. **Maintenance** - Recently updated to reflect latest features 6. **Organization** - Logical structure with guidance by file type **Notable Documentation Excellence:** - `docs/guidance/playwright.md` - Exceptional detail on typed API mocks with source-of-truth table - `docs/extensions/development.md` - Clear explanation of extension shim system - `docs/testing/vitest-patterns.md` - Practical, actionable testing patterns - `README.md` - Comprehensive extension API examples with version tracking - `.agents/checks/adr-compliance.md` - Thorough architectural guardrails ### Minor Observations (Not Issues) 1. **Undocumented Scripts** - These exist but aren't in AGENTS.md (likely intentional): - `pnpm dev:no-vue` - Internal development flag - `pnpm build:desktop`, `pnpm build:cloud` - Distribution-specific builds - `pnpm knip` - Dependency analysis tool - `pnpm stylelint` - CSS linting (mentioned in workflows, not main docs) 2. **Vue Test Utils** - Minor inconsistency: - AGENTS.md says "Vue Test Utils is also accepted" - ESLint rule bans it with message "Use @testing-library/vue instead" - Recommendation: Clarify if VTU is acceptable for existing tests only 3. **Extension Examples** - All working, no changes needed: - PrimeVue icons reference still valid (primevue.org/icons) - Toast API reference accurate (primevue.org/toast) - All extension lifecycle hooks documented correctly ### What Was NOT Changed No changes were made to the following areas as they are all accurate: - README.md extension API examples - Configuration file documentation - Testing documentation patterns - Architecture decision records - Extension development guides - Vue component patterns - TypeScript guidelines - Git conventions - Security guidelines ## Statistics - **Total Files Audited:** 70+ markdown files - **Critical Path Verifications:** 25+ items - **Script Command Verifications:** 15+ commands - **Configuration Files Checked:** 6 files - **API Example Validations:** 10+ examples - **Cross-Reference Validations:** 20+ references - **Files Modified:** 1 (AGENTS.md) - **Lines Added:** 1 - **Issues Found:** 0 critical, 0 high, 0 medium ## Conclusion The documentation is in **excellent condition** and remains highly accurate. This audit confirms that the ComfyUI Frontend team maintains documentation as a first-class citizen alongside code. The single enhancement (adding `pnpm dev:cloud`) improves discoverability of an existing command that was already documented elsewhere. **Recommendation:** This is a model example of documentation quality for other projects to follow. Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: Christian Byrne --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 9463e5357d..a9a77423cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g ## Build, Test, and Development Commands - `pnpm dev`: Start Vite dev server. +- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org) - `pnpm dev:electron`: Dev server with Electron API mocks - `pnpm build`: Type-check then production build to `dist/` - `pnpm preview`: Preview the production build locally From d2e30645feeddb9b2fce0f4a1471e81af32b87fe Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 19 Apr 2026 19:01:35 -0700 Subject: [PATCH 022/460] [chore] Update Ingest API types from cloud@9b9da80 (#11126) ## Automated Ingest API Type Update This PR updates the Ingest API TypeScript types and Zod schemas from the latest cloud OpenAPI specification. - Cloud commit: 9b9da80 - Generated using @hey-api/openapi-ts with Zod plugin These types cover cloud-only endpoints (workspaces, billing, secrets, assets, tasks, etc.). Overlapping endpoints shared with the local ComfyUI Python backend are excluded. --------- Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com> Co-authored-by: GitHub Action --- packages/ingest-types/src/index.ts | 86 ++- packages/ingest-types/src/types.gen.ts | 884 +++++++++++++++++++------ packages/ingest-types/src/zod.gen.ts | 353 +++++++--- 3 files changed, 1040 insertions(+), 283 deletions(-) diff --git a/packages/ingest-types/src/index.ts b/packages/ingest-types/src/index.ts index d46d879be9..94fb077aa3 100644 --- a/packages/ingest-types/src/index.ts +++ b/packages/ingest-types/src/index.ts @@ -28,6 +28,7 @@ export type { BillingPlansResponse, BillingStatus, BillingStatusResponse, + BindingErrorResponse, CancelSubscriptionData, CancelSubscriptionError, CancelSubscriptionErrors, @@ -44,11 +45,6 @@ export type { CheckHubUsernameErrors, CheckHubUsernameResponse, CheckHubUsernameResponses, - ClaimInviteCodeData, - ClaimInviteCodeError, - ClaimInviteCodeErrors, - ClaimInviteCodeResponse, - ClaimInviteCodeResponses, ClientOptions, CreateAssetDownloadData, CreateAssetDownloadError, @@ -113,6 +109,13 @@ export type { CreateWorkflowVersionRequest, CreateWorkflowVersionResponse, CreateWorkflowVersionResponses, + CreateWorkspaceApiKeyData, + CreateWorkspaceApiKeyError, + CreateWorkspaceApiKeyErrors, + CreateWorkspaceApiKeyRequest, + CreateWorkspaceApiKeyResponse, + CreateWorkspaceApiKeyResponse2, + CreateWorkspaceApiKeyResponses, CreateWorkspaceData, CreateWorkspaceError, CreateWorkspaceErrors, @@ -237,12 +240,16 @@ export type { GetBillingStatusErrors, GetBillingStatusResponse, GetBillingStatusResponses, + GetCustomNodeProxyData, + GetCustomNodeProxyErrors, + GetCustomNodeProxyResponses, GetDeletionRequestData, GetDeletionRequestError, GetDeletionRequestErrors, GetDeletionRequestResponse, GetDeletionRequestResponses, GetExtensionsData, + GetExtensionsResponse, GetExtensionsResponses, GetFeaturesData, GetFeaturesResponse, @@ -263,7 +270,9 @@ export type { GetGlobalSubgraphsResponse, GetGlobalSubgraphsResponses, GetHealthData, + GetHealthError, GetHealthErrors, + GetHealthResponse, GetHealthResponses, GetHistoryData, GetHistoryError, @@ -285,11 +294,6 @@ export type { GetHubWorkflowErrors, GetHubWorkflowResponse, GetHubWorkflowResponses, - GetInviteCodeStatusData, - GetInviteCodeStatusError, - GetInviteCodeStatusErrors, - GetInviteCodeStatusResponse, - GetInviteCodeStatusResponses, GetJobDetailData, GetJobDetailError, GetJobDetailErrors, @@ -339,9 +343,19 @@ export type { GetMyHubProfileErrors, GetMyHubProfileResponse, GetMyHubProfileResponses, + GetNodeByIdData, + GetNodeByIdErrors, + GetNodeByIdResponses, GetNodeInfoData, GetNodeInfoResponse, GetNodeInfoResponses, + GetNodeInfoSchemaData, + GetNodeInfoSchemaResponses, + GetNodeReplacementsData, + GetNodeReplacementsError, + GetNodeReplacementsErrors, + GetNodeReplacementsResponse, + GetNodeReplacementsResponses, GetOpenapiSpecData, GetOpenapiSpecResponses, GetPaymentPortalData, @@ -422,11 +436,15 @@ export type { GetUserErrors, GetUserResponse, GetUserResponses, - GetUsersRawData, - GetUsersRawErrors, - GetUsersRawResponses, + GetUsersInfoData, + GetUsersInfoError, + GetUsersInfoErrors, + GetUsersInfoResponse, + GetUsersInfoResponses, GetVhsQueryVideoData, + GetVhsQueryVideoError, GetVhsQueryVideoErrors, + GetVhsQueryVideoResponse, GetVhsQueryVideoResponses, GetVhsViewAudioData, GetVhsViewAudioErrors, @@ -487,8 +505,6 @@ export type { InterruptJobError, InterruptJobErrors, InterruptJobResponses, - InviteCodeClaimResponse, - InviteCodeStatusResponse, JobDetailResponse, JobEntry, JobsListResponse, @@ -551,6 +567,12 @@ export type { ListWorkflowsErrors, ListWorkflowsResponse, ListWorkflowsResponses, + ListWorkspaceApiKeysData, + ListWorkspaceApiKeysError, + ListWorkspaceApiKeysErrors, + ListWorkspaceApiKeysResponse, + ListWorkspaceApiKeysResponse2, + ListWorkspaceApiKeysResponses, ListWorkspaceInvitesData, ListWorkspaceInvitesError, ListWorkspaceInvitesErrors, @@ -601,6 +623,9 @@ export type { PostAssetsFromWorkflowErrors, PostAssetsFromWorkflowResponse, PostAssetsFromWorkflowResponses, + PostCustomNodeProxyData, + PostCustomNodeProxyErrors, + PostCustomNodeProxyResponses, PostMonitoringTasksSubpathData, PostMonitoringTasksSubpathErrors, PostMonitoringTasksSubpathResponses, @@ -661,6 +686,11 @@ export type { ResubscribeResponse, ResubscribeResponse2, ResubscribeResponses, + RevokeWorkspaceApiKeyData, + RevokeWorkspaceApiKeyError, + RevokeWorkspaceApiKeyErrors, + RevokeWorkspaceApiKeyResponse, + RevokeWorkspaceApiKeyResponses, RevokeWorkspaceInviteData, RevokeWorkspaceInviteError, RevokeWorkspaceInviteErrors, @@ -668,13 +698,6 @@ export type { RevokeWorkspaceInviteResponses, SecretListResponse, SecretResponse, - SendUserInviteEmailData, - SendUserInviteEmailError, - SendUserInviteEmailErrors, - SendUserInviteEmailRequest, - SendUserInviteEmailResponse, - SendUserInviteEmailResponse2, - SendUserInviteEmailResponses, SetReviewStatusData, SetReviewStatusError, SetReviewStatusErrors, @@ -718,6 +741,12 @@ export type { UpdateHubProfileRequest, UpdateHubProfileResponse, UpdateHubProfileResponses, + UpdateHubWorkflowData, + UpdateHubWorkflowError, + UpdateHubWorkflowErrors, + UpdateHubWorkflowRequest, + UpdateHubWorkflowResponse, + UpdateHubWorkflowResponses, UpdateMultipleSettingsData, UpdateMultipleSettingsError, UpdateMultipleSettingsErrors, @@ -734,6 +763,11 @@ export type { UpdateSettingByKeyErrors, UpdateSettingByKeyResponse, UpdateSettingByKeyResponses, + UpdateSubscriptionCacheData, + UpdateSubscriptionCacheError, + UpdateSubscriptionCacheErrors, + UpdateSubscriptionCacheResponse, + UpdateSubscriptionCacheResponses, UpdateWorkflowData, UpdateWorkflowError, UpdateWorkflowErrors, @@ -765,6 +799,13 @@ export type { UserResponse, ValidationError, ValidationResult, + VerifyApiKeyRequest, + VerifyApiKeyResponse, + VerifyWorkspaceApiKeyData, + VerifyWorkspaceApiKeyError, + VerifyWorkspaceApiKeyErrors, + VerifyWorkspaceApiKeyResponse, + VerifyWorkspaceApiKeyResponses, ViewFileData, ViewFileError, ViewFileErrors, @@ -779,6 +820,7 @@ export type { WorkflowVersionContentResponse, WorkflowVersionResponse, Workspace, + WorkspaceApiKeyInfo, WorkspaceSummary, WorkspaceWithRole } from './types.gen' diff --git a/packages/ingest-types/src/types.gen.ts b/packages/ingest-types/src/types.gen.ts index ea6a24a294..7b307a84ec 100644 --- a/packages/ingest-types/src/types.gen.ts +++ b/packages/ingest-types/src/types.gen.ts @@ -50,6 +50,72 @@ export type HubAssetUploadUrlRequest = { content_type: string } +/** + * Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token: + * + * * field omitted or null — leave unchanged + * * string field = "" — clear (for clearable string fields) + * * array field = [] — clear the list + * * any other value — set to the provided value + * + * Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value". + * Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint. + * + */ +export type UpdateHubWorkflowRequest = { + /** + * Display name. Not clearable. Null/omit leaves unchanged; empty string is invalid. + */ + name?: string | null + /** + * Workflow description. Send "" to clear. Null/omit leaves unchanged. + */ + description?: string | null + /** + * Full replacement of tag slugs. Must exist in hub_labels. Send [] to clear. Null/omit leaves unchanged. + */ + tags?: Array | null + /** + * Full replacement of model slugs. Must exist in hub_labels. Send [] to clear. Null/omit leaves unchanged. + */ + models?: Array | null + /** + * Full replacement of custom_node slugs. Must exist in hub_labels. Send [] to clear. Null/omit leaves unchanged. + */ + custom_nodes?: Array | null + /** + * Tutorial URL. Send "" to clear. Null/omit leaves unchanged. + */ + tutorial_url?: string | null + /** + * Thumbnail kind. Null/omit leaves unchanged; not clearable via this endpoint. If set to image_comparison, both the thumbnail and comparison thumbnail must resolve to a value on the stored record after this update is applied (either already present and not being cleared, or supplied as a token in this request). + * + */ + thumbnail_type?: 'image' | 'video' | 'image_comparison' + /** + * Token from POST /api/hub/assets/upload-url for a newly uploaded thumbnail. Null/omit leaves the existing thumbnail unchanged. Send "" to clear. (PATCH does not accept an existing public URL here — to keep the current thumbnail, simply omit the field.) + * + */ + thumbnail_token?: string | null + /** + * Token from POST /api/hub/assets/upload-url for a newly uploaded comparison thumbnail. Null/omit leaves unchanged. Send "" to clear. (PATCH does not accept an existing public URL here — to keep the current comparison thumbnail, simply omit the field.) + * + */ + thumbnail_comparison_token?: string | null + /** + * Full replacement of sample images. Each element is either a token from /api/hub/assets/upload-url or an existing public URL. Send [] to clear. Null/omit leaves unchanged. + * + */ + sample_image_tokens_or_urls?: Array | null + /** + * Admin-only full replacement of the hub_workflow_detail.metadata JSON object. Null/omit leaves unchanged. Send {} to clear all keys. Accepts arbitrary JSON (size, vram, open_source, media_type, logos, etc.). + * + */ + metadata?: { + [key: string]: unknown + } | null +} + export type PublishHubWorkflowRequest = { /** * Username of the hub profile to publish under. The authenticated user must belong to the workspace that owns this profile. @@ -264,8 +330,26 @@ export type HubWorkflowTemplateEntry = { thumbnailVariant?: string mediaType?: string mediaSubtype?: string + /** + * Workflow asset size in bytes. + */ size?: number + /** + * Approximate VRAM requirement in bytes. + */ vram?: number + /** + * Usage count reported upstream. + */ + usage?: number + /** + * Search ranking score reported upstream. + */ + searchRank?: number + /** + * Whether the template belongs to a module marked as essential. + */ + isEssential?: boolean openSource?: boolean profile?: HubProfileSummary tutorialUrl?: string @@ -1109,6 +1193,143 @@ export type JwksResponse = { keys: Array } +export type VerifyApiKeyResponse = { + /** + * Firebase UID of the key creator + */ + user_id: string + /** + * User's email address + */ + email: string + /** + * User's display name + */ + name: string + /** + * Whether the user is an admin + */ + is_admin: boolean + /** + * Workspace ID for billing attribution + */ + workspace_id: string + /** + * Type of workspace + */ + workspace_type: 'personal' | 'team' + /** + * User's role in the workspace + */ + role: 'owner' | 'member' + /** + * Whether the workspace has available funds for usage + */ + has_funds: boolean + /** + * Whether the workspace has an active subscription + */ + is_active: boolean + /** + * Permissions granted by this key. Always includes the role permission + * (`owner:*` or `member:*`). May also include `partner-node:use`, + * which is a **staging-only shim** used to gate partner-node access + * for non-admin users during testing. No production code path checks + * this permission today; it is emitted for parity with the Cloud JWT + * claim set so JWT and API-key callers see the same permissions. + * + */ + permissions: Array +} + +export type VerifyApiKeyRequest = { + /** + * The full plaintext API key to verify + */ + api_key: string +} + +export type ListWorkspaceApiKeysResponse = { + api_keys: Array +} + +export type WorkspaceApiKeyInfo = { + /** + * API key ID + */ + id: string + /** + * Workspace this key belongs to + */ + workspace_id: string + /** + * User who created this key + */ + user_id: string + /** + * User-provided label + */ + name: string + /** + * First 8 chars after prefix for display + */ + key_prefix: string + /** + * When the key expires (if set) + */ + expires_at?: string + /** + * Last time the key was used + */ + last_used_at?: string + /** + * When the key was revoked (if revoked) + */ + revoked_at?: string + /** + * When the key was created + */ + created_at: string +} + +export type CreateWorkspaceApiKeyResponse = { + /** + * API key ID + */ + id: string + /** + * User-provided label + */ + name: string + /** + * The full plaintext API key (only shown once) + */ + key: string + /** + * First 8 chars after prefix for display + */ + key_prefix: string + /** + * When the key expires (if set) + */ + expires_at?: string + /** + * When the key was created + */ + created_at: string +} + +export type CreateWorkspaceApiKeyRequest = { + /** + * User-provided label for the key + */ + name: string + /** + * Optional expiration timestamp + */ + expires_at?: string +} + export type AcceptInviteResponse = { /** * ID of the workspace joined @@ -1434,6 +1655,9 @@ export type JobDetailResponse = { workflow?: { [key: string]: unknown } + /** + * Detailed execution error from ComfyUI (only for failed jobs with structured error data) + */ execution_error?: ExecutionError /** * Job creation timestamp (Unix timestamp in milliseconds) @@ -1489,6 +1713,9 @@ export type JobEntry = { * User-friendly job status */ status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled' + /** + * Detailed execution error from ComfyUI (only for failed jobs with structured error data) + */ execution_error?: ExecutionError /** * Job creation timestamp (Unix timestamp in milliseconds) @@ -1619,6 +1846,9 @@ export type AssetMetadataResponse = { * Preview image as base64-encoded data URL */ preview_image?: string + /** + * Validation results for the file + */ validation?: ValidationResult } @@ -1779,34 +2009,6 @@ export type AssetCreated = Asset & { created_new: boolean } -/** - * Response after sending an invite email - */ -export type SendUserInviteEmailResponse = { - /** - * Whether the email was sent successfully - */ - success: boolean - /** - * A message describing the result - */ - message: string -} - -/** - * Request to send an invite email to a user - */ -export type SendUserInviteEmailRequest = { - /** - * The email address to send the invitation to - */ - email: string - /** - * Whether to force send the invite even if user already exists or has been invited - */ - force?: boolean -} - export type SetReviewStatusResponse = { /** * The share IDs that were submitted for review @@ -1829,34 +2031,6 @@ export type SetReviewStatusRequest = { status: 'approved' | 'rejected' } -/** - * Response after successfully claiming an invite code - */ -export type InviteCodeClaimResponse = { - /** - * Whether the claim was successful - */ - success: boolean - /** - * Success message - */ - message: string -} - -/** - * Invite code status response - */ -export type InviteCodeStatusResponse = { - /** - * Whether the code has been claimed - */ - claimed: boolean - /** - * Whether the code has expired - */ - expired: boolean -} - /** * Response after deleting a session cookie */ @@ -1886,7 +2060,11 @@ export type CreateSessionResponse = { */ export type UserResponse = { /** - * User status (active or waitlisted) + * Firebase UID of the authenticated user + */ + id: string + /** + * User status (always "active" for authenticated users) */ status: string } @@ -2167,11 +2345,11 @@ export type QueueInfo = { /** * Array of currently running job items */ - queue_running?: Array> + queue_running?: Array<[unknown, unknown, unknown, unknown, unknown]> /** * Array of pending job items (ordered by creation time, oldest first) */ - queue_pending?: Array> + queue_pending?: Array<[unknown, unknown, unknown, unknown, unknown]> } /** @@ -2445,6 +2623,10 @@ export type ExportDownloadUrlResponse = { expires_at?: string } +export type BindingErrorResponse = { + message: string +} + export type ErrorResponse = { code: string message: string @@ -2710,6 +2892,35 @@ export type GetFeaturesResponses = { export type GetFeaturesResponse = GetFeaturesResponses[keyof GetFeaturesResponses] +export type GetNodeReplacementsData = { + body?: never + path?: never + query?: never + url: '/api/node_replacements' +} + +export type GetNodeReplacementsErrors = { + /** + * Internal server error + */ + 500: ErrorResponse +} + +export type GetNodeReplacementsError = + GetNodeReplacementsErrors[keyof GetNodeReplacementsErrors] + +export type GetNodeReplacementsResponses = { + /** + * Success - Node replacement mappings + */ + 200: { + [key: string]: unknown + } +} + +export type GetNodeReplacementsResponse = + GetNodeReplacementsResponses[keyof GetNodeReplacementsResponses] + export type GetWorkflowTemplatesData = { body?: never path?: never @@ -3185,7 +3396,7 @@ export type ViewFileError = ViewFileErrors[keyof ViewFileErrors] export type ViewFileResponses = { /** - * Success - File content returned (used when channel or res parameter is present) + * Processed PNG image with extracted channel */ 200: Blob | File } @@ -5919,6 +6130,168 @@ export type RemoveWorkspaceMemberResponses = { export type RemoveWorkspaceMemberResponse = RemoveWorkspaceMemberResponses[keyof RemoveWorkspaceMemberResponses] +export type ListWorkspaceApiKeysData = { + body?: never + path?: never + query?: never + url: '/api/workspace/api-keys' +} + +export type ListWorkspaceApiKeysErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse + /** + * Forbidden + */ + 403: ErrorResponse + /** + * Internal server error + */ + 500: ErrorResponse +} + +export type ListWorkspaceApiKeysError = + ListWorkspaceApiKeysErrors[keyof ListWorkspaceApiKeysErrors] + +export type ListWorkspaceApiKeysResponses = { + /** + * List of API keys + */ + 200: ListWorkspaceApiKeysResponse +} + +export type ListWorkspaceApiKeysResponse2 = + ListWorkspaceApiKeysResponses[keyof ListWorkspaceApiKeysResponses] + +export type CreateWorkspaceApiKeyData = { + body: CreateWorkspaceApiKeyRequest + path?: never + query?: never + url: '/api/workspace/api-keys' +} + +export type CreateWorkspaceApiKeyErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse + /** + * Not a workspace member or personal workspace + */ + 403: ErrorResponse + /** + * Workspace not found + */ + 404: ErrorResponse + /** + * Validation error + */ + 422: ErrorResponse + /** + * Key limit reached + */ + 429: ErrorResponse + /** + * Internal server error + */ + 500: ErrorResponse +} + +export type CreateWorkspaceApiKeyError = + CreateWorkspaceApiKeyErrors[keyof CreateWorkspaceApiKeyErrors] + +export type CreateWorkspaceApiKeyResponses = { + /** + * API key created (plaintext returned once) + */ + 201: CreateWorkspaceApiKeyResponse +} + +export type CreateWorkspaceApiKeyResponse2 = + CreateWorkspaceApiKeyResponses[keyof CreateWorkspaceApiKeyResponses] + +export type RevokeWorkspaceApiKeyData = { + body?: never + path: { + /** + * API key ID to revoke + */ + id: string + } + query?: never + url: '/api/workspace/api-keys/{id}' +} + +export type RevokeWorkspaceApiKeyErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse + /** + * Not authorized to revoke this key + */ + 403: ErrorResponse + /** + * API key not found + */ + 404: ErrorResponse + /** + * Internal server error + */ + 500: ErrorResponse +} + +export type RevokeWorkspaceApiKeyError = + RevokeWorkspaceApiKeyErrors[keyof RevokeWorkspaceApiKeyErrors] + +export type RevokeWorkspaceApiKeyResponses = { + /** + * API key revoked + */ + 204: void +} + +export type RevokeWorkspaceApiKeyResponse = + RevokeWorkspaceApiKeyResponses[keyof RevokeWorkspaceApiKeyResponses] + +export type VerifyWorkspaceApiKeyData = { + body: VerifyApiKeyRequest + path?: never + query?: { + /** + * When true, fetches real billing status from the billing service and populates has_funds and is_active accordingly. When false or omitted, the billing lookup is skipped and has_funds/is_active are returned as true (optimistic defaults). Use true when the caller needs to gate access based on billing (e.g. partner node auth); omit for identity-only lookups (e.g. key caching). + */ + include_billing?: boolean + } + url: '/admin/api/keys/verify' +} + +export type VerifyWorkspaceApiKeyErrors = { + /** + * Invalid key or unauthorized + */ + 401: ErrorResponse + /** + * Internal server error + */ + 500: ErrorResponse +} + +export type VerifyWorkspaceApiKeyError = + VerifyWorkspaceApiKeyErrors[keyof VerifyWorkspaceApiKeyErrors] + +export type VerifyWorkspaceApiKeyResponses = { + /** + * Key is valid + */ + 200: VerifyApiKeyResponse +} + +export type VerifyWorkspaceApiKeyResponse = + VerifyWorkspaceApiKeyResponses[keyof VerifyWorkspaceApiKeyResponses] + export type GetUserData = { body?: never path?: never @@ -5944,121 +6317,6 @@ export type GetUserResponses = { export type GetUserResponse = GetUserResponses[keyof GetUserResponses] -export type GetInviteCodeStatusData = { - body?: never - path: { - /** - * The invite code to check - */ - code: string - } - query?: never - url: '/api/invite_code/{code}/status' -} - -export type GetInviteCodeStatusErrors = { - /** - * Unauthorized - Firebase authentication required - */ - 401: ErrorResponse - /** - * Invite code not found - */ - 404: ErrorResponse -} - -export type GetInviteCodeStatusError = - GetInviteCodeStatusErrors[keyof GetInviteCodeStatusErrors] - -export type GetInviteCodeStatusResponses = { - /** - * Success - invite code exists - */ - 200: InviteCodeStatusResponse -} - -export type GetInviteCodeStatusResponse = - GetInviteCodeStatusResponses[keyof GetInviteCodeStatusResponses] - -export type ClaimInviteCodeData = { - body?: never - path: { - /** - * The invite code to claim - */ - code: string - } - query?: never - url: '/api/invite_code/{code}/claim' -} - -export type ClaimInviteCodeErrors = { - /** - * Bad request - invite code already claimed or expired - */ - 400: ErrorResponse - /** - * Unauthorized - Firebase authentication required - */ - 401: ErrorResponse - /** - * Invite code not found - */ - 404: ErrorResponse -} - -export type ClaimInviteCodeError = - ClaimInviteCodeErrors[keyof ClaimInviteCodeErrors] - -export type ClaimInviteCodeResponses = { - /** - * Success - invite code claimed successfully - */ - 200: InviteCodeClaimResponse -} - -export type ClaimInviteCodeResponse = - ClaimInviteCodeResponses[keyof ClaimInviteCodeResponses] - -export type SendUserInviteEmailData = { - body: SendUserInviteEmailRequest - path?: never - query?: never - url: '/admin/api/send_user_invite_email' -} - -export type SendUserInviteEmailErrors = { - /** - * Bad request - invalid email or parameters - */ - 400: ErrorResponse - /** - * Unauthorized - authentication required - */ - 401: ErrorResponse - /** - * Forbidden - insufficient permissions - */ - 403: ErrorResponse - /** - * Internal server error - */ - 500: ErrorResponse -} - -export type SendUserInviteEmailError = - SendUserInviteEmailErrors[keyof SendUserInviteEmailErrors] - -export type SendUserInviteEmailResponses = { - /** - * Success - invite email sent successfully - */ - 200: SendUserInviteEmailResponse -} - -export type SendUserInviteEmailResponse2 = - SendUserInviteEmailResponses[keyof SendUserInviteEmailResponses] - export type SetReviewStatusData = { body: SetReviewStatusRequest path?: never @@ -6098,6 +6356,51 @@ export type SetReviewStatusResponses = { export type SetReviewStatusResponse2 = SetReviewStatusResponses[keyof SetReviewStatusResponses] +export type UpdateHubWorkflowData = { + body: UpdateHubWorkflowRequest + path: { + share_id: string + } + query?: never + url: '/admin/api/hub/workflows/{share_id}' +} + +export type UpdateHubWorkflowErrors = { + /** + * Bad request - invalid field, unknown label slug, or invalid media token + */ + 400: ErrorResponse + /** + * Unauthorized - authentication required + */ + 401: ErrorResponse + /** + * Forbidden - insufficient permissions + */ + 403: ErrorResponse + /** + * Not found - no published workflow for the given share_id + */ + 404: ErrorResponse + /** + * Internal server error + */ + 500: ErrorResponse +} + +export type UpdateHubWorkflowError = + UpdateHubWorkflowErrors[keyof UpdateHubWorkflowErrors] + +export type UpdateHubWorkflowResponses = { + /** + * Updated hub workflow detail + */ + 200: HubWorkflowDetail +} + +export type UpdateHubWorkflowResponse = + UpdateHubWorkflowResponses[keyof UpdateHubWorkflowResponses] + export type GetDeletionRequestData = { body?: never path?: never @@ -6230,6 +6533,65 @@ export type ReportPartnerUsageResponses = { export type ReportPartnerUsageResponse = ReportPartnerUsageResponses[keyof ReportPartnerUsageResponses] +export type UpdateSubscriptionCacheData = { + body: { + /** + * Firebase UID of the user whose cache should be updated. + */ + user_id: string + /** + * Whether the user currently has an active personal subscription. + * When false, any cached entry is cleared. + * + */ + is_active: boolean + /** + * Subscription tier (e.g. `PRO`, `CREATOR`). Required when + * `is_active=true`; ignored otherwise. Unknown values are treated as a + * no-op rather than cached, so a schema drift between services cannot + * poison the cache. + * + */ + tier?: string + } + path?: never + query?: never + url: '/admin/api/update-subscription-cache' +} + +export type UpdateSubscriptionCacheErrors = { + /** + * Missing or invalid request body + */ + 400: ErrorResponse + /** + * Cache write failed (Redis unavailable, DEL/SET error, marshal error). + * Caller should retry — state on the ingest side is unchanged. + * + */ + 500: ErrorResponse +} + +export type UpdateSubscriptionCacheError = + UpdateSubscriptionCacheErrors[keyof UpdateSubscriptionCacheErrors] + +export type UpdateSubscriptionCacheResponses = { + /** + * Cache updated successfully + */ + 200: { + /** + * One of `updated` (cache entry written), `cleared` (cache entry + * removed), or `skipped` (defensive no-op for missing / unknown tier). + * + */ + status?: string + } +} + +export type UpdateSubscriptionCacheResponse = + UpdateSubscriptionCacheResponses[keyof UpdateSubscriptionCacheResponses] + export type GetJobStatusData = { body?: never path: { @@ -7612,7 +7974,50 @@ export type GetExtensionsData = { export type GetExtensionsResponses = { /** - * JSON array of extension file paths + * URL paths (relative to web root) of available extension JS files + */ + 200: Array +} + +export type GetExtensionsResponse = + GetExtensionsResponses[keyof GetExtensionsResponses] + +export type GetNodeInfoSchemaData = { + body?: never + path?: never + query?: never + url: '/api/experiment/nodes' +} + +export type GetNodeInfoSchemaResponses = { + /** + * Full node schema JSON + */ + 200: unknown +} + +export type GetNodeByIdData = { + body?: never + path: { + /** + * Node class_type identifier + */ + id: string + } + query?: never + url: '/api/experiment/nodes/{id}' +} + +export type GetNodeByIdErrors = { + /** + * Node not found + */ + 404: unknown +} + +export type GetNodeByIdResponses = { + /** + * Node definition JSON */ 200: unknown } @@ -7698,40 +8103,89 @@ export type GetVhsQueryVideoData = { } export type GetVhsQueryVideoErrors = { + /** + * Missing required query parameter. Produced by the oapi-codegen + * wrapper via echo.NewHTTPError, so the body shape matches Echo's + * default HTTPError serialization rather than ErrorResponse. + * + */ + 400: BindingErrorResponse /** * Unauthorized */ - 401: unknown + 401: ErrorResponse } +export type GetVhsQueryVideoError = + GetVhsQueryVideoErrors[keyof GetVhsQueryVideoErrors] + export type GetVhsQueryVideoResponses = { /** * Video metadata */ - 200: unknown + 200: { + /** + * Source video metadata + */ + source: { + /** + * [width, height] in pixels + */ + size: [number, number] + /** + * Frames per second + */ + fps: number + /** + * Total frame count + */ + frames: number + /** + * Duration in seconds + */ + duration: number + } + } } -export type GetUsersRawData = { +export type GetVhsQueryVideoResponse = + GetVhsQueryVideoResponses[keyof GetVhsQueryVideoResponses] + +export type GetUsersInfoData = { body?: never path?: never query?: never url: '/api/users' } -export type GetUsersRawErrors = { +export type GetUsersInfoErrors = { /** * Unauthorized */ - 401: unknown + 401: ErrorResponse } -export type GetUsersRawResponses = { +export type GetUsersInfoError = GetUsersInfoErrors[keyof GetUsersInfoErrors] + +export type GetUsersInfoResponses = { /** - * User list + * Userdata storage information */ - 200: unknown + 200: { + /** + * Where user data is stored (always "server" in cloud) + */ + storage: string + /** + * Whether user data has been migrated (always true in cloud) + */ + migrated: boolean + } } +export type GetUsersInfoResponse = + GetUsersInfoResponses[keyof GetUsersInfoResponses] + export type GetApiViewVideoAliasData = { body?: never path?: never @@ -7833,16 +8287,20 @@ export type GetHealthErrors = { /** * Service is unhealthy */ - 503: unknown + 503: string } +export type GetHealthError = GetHealthErrors[keyof GetHealthErrors] + export type GetHealthResponses = { /** * Service is healthy */ - 200: unknown + 200: string } +export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses] + export type GetOpenapiSpecData = { body?: never path?: never @@ -8043,3 +8501,57 @@ export type GetStaticExtensionsResponses = { */ 200: unknown } + +export type GetCustomNodeProxyData = { + body?: never + path: { + path: string + } + query?: never + url: '/__custom_node_proxy/{path}' +} + +export type GetCustomNodeProxyErrors = { + /** + * Unauthorized + */ + 401: unknown + /** + * Path not in allowlist + */ + 403: unknown +} + +export type GetCustomNodeProxyResponses = { + /** + * Proxied response + */ + 200: unknown +} + +export type PostCustomNodeProxyData = { + body?: never + path: { + path: string + } + query?: never + url: '/__custom_node_proxy/{path}' +} + +export type PostCustomNodeProxyErrors = { + /** + * Unauthorized + */ + 401: unknown + /** + * Path not in allowlist + */ + 403: unknown +} + +export type PostCustomNodeProxyResponses = { + /** + * Proxied response + */ + 200: unknown +} diff --git a/packages/ingest-types/src/zod.gen.ts b/packages/ingest-types/src/zod.gen.ts index f6f4ecc16b..20788c35ac 100644 --- a/packages/ingest-types/src/zod.gen.ts +++ b/packages/ingest-types/src/zod.gen.ts @@ -20,6 +20,32 @@ export const zHubAssetUploadUrlRequest = z.object({ content_type: z.string() }) +/** + * Partial update for a published hub workflow (admin moderation). All fields are optional. Semantics match UpdateHubProfileRequest / avatar_token: + * + * * field omitted or null — leave unchanged + * * string field = "" — clear (for clearable string fields) + * * array field = [] — clear the list + * * any other value — set to the provided value + * + * Array fields use full-replacement (PUT) semantics when a value is supplied. The two single-value thumbnail token fields accept only upload tokens (not existing URLs) since omitting them already expresses "keep the current value". + * Backend note: cleared string columns are persisted as the empty string "" in the Ent schema (description, thumbnail_url, thumbnail_comparison_url, tutorial_url). thumbnail_type is the only true SQL-nullable column but is not clearable via this endpoint. + * + */ +export const zUpdateHubWorkflowRequest = z.object({ + name: z.string().min(1).nullish(), + description: z.string().nullish(), + tags: z.array(z.string()).nullish(), + models: z.array(z.string()).nullish(), + custom_nodes: z.array(z.string()).nullish(), + tutorial_url: z.string().nullish(), + thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(), + thumbnail_token: z.string().nullish(), + thumbnail_comparison_token: z.string().nullish(), + sample_image_tokens_or_urls: z.array(z.string()).nullish(), + metadata: z.record(z.unknown()).nullish() +}) + export const zPublishHubWorkflowRequest = z.object({ username: z.string(), name: z.string(), @@ -134,8 +160,43 @@ export const zHubWorkflowTemplateEntry = z.object({ thumbnailVariant: z.string().optional(), mediaType: z.string().optional(), mediaSubtype: z.string().optional(), - size: z.number().optional(), - vram: z.number().optional(), + size: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + message: 'Invalid value: Expected int64 to be >= -9223372036854775808' + }) + .max(BigInt('9223372036854775807'), { + message: 'Invalid value: Expected int64 to be <= 9223372036854775807' + }) + .optional(), + vram: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + message: 'Invalid value: Expected int64 to be >= -9223372036854775808' + }) + .max(BigInt('9223372036854775807'), { + message: 'Invalid value: Expected int64 to be <= 9223372036854775807' + }) + .optional(), + usage: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + message: 'Invalid value: Expected int64 to be >= -9223372036854775808' + }) + .max(BigInt('9223372036854775807'), { + message: 'Invalid value: Expected int64 to be <= 9223372036854775807' + }) + .optional(), + searchRank: z.coerce + .bigint() + .min(BigInt('-9223372036854775808'), { + message: 'Invalid value: Expected int64 to be >= -9223372036854775808' + }) + .max(BigInt('9223372036854775807'), { + message: 'Invalid value: Expected int64 to be <= 9223372036854775807' + }) + .optional(), + isEssential: z.boolean().optional(), openSource: z.boolean().optional(), profile: zHubProfileSummary.optional(), tutorialUrl: z.string().optional(), @@ -641,6 +702,53 @@ export const zJwksResponse = z.object({ keys: z.array(zJwkKey) }) +export const zVerifyApiKeyResponse = z.object({ + user_id: z.string(), + email: z.string(), + name: z.string(), + is_admin: z.boolean(), + workspace_id: z.string(), + workspace_type: z.enum(['personal', 'team']), + role: z.enum(['owner', 'member']), + has_funds: z.boolean(), + is_active: z.boolean(), + permissions: z.array(z.string()) +}) + +export const zVerifyApiKeyRequest = z.object({ + api_key: z.string() +}) + +export const zWorkspaceApiKeyInfo = z.object({ + id: z.string().uuid(), + workspace_id: z.string(), + user_id: z.string(), + name: z.string(), + key_prefix: z.string(), + expires_at: z.string().datetime().optional(), + last_used_at: z.string().datetime().optional(), + revoked_at: z.string().datetime().optional(), + created_at: z.string().datetime() +}) + +export const zListWorkspaceApiKeysResponse = z.object({ + api_keys: z.array(zWorkspaceApiKeyInfo) +}) + +export const zCreateWorkspaceApiKeyResponse = z.object({ + id: z.string().uuid(), + name: z.string(), + key: z.string(), + key_prefix: z.string(), + expires_at: z.string().datetime().optional(), + created_at: z.string().datetime() +}) + +export const zCreateWorkspaceApiKeyRequest = z.object({ + name: z.string(), + expires_at: z.string().datetime().optional() +}) + export const zAcceptInviteResponse = z.object({ workspace_id: z.string(), workspace_name: z.string() @@ -979,22 +1087,6 @@ export const zAssetCreated = zAsset.and( }) ) -/** - * Response after sending an invite email - */ -export const zSendUserInviteEmailResponse = z.object({ - success: z.boolean(), - message: z.string() -}) - -/** - * Request to send an invite email to a user - */ -export const zSendUserInviteEmailRequest = z.object({ - email: z.string(), - force: z.boolean().optional().default(false) -}) - export const zSetReviewStatusResponse = z.object({ share_ids: z.array(z.string()), status: z.enum(['approved', 'rejected']) @@ -1005,22 +1097,6 @@ export const zSetReviewStatusRequest = z.object({ status: z.enum(['approved', 'rejected']) }) -/** - * Response after successfully claiming an invite code - */ -export const zInviteCodeClaimResponse = z.object({ - success: z.boolean(), - message: z.string() -}) - -/** - * Invite code status response - */ -export const zInviteCodeStatusResponse = z.object({ - claimed: z.boolean(), - expired: z.boolean() -}) - /** * Response after deleting a session cookie */ @@ -1040,6 +1116,7 @@ export const zCreateSessionResponse = z.object({ * User information response */ export const zUserResponse = z.object({ + id: z.string(), status: z.string() }) @@ -1194,8 +1271,16 @@ export const zQueueManageRequest = z.object({ * Queue information with pending and running jobs */ export const zQueueInfo = z.object({ - queue_running: z.array(z.array(z.unknown())).optional(), - queue_pending: z.array(z.array(z.unknown())).optional() + queue_running: z + .array( + z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()]) + ) + .optional(), + queue_pending: z + .array( + z.tuple([z.unknown(), z.unknown(), z.unknown(), z.unknown(), z.unknown()]) + ) + .optional() }) /** @@ -1315,6 +1400,10 @@ export const zExportDownloadUrlResponse = z.object({ expires_at: z.string().datetime().optional() }) +export const zBindingErrorResponse = z.object({ + message: z.string() +}) + export const zErrorResponse = z.object({ code: z.string(), message: z.string() @@ -1427,6 +1516,17 @@ export const zGetFeaturesResponse = z.object({ max_upload_size: z.number().int().optional() }) +export const zGetNodeReplacementsData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional() +}) + +/** + * Success - Node replacement mappings + */ +export const zGetNodeReplacementsResponse = z.record(z.unknown()) + export const zGetWorkflowTemplatesData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -1588,7 +1688,7 @@ export const zViewFileData = z.object({ }) /** - * Success - File content returned (used when channel or res parameter is present) + * Processed PNG image with extracted channel */ export const zViewFileResponse = z.string() @@ -2429,6 +2529,56 @@ export const zRemoveWorkspaceMemberData = z.object({ */ export const zRemoveWorkspaceMemberResponse = z.void() +export const zListWorkspaceApiKeysData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional() +}) + +/** + * List of API keys + */ +export const zListWorkspaceApiKeysResponse2 = zListWorkspaceApiKeysResponse + +export const zCreateWorkspaceApiKeyData = z.object({ + body: zCreateWorkspaceApiKeyRequest, + path: z.never().optional(), + query: z.never().optional() +}) + +/** + * API key created (plaintext returned once) + */ +export const zCreateWorkspaceApiKeyResponse2 = zCreateWorkspaceApiKeyResponse + +export const zRevokeWorkspaceApiKeyData = z.object({ + body: z.never().optional(), + path: z.object({ + id: z.string().uuid() + }), + query: z.never().optional() +}) + +/** + * API key revoked + */ +export const zRevokeWorkspaceApiKeyResponse = z.void() + +export const zVerifyWorkspaceApiKeyData = z.object({ + body: zVerifyApiKeyRequest, + path: z.never().optional(), + query: z + .object({ + include_billing: z.boolean().optional().default(false) + }) + .optional() +}) + +/** + * Key is valid + */ +export const zVerifyWorkspaceApiKeyResponse = zVerifyApiKeyResponse + export const zGetUserData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -2440,43 +2590,6 @@ export const zGetUserData = z.object({ */ export const zGetUserResponse = zUserResponse -export const zGetInviteCodeStatusData = z.object({ - body: z.never().optional(), - path: z.object({ - code: z.string() - }), - query: z.never().optional() -}) - -/** - * Success - invite code exists - */ -export const zGetInviteCodeStatusResponse = zInviteCodeStatusResponse - -export const zClaimInviteCodeData = z.object({ - body: z.never().optional(), - path: z.object({ - code: z.string() - }), - query: z.never().optional() -}) - -/** - * Success - invite code claimed successfully - */ -export const zClaimInviteCodeResponse = zInviteCodeClaimResponse - -export const zSendUserInviteEmailData = z.object({ - body: zSendUserInviteEmailRequest, - path: z.never().optional(), - query: z.never().optional() -}) - -/** - * Success - invite email sent successfully - */ -export const zSendUserInviteEmailResponse2 = zSendUserInviteEmailResponse - export const zSetReviewStatusData = z.object({ body: zSetReviewStatusRequest, path: z.never().optional(), @@ -2488,6 +2601,19 @@ export const zSetReviewStatusData = z.object({ */ export const zSetReviewStatusResponse2 = zSetReviewStatusResponse +export const zUpdateHubWorkflowData = z.object({ + body: zUpdateHubWorkflowRequest, + path: z.object({ + share_id: z.string() + }), + query: z.never().optional() +}) + +/** + * Updated hub workflow detail + */ +export const zUpdateHubWorkflowResponse = zHubWorkflowDetail + export const zGetDeletionRequestData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -2527,6 +2653,23 @@ export const zReportPartnerUsageData = z.object({ */ export const zReportPartnerUsageResponse = zPartnerUsageResponse +export const zUpdateSubscriptionCacheData = z.object({ + body: z.object({ + user_id: z.string(), + is_active: z.boolean(), + tier: z.string().optional() + }), + path: z.never().optional(), + query: z.never().optional() +}) + +/** + * Cache updated successfully + */ +export const zUpdateSubscriptionCacheResponse = z.object({ + status: z.string().optional() +}) + export const zGetJobStatusData = z.object({ body: z.never().optional(), path: z.object({ @@ -2991,6 +3134,25 @@ export const zGetExtensionsData = z.object({ query: z.never().optional() }) +/** + * URL paths (relative to web root) of available extension JS files + */ +export const zGetExtensionsResponse = z.array(z.string()) + +export const zGetNodeInfoSchemaData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional() +}) + +export const zGetNodeByIdData = z.object({ + body: z.never().optional(), + path: z.object({ + id: z.string() + }), + query: z.never().optional() +}) + export const zGetVhsViewVideoData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -3019,12 +3181,32 @@ export const zGetVhsQueryVideoData = z.object({ }) }) -export const zGetUsersRawData = z.object({ +/** + * Video metadata + */ +export const zGetVhsQueryVideoResponse = z.object({ + source: z.object({ + size: z.tuple([z.number().int(), z.number().int()]), + fps: z.number(), + frames: z.number().int(), + duration: z.number() + }) +}) + +export const zGetUsersInfoData = z.object({ body: z.never().optional(), path: z.never().optional(), query: z.never().optional() }) +/** + * Userdata storage information + */ +export const zGetUsersInfoResponse = z.object({ + storage: z.string(), + migrated: z.boolean() +}) + export const zGetApiViewVideoAliasData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -3065,6 +3247,11 @@ export const zGetHealthData = z.object({ query: z.never().optional() }) +/** + * Service is healthy + */ +export const zGetHealthResponse = z.string() + export const zGetOpenapiSpecData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -3134,3 +3321,19 @@ export const zGetStaticExtensionsData = z.object({ }), query: z.never().optional() }) + +export const zGetCustomNodeProxyData = z.object({ + body: z.never().optional(), + path: z.object({ + path: z.string() + }), + query: z.never().optional() +}) + +export const zPostCustomNodeProxyData = z.object({ + body: z.never().optional(), + path: z.object({ + path: z.string() + }), + query: z.never().optional() +}) From a1ba567dbc1957316f5fbfcd42aecc016703838c Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 19 Apr 2026 18:46:20 -0700 Subject: [PATCH 023/460] test: remove --listen 0.0.0.0 from E2E test mock argv (#11021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Remove `--listen 0.0.0.0` from mock `argv` in E2E test fixtures to avoid normalizing a flag that exposes the server to all network interfaces. ## Changes - **What**: Removed `--listen` and `0.0.0.0` from `mockSystemStats.system.argv` in `browser_tests/fixtures/data/systemStats.ts` (shared fixture) and the ManagerDialog-specific override in `browser_tests/tests/dialogs/managerDialog.spec.ts`. Neither value is required for any test assertion. Fixes #11008 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11021-test-remove-listen-0-0-0-0-from-E2E-test-mock-argv-33e6d73d365081c59d3fe9610afbeb6f) by [Unito](https://www.unito.io) --- browser_tests/fixtures/data/systemStats.ts | 2 +- browser_tests/tests/dialogs/managerDialog.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/browser_tests/fixtures/data/systemStats.ts b/browser_tests/fixtures/data/systemStats.ts index b35156d49a..c5984663e2 100644 --- a/browser_tests/fixtures/data/systemStats.ts +++ b/browser_tests/fixtures/data/systemStats.ts @@ -7,7 +7,7 @@ export const mockSystemStats: SystemStatsResponse = { embedded_python: false, comfyui_version: '0.3.10', pytorch_version: '2.4.0+cu124', - argv: ['main.py', '--listen', '0.0.0.0'], + argv: ['main.py'], ram_total: 67108864000, ram_free: 52428800000 }, diff --git a/browser_tests/tests/dialogs/managerDialog.spec.ts b/browser_tests/tests/dialogs/managerDialog.spec.ts index 60da868072..94afd6c0f9 100644 --- a/browser_tests/tests/dialogs/managerDialog.spec.ts +++ b/browser_tests/tests/dialogs/managerDialog.spec.ts @@ -167,7 +167,7 @@ test.describe('ManagerDialog', { tag: '@ui' }, () => { ...mockSystemStats, system: { ...mockSystemStats.system, - argv: ['main.py', '--listen', '0.0.0.0', '--enable-manager'] + argv: ['main.py', '--enable-manager'] } } await comfyPage.page.route('**/system_stats**', async (route) => { From 2fea0aa538d0400c78c6edd802211e169785baad Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 19 Apr 2026 18:51:08 -0700 Subject: [PATCH 024/460] fix: trigger Vue reactivity on output slot type changes in matchType (#9935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix VHS unbatch output slot color not updating when slot types change via matchType resolution in Vue renderer. ## Changes - **What**: After `changeOutputType` mutates `output.type` on objects inside a `shallowReactive` array, spread-copy `this.outputs` to trigger the shallowReactive setter so `SlotConnectionDot` re-evaluates the slot color. ## Review Focus The fix adds `this.outputs = [...this.outputs]` after the matchType resolution loop in `withComfyMatchType`. This forces Vue's shallowReactive proxy to fire, since mutating a property on an object inside the array doesn't trigger the setter. The spread is placed after all outputs are updated to batch the reactivity trigger. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9935-fix-trigger-Vue-reactivity-on-output-slot-type-changes-in-matchType-3246d73d365081c4a293f57931892c61) by [Unito](https://www.unito.io) --- src/core/graph/widgets/dynamicWidgets.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/graph/widgets/dynamicWidgets.ts b/src/core/graph/widgets/dynamicWidgets.ts index 2b2e189c24..bc2479a3c3 100644 --- a/src/core/graph/widgets/dynamicWidgets.ts +++ b/src/core/graph/widgets/dynamicWidgets.ts @@ -323,6 +323,10 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode { if (!(outputGroups?.[idx] == matchKey)) return changeOutputType(this, output, outputType) }) + // Force Vue reactivity update for output slot types. + // Outputs are wrapped in shallowReactive by useGraphNodeManager, + // so mutating output.type alone doesn't trigger re-render. + this.outputs = [...this.outputs] app.canvas?.setDirty(true, true) } ) From feafdc0b4ac243e3f63cdd3bd61fcb8f1f29b39d Mon Sep 17 00:00:00 2001 From: Dante Date: Mon, 20 Apr 2026 10:55:44 +0900 Subject: [PATCH 025/460] fix: chain Load3D node lifecycle callbacks to preserve widget cleanup (#11359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Undo on a workflow with an interactive 3D/camera node (e.g. Qwen MultiAngle Camera) broke the interactive UI: it disappeared for Vue Nodes 2.0 and desynced for LiteGraph. Root cause: `initializeLoad3d` in `useLoad3d.ts` assigned `node.onRemoved`, `node.onResize`, and the other node lifecycle handlers by direct assignment, overwriting the cleanup chain that `addWidget()` had already appended during node construction (line `node.onRemoved = useChainCallback(node.onRemoved, () => widget.onRemove?.())` in `domWidget.ts`). When undo cleared the graph, `widget.onRemove` never ran, so the component widget stayed in `domWidgetStore` pointing at a detached element while new nodes registered fresh widgets at the same UUID keys. Fix: wrap all of those assignments with `useChainCallback` so earlier subscribers (widget registration, badge composables, extension nodeCreated hooks) continue to fire. - Fixes FE-214 () ## Red-Green Verification | Commit | CI Status | Purpose | |--------|-----------|---------| | `test: add failing test for FE-214 undo losing Load3D widget callback chain` | :red_circle: Red | Proves the test catches the bug | | `fix: chain Load3D node lifecycle callbacks to preserve widget cleanup` | :green_circle: Green | Proves the fix resolves the bug | ## Test Plan - [ ] CI red on test-only commit - [ ] CI green on fix commit - [ ] Manual: load Qwen MultiAngle Camera workflow, mutate camera, press Ctrl+Z, confirm interactive UI stays mounted and value reflects restored state (Vue Nodes 2.0 and LiteGraph) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11359-fix-chain-Load3D-node-lifecycle-callbacks-to-preserve-widget-cleanup-3466d73d365081e2b64de65c26ee6abf) by [Unito](https://www.unito.io) --- src/composables/useLoad3d.test.ts | 33 +++++++++++++++++++++++++++++++ src/composables/useLoad3d.ts | 29 +++++++++++++++------------ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index c96414a4ec..3844c521e6 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -321,6 +321,39 @@ describe('useLoad3d', () => { }) }) + describe('preserves existing node callbacks through initializeLoad3d', () => { + // Regression: FE-214 — undo triggers rootGraph.clear() which fires + // node.onRemoved on the outgoing node. addWidget() chains a cleanup that + // unregisters the component widget from the DOM widget store. If + // initializeLoad3d overwrites node.onRemoved instead of chaining, that + // cleanup is lost and the interactive UI persists with a stale reference. + it('chains node.onRemoved with a preexisting callback', async () => { + const existingOnRemoved = vi.fn() + mockNode.onRemoved = existingOnRemoved + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + await composable.initializeLoad3d(containerRef) + + mockNode.onRemoved?.() + + expect(existingOnRemoved).toHaveBeenCalledTimes(1) + }) + + it('chains node.onResize with a preexisting callback', async () => { + const existingOnResize = vi.fn() + mockNode.onResize = existingOnResize + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + await composable.initializeLoad3d(containerRef) + + mockNode.onResize?.([512, 512] as Size) + + expect(existingOnResize).toHaveBeenCalledTimes(1) + }) + }) + describe('waitForLoad3d', () => { it('should execute callback immediately if Load3d exists', async () => { const composable = useLoad3d(mockNode) diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 929d2088ec..fb50b0a651 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -4,6 +4,7 @@ import { toRef } from '@vueuse/core' import { getActivePinia } from 'pinia' import { ref, toRaw, watch } from 'vue' +import { useChainCallback } from '@/composables/functional/useChainCallback' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import { @@ -133,30 +134,32 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { await restoreConfigurationsFromNode(node) - node.onMouseEnter = function () { + node.onMouseEnter = useChainCallback(node.onMouseEnter, () => { load3d?.refreshViewport() - load3d?.updateStatusMouseOnNode(true) - } + }) - node.onMouseLeave = function () { + node.onMouseLeave = useChainCallback(node.onMouseLeave, () => { load3d?.updateStatusMouseOnNode(false) - } + }) - node.onResize = function () { + node.onResize = useChainCallback(node.onResize, () => { load3d?.handleResize() - } + }) - node.onDrawBackground = function () { - if (load3d) { - load3d.renderer.domElement.hidden = this.flags.collapsed ?? false + node.onDrawBackground = useChainCallback( + node.onDrawBackground, + function (this: LGraphNode) { + if (load3d) { + load3d.renderer.domElement.hidden = this.flags.collapsed ?? false + } } - } + ) - node.onRemoved = function () { + node.onRemoved = useChainCallback(node.onRemoved, () => { useLoad3dService().removeLoad3d(node) pendingCallbacks.delete(node) - } + }) nodeToLoad3dMap.set(node, load3d) From 0ac4c3d6c50539d70a4ffdc97aacf3d0d4542c8d Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Mon, 20 Apr 2026 11:12:56 +0900 Subject: [PATCH 026/460] 1.44.6 (#11433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.44.6 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11433-1-44-6-3486d73d365081778622e094f11b500c) by [Unito](https://www.unito.io) Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: Christian Byrne --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7b3f089886..b5a2337625 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.44.5", + "version": "1.44.6", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", From 60c747181882fb9d090a8fba0f6134008dd50f56 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 19 Apr 2026 19:16:54 -0700 Subject: [PATCH 027/460] feat: enable node replacement by default (#11439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary Enable node replacement suggestions by default so users see Quick Fix options for deprecated/renamed nodes without toggling an experimental setting. - Change `Comfy.NodeReplacement.Enabled` default from `false` to `true` and remove `experimental` flag - Add `versionModified` metadata for release tracking - No breaking change — users who previously disabled this setting keep their preference ## Safety gates This is an intentional global rollout, gated by two additional server-side checks: 1. Server must provide `node_replacements` feature flag as true (PostHog controlled) 2. `GET /api/node_replacements` must return data (cloud PR Comfy-Org/cloud#2686) Without both, changing this default alone has no effect. The three gates ensure safe rollout. ## Companion PRs - Comfy-Org/cloud#2686 — backend `GET /api/node_replacements` endpoint + server-side validation bypass Replicate of #11246, retargeted to `main` for backport automation. Labels: `needs-backport`, `cloud/1.42`, `cloud/1.43`, `core/1.42`, `core/1.43` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11439-feat-enable-node-replacement-by-default-3486d73d36508192b77aea9640986106) by [Unito](https://www.unito.io) Co-authored-by: Glary-Bot --- src/platform/settings/constants/coreSettings.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 3dd3a8e624..704336d41e 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -1272,9 +1272,10 @@ export const CORE_SETTINGS: SettingParams[] = [ tooltip: 'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.', type: 'boolean', - defaultValue: false, - experimental: true, - versionAdded: '1.40.0' + defaultValue: true, + experimental: false, + versionAdded: '1.40.0', + versionModified: '1.44.5' }, { id: 'Comfy.Graph.DeduplicateSubgraphNodeIds', From 5d98e11ba1e0586cf2cd1a990fd1a1c829a2bd20 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 20 Apr 2026 03:01:48 -0700 Subject: [PATCH 028/460] feat: enable queue panel v2 by default on nightly builds (#11376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary - Changes the `Comfy.Queue.QPOV2` setting's `defaultValue` from `false` to `isNightly` - On nightly builds, users get the docked job history/queue panel (v2) by default - On stable builds, behavior is unchanged (v1 floating overlay remains default) - Users can still toggle the setting manually regardless of build type ## Pattern Follows the existing pattern used by `Comfy.VueNodes.Enabled` which uses `isCloud || isDesktop` as its version-conditional default. This is a compile-time constant from `@/platform/distribution/types`. ## Context Part of a dual-variant audit to graduate experimental features. QPO v2 has 0 extension ecosystem dependencies (confirmed via GitHub codesearch), making nightly default-on safe for gathering feedback before promoting to all users. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11376-feat-enable-queue-panel-v2-by-default-on-nightly-builds-3466d73d36508140b814d1d684acacba) by [Unito](https://www.unito.io) Co-authored-by: Glary-Bot --- src/platform/settings/constants/coreSettings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 704336d41e..0407adfa1a 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -1,5 +1,5 @@ import { LinkMarkerShape, LiteGraph } from '@/lib/litegraph/src/litegraph' -import { isCloud, isDesktop } from '@/platform/distribution/types' +import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' import type { SettingParams } from '@/platform/settings/types' import type { ColorPalettes } from '@/schemas/colorPaletteSchema' @@ -1245,7 +1245,7 @@ export const CORE_SETTINGS: SettingParams[] = [ type: 'boolean', tooltip: 'Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout.', - defaultValue: false, + defaultValue: isNightly, experimental: true }, { From 35bfe509b39896c4428090582ad856b5fc7f7f35 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:11:37 +0100 Subject: [PATCH 029/460] test: add/update terminal tests (#11239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds test coverage for the integrated terminal ## Changes - **What**: - refactor and simplify existing tests - add new tests for xterm integration ┆Issue is synchronized with this [Notion page](https://app.notion.com/p/PR-11239-test-add-update-terminal-tests-3426d73d365081c99445c35d8808afb4) by [Unito](https://www.unito.io) --- .../fixtures/components/BottomPanel.ts | 32 +++ .../fixtures/helpers/LogsTerminalHelper.ts | 75 +++++++ browser_tests/fixtures/selectors.ts | 8 + browser_tests/tests/bottomPanelLogs.spec.ts | 195 +++++++++++------- .../tabs/terminal/BaseTerminal.vue | 13 +- .../tabs/terminal/LogsTerminal.vue | 7 +- 6 files changed, 256 insertions(+), 74 deletions(-) create mode 100644 browser_tests/fixtures/helpers/LogsTerminalHelper.ts diff --git a/browser_tests/fixtures/components/BottomPanel.ts b/browser_tests/fixtures/components/BottomPanel.ts index c3ac138d5a..fce7b6f03c 100644 --- a/browser_tests/fixtures/components/BottomPanel.ts +++ b/browser_tests/fixtures/components/BottomPanel.ts @@ -1,5 +1,7 @@ import type { Locator, Page } from '@playwright/test' +import { TestIds } from '@e2e/fixtures/selectors' + class ShortcutsTab { readonly essentialsTab: Locator readonly viewControlsTab: Locator @@ -16,6 +18,26 @@ class ShortcutsTab { } } +export class LogsTab { + readonly tab: Locator + readonly terminalRoot: Locator + readonly terminalHost: Locator + readonly copyButton: Locator + readonly errorMessage: Locator + readonly loadingSpinner: Locator + readonly xtermScreen: Locator + + constructor(readonly page: Page) { + this.tab = page.getByRole('tab', { name: /Logs/i }) + this.terminalRoot = page.getByTestId(TestIds.terminal.root) + this.terminalHost = page.getByTestId(TestIds.terminal.host) + this.copyButton = page.getByTestId(TestIds.terminal.copyButton) + this.errorMessage = page.getByTestId(TestIds.terminal.errorMessage) + this.loadingSpinner = page.getByTestId(TestIds.terminal.loadingSpinner) + this.xtermScreen = this.terminalHost.locator('.xterm-screen') + } +} + export class BottomPanel { readonly root: Locator readonly keyboardShortcutsButton: Locator @@ -23,6 +45,7 @@ export class BottomPanel { readonly closeButton: Locator readonly resizeGutter: Locator readonly shortcuts: ShortcutsTab + readonly logs: LogsTab constructor(readonly page: Page) { this.root = page.locator('.bottom-panel') @@ -38,6 +61,15 @@ export class BottomPanel { '.splitter-overlay-bottom > .p-splitter-gutter' ) this.shortcuts = new ShortcutsTab(page) + this.logs = new LogsTab(page) + } + + async toggleLogs() { + await this.toggleButton.click() + await this.logs.tab.waitFor({ state: 'visible' }) + if ((await this.logs.tab.getAttribute('aria-selected')) !== 'true') { + await this.logs.tab.click() + } } async resizeByDragging(deltaY: number): Promise { diff --git a/browser_tests/fixtures/helpers/LogsTerminalHelper.ts b/browser_tests/fixtures/helpers/LogsTerminalHelper.ts new file mode 100644 index 0000000000..b52db5198e --- /dev/null +++ b/browser_tests/fixtures/helpers/LogsTerminalHelper.ts @@ -0,0 +1,75 @@ +import { test as base } from '@playwright/test' +import type { Page, Route } from '@playwright/test' + +import type { LogsRawResponse } from '@/schemas/apiSchema' + +export class LogsTerminalHelper { + constructor(private readonly page: Page) {} + + async mockRawLogs(messages: string[]) { + await this.page.route('**/internal/logs/raw**', (route: Route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages)) + }) + ) + } + + async mockRawLogsPending(messages: string[] = []): Promise<() => void> { + let resolve!: () => void + const pending = new Promise((r) => { + resolve = r + }) + await this.page.route('**/internal/logs/raw**', async (route: Route) => { + await pending + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages)) + }) + }) + return resolve + } + + async mockRawLogsError() { + await this.page.route('**/internal/logs/raw**', (route: Route) => + route.fulfill({ status: 500, body: 'Internal Server Error' }) + ) + } + + async mockSubscribeLogs() { + await this.page.route('**/internal/logs/subscribe**', (route: Route) => + route.fulfill({ status: 200, body: '' }) + ) + } + + static buildWsLogFrame(messages: string[]): string { + return JSON.stringify({ + type: 'logs', + data: { entries: LogsTerminalHelper.buildEntries(messages) } + }) + } + + private static buildRawLogsResponse(messages: string[]): LogsRawResponse { + return { + size: { cols: 80, row: 24 }, + entries: LogsTerminalHelper.buildEntries(messages) + } + } + + private static buildEntries(messages: string[]) { + return messages.map((m) => ({ + t: '1970-01-01T00:00:00.000Z', + m: m.endsWith('\n') ? m : `${m}\n` + })) + } +} + +export const logsTerminalFixture = base.extend<{ + logsTerminal: LogsTerminalHelper +}>({ + logsTerminal: async ({ page }, use) => { + await use(new LogsTerminalHelper(page)) + } +}) diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 12d9101ad0..aa82010e65 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -199,6 +199,13 @@ export const TestIds = { load3dViewer: { sidebar: 'load3d-viewer-sidebar' }, + terminal: { + root: 'terminal-root', + host: 'terminal-host', + copyButton: 'terminal-copy-button', + errorMessage: 'terminal-error-message', + loadingSpinner: 'terminal-loading-spinner' + }, imageCompare: { viewport: 'image-compare-viewport', empty: 'image-compare-empty', @@ -241,4 +248,5 @@ export type TestIdValue = | (typeof TestIds.errors)[keyof typeof TestIds.errors] | (typeof TestIds.loading)[keyof typeof TestIds.loading] | (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer] + | (typeof TestIds.terminal)[keyof typeof TestIds.terminal] | (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare] diff --git a/browser_tests/tests/bottomPanelLogs.spec.ts b/browser_tests/tests/bottomPanelLogs.spec.ts index 5004b5273b..8465c794a3 100644 --- a/browser_tests/tests/bottomPanelLogs.spec.ts +++ b/browser_tests/tests/bottomPanelLogs.spec.ts @@ -1,98 +1,151 @@ +import { mergeTests } from '@playwright/test' + import { - comfyPageFixture as test, - comfyExpect as expect + comfyExpect as expect, + comfyPageFixture } from '@e2e/fixtures/ComfyPage' +import { + LogsTerminalHelper, + logsTerminalFixture +} from '@e2e/fixtures/helpers/LogsTerminalHelper' +import { webSocketFixture } from '@e2e/fixtures/ws' +import { + getClipboardText, + interceptClipboardWrite +} from '@e2e/helpers/clipboardSpy' + +const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture) test.describe('Bottom Panel Logs', { tag: '@ui' }, () => { - test('should open bottom panel via toggle button', async ({ comfyPage }) => { - const { bottomPanel } = comfyPage + test.describe('panel', () => { + test.beforeEach(async ({ logsTerminal }) => { + await logsTerminal.mockSubscribeLogs() + await logsTerminal.mockRawLogs([]) + }) - await expect(bottomPanel.root).toBeHidden() - await bottomPanel.toggleButton.click() - await expect(bottomPanel.root).toBeVisible() + test('opens to Logs tab via toggle button', async ({ comfyPage }) => { + await expect(comfyPage.bottomPanel.root).toBeHidden() + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.logs.tab).toHaveAttribute( + 'aria-selected', + 'true' + ) + await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible() + }) + + test('closes via toggle button', async ({ comfyPage }) => { + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.root).toBeVisible() + + await comfyPage.bottomPanel.toggleButton.click() + await expect(comfyPage.bottomPanel.root).toBeHidden() + }) + + test('switches from shortcuts to Logs tab', async ({ comfyPage }) => { + await comfyPage.bottomPanel.keyboardShortcutsButton.click() + await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeVisible() + + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.logs.tab).toBeVisible() + await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeHidden() + }) }) - test('should show Logs tab when terminal panel opens', async ({ - comfyPage - }) => { - const { bottomPanel } = comfyPage + test.describe('terminal', () => { + test.beforeEach(async ({ logsTerminal }) => { + await logsTerminal.mockSubscribeLogs() + await logsTerminal.mockRawLogs([]) + }) - await bottomPanel.toggleButton.click() - await expect(bottomPanel.root).toBeVisible() + test('shows loading spinner while logs are loading', async ({ + comfyPage, + logsTerminal + }) => { + const resolveRaw = await logsTerminal.mockRawLogsPending() - const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i }) - await expect(logsTab).toBeVisible() - }) + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible() - test('should close bottom panel via toggle button', async ({ comfyPage }) => { - const { bottomPanel } = comfyPage + resolveRaw() + await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden() + }) - await bottomPanel.toggleButton.click() - await expect(bottomPanel.root).toBeVisible() + test('renders initial log entries from the raw-logs API', async ({ + comfyPage, + logsTerminal + }) => { + const logLine = 'Hello from ComfyUI backend!' + await logsTerminal.mockRawLogs([logLine]) - await bottomPanel.toggleButton.click() - await expect(bottomPanel.root).toBeHidden() - }) + await comfyPage.bottomPanel.toggleLogs() - test('should switch between shortcuts and terminal panels', async ({ - comfyPage - }) => { - const { bottomPanel } = comfyPage + await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible() + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + logLine + ) + }) - await bottomPanel.keyboardShortcutsButton.click() - await expect(bottomPanel.root).toBeVisible() - await expect( - comfyPage.page.locator('[id*="tab_shortcuts-essentials"]') - ).toBeVisible() + test('appends log entries received via WebSocket', async ({ + comfyPage, + getWebSocket + }) => { + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible() - await bottomPanel.toggleButton.click() + const ws = await getWebSocket() + const firstLine = 'First live log line' + const secondLine = 'Second live log line' - const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i }) - await expect(logsTab).toBeVisible() - await expect( - comfyPage.page.locator('[id*="tab_shortcuts-essentials"]') - ).toBeHidden() - }) + ws.send(LogsTerminalHelper.buildWsLogFrame([firstLine])) + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + firstLine + ) - test('should persist Logs tab content in bottom panel', async ({ - comfyPage - }) => { - const { bottomPanel } = comfyPage + ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine])) + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + firstLine + ) + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + secondLine + ) + }) - await bottomPanel.toggleButton.click() - await expect(bottomPanel.root).toBeVisible() + test('copy button copies terminal contents to clipboard', async ({ + comfyPage, + logsTerminal + }) => { + const logLine = 'Copy me to the clipboard' + await logsTerminal.mockRawLogs([logLine]) - const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i }) - await expect(logsTab).toBeVisible() + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + logLine + ) - const isAlreadyActive = - (await logsTab.getAttribute('aria-selected')) === 'true' - if (!isAlreadyActive) { - await logsTab.click() - } + await interceptClipboardWrite(comfyPage.page) - const xtermContainer = bottomPanel.root.locator('.xterm') - await expect(xtermContainer).toBeVisible() - }) + await comfyPage.bottomPanel.logs.terminalRoot.hover() + await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible() + await comfyPage.bottomPanel.logs.copyButton.click() - test('should render xterm container in terminal panel', async ({ - comfyPage - }) => { - const { bottomPanel } = comfyPage + await expect + .poll(() => getClipboardText(comfyPage.page)) + .toContain(logLine) + }) - await bottomPanel.toggleButton.click() - await expect(bottomPanel.root).toBeVisible() + test('shows error message when raw-logs API fails', async ({ + comfyPage, + logsTerminal + }) => { + await logsTerminal.mockRawLogsError() - const logsTab = comfyPage.page.getByRole('tab', { name: /Logs/i }) - await expect(logsTab).toBeVisible() + await comfyPage.bottomPanel.toggleLogs() - const isAlreadyActive = - (await logsTab.getAttribute('aria-selected')) === 'true' - if (!isAlreadyActive) { - await logsTab.click() - } - - const xtermScreen = bottomPanel.root.locator('.xterm, .xterm-screen') - await expect(xtermScreen.first()).toBeVisible() + await expect(comfyPage.bottomPanel.logs.errorMessage).toBeVisible() + await expect(comfyPage.bottomPanel.logs.errorMessage).toContainText( + 'Unable to load logs' + ) + await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden() + }) }) }) diff --git a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue index b1a7f30b75..195c6b65e5 100644 --- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue @@ -1,13 +1,22 @@ diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetDOM.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetDOM.test.ts new file mode 100644 index 0000000000..ffe25267a7 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetDOM.test.ts @@ -0,0 +1,126 @@ +import { render } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const canvasMocks = vi.hoisted(() => ({ + canvas: { + graph: { + getNodeById: vi.fn(() => null as unknown) + } + }, + linearMode: false +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => canvasMocks +})) + +const resolveMock = vi.hoisted(() => vi.fn()) +vi.mock( + '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget', + () => ({ + resolveWidgetFromHostNode: resolveMock + }) +) + +const isDOMWidgetMock = vi.hoisted(() => vi.fn(() => true)) +vi.mock('@/scripts/domWidget', () => ({ + isDOMWidget: isDOMWidgetMock +})) + +import WidgetDOM from './WidgetDOM.vue' +import { createMockWidget } from './widgetTestUtils' + +describe('WidgetDOM', () => { + beforeEach(() => { + canvasMocks.canvas.graph.getNodeById.mockReset() + resolveMock.mockReset() + isDOMWidgetMock.mockReset() + isDOMWidgetMock.mockReturnValue(true) + }) + + function mountWithWidget(domElement: HTMLElement | null) { + if (domElement) { + canvasMocks.canvas.graph.getNodeById.mockReturnValue({ mock: true }) + resolveMock.mockReturnValue({ + node: { mock: true }, + widget: { element: domElement, name: 'dom' } + }) + } + return render(WidgetDOM, { + props: { + widget: createMockWidget({ + value: undefined, + name: 'dom', + type: 'dom' + }), + nodeId: 'n1' + } + }) + } + + it('mounts the resolved DOM widget element inside the container', () => { + const hosted = document.createElement('div') + hosted.setAttribute('data-testid', 'hosted-dom') + hosted.textContent = 'hosted content' + + const { container } = mountWithWidget(hosted) + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + expect(container.querySelector('[data-testid="hosted-dom"]')).toBe(hosted) + }) + + it('renders an empty container when no host node is found', () => { + canvasMocks.canvas.graph.getNodeById.mockReturnValue(null) + resolveMock.mockReturnValue(undefined) + + const { container } = render(WidgetDOM, { + props: { + widget: createMockWidget({ + value: undefined, + name: 'dom', + type: 'dom' + }), + nodeId: 'missing' + } + }) + + // eslint-disable-next-line testing-library/no-node-access + const root = container.firstElementChild as HTMLElement + expect(root).toBeInTheDocument() + // eslint-disable-next-line testing-library/no-node-access + expect(root.children).toHaveLength(0) + }) + + it('skips mounting when the resolved widget is not a DOM widget', () => { + const hosted = document.createElement('div') + hosted.setAttribute('data-testid', 'hosted-dom') + + canvasMocks.canvas.graph.getNodeById.mockReturnValue({ mock: true }) + resolveMock.mockReturnValue({ + node: { mock: true }, + widget: { element: hosted, name: 'dom' } + }) + isDOMWidgetMock.mockReturnValue(false) + + const { container } = render(WidgetDOM, { + props: { + widget: createMockWidget({ + value: undefined, + name: 'dom', + type: 'dom' + }), + nodeId: 'n1' + } + }) + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + expect(container.querySelector('[data-testid="hosted-dom"]')).toBeNull() + }) + + it('renders a visible root element for pointer-event capture', () => { + const { container } = mountWithWidget(document.createElement('span')) + // eslint-disable-next-line testing-library/no-node-access + const root = container.firstElementChild as HTMLElement + expect(root).toBeVisible() + }) +}) From 15c5a298a6a6b80f8645108b0f82dfbece77f149 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Tue, 21 Apr 2026 13:42:23 +0900 Subject: [PATCH 043/460] 1.44.7 (#11485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.44.7 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11485-1-44-7-3496d73d36508175b725c4ffbed4c4d0) by [Unito](https://www.unito.io) Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: Alexander Brown --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b5a2337625..eb9ccc4fa1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.44.6", + "version": "1.44.7", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", From 91ed6a37e26a763205442bd1b76c084330c337fc Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Tue, 21 Apr 2026 01:14:51 -0700 Subject: [PATCH 044/460] Fix nodeReplacement not triggering onRemoved (#11509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node Replacement failed to call onRemoved on the old node. This would cause domWidgets to persist after a node is replaced. image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11509-Fix-nodeReplacement-not-triggering-onRemoved-3496d73d365081e19a4ae252aa87172d) by [Unito](https://www.unito.io) --- src/platform/nodeReplacement/useNodeReplacement.test.ts | 5 +++++ src/platform/nodeReplacement/useNodeReplacement.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/src/platform/nodeReplacement/useNodeReplacement.test.ts b/src/platform/nodeReplacement/useNodeReplacement.test.ts index d6be1879e7..8c10e7fde9 100644 --- a/src/platform/nodeReplacement/useNodeReplacement.test.ts +++ b/src/platform/nodeReplacement/useNodeReplacement.test.ts @@ -297,6 +297,7 @@ describe('useNodeReplacement', () => { it('should apply set_value to widget', () => { const placeholder = createPlaceholderNode(1, 'ImageScaleBy') + placeholder.onRemoved = vi.fn() const graph = createMockGraph([placeholder]) placeholder.graph = graph Object.assign(app, { rootGraph: graph }) @@ -331,6 +332,10 @@ describe('useNodeReplacement', () => { // set_value should be applied to the widget expect(newNode.widgets![0].value).toBe('scale by multiplier') + expect( + placeholder.onRemoved, + 'call onRemoved on old node' + ).toHaveBeenCalledTimes(1) }) it('should transfer widget values using old_widget_ids', () => { diff --git a/src/platform/nodeReplacement/useNodeReplacement.ts b/src/platform/nodeReplacement/useNodeReplacement.ts index c272cfe307..e320bb7940 100644 --- a/src/platform/nodeReplacement/useNodeReplacement.ts +++ b/src/platform/nodeReplacement/useNodeReplacement.ts @@ -159,6 +159,7 @@ function replaceWithMapping( nodeGraph: LGraph, idx: number ): void { + node.onRemoved?.() newNode.id = node.id newNode.pos = [...node.pos] newNode.size = [...node.size] From 983789753e9d04eeca0c31c35143f44c3ff2b94f Mon Sep 17 00:00:00 2001 From: Kelly Yang <124ykl@gmail.com> Date: Tue, 21 Apr 2026 10:34:04 -0700 Subject: [PATCH 045/460] refactor: remove @ts-expect-error suppressions in test files (#11337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Part of #11092 — Phase 3: remove @ts-expect-error suppressions from test files. This phase targets 22 suppressions across two test files: - `src/utils/nodeDefUtil.test.ts` (18) - `src/platform/workflow/validation/schemas/workflowSchema.test.ts` (4) ## Changes `nodeDefUtil.test.ts`: Each test already constrains the inputs to a known subtype (`IntInputSpec`, `FloatInputSpec`, `ComboInputSpecV2`), so casting result to the expected subtype at the declaration site is both correct and self-documenting. For the one test that uses the base `InputSpec` type, the options object is extracted with an inline structural cast. `workflowSchema.test.ts`: validateComfyWorkflow returns ComfyWorkflowJSON | null. The tests were accessing .nodes[0].pos without narrowing, causing "object is possibly null" errors. Fixed with explicit expect(validatedWorkflow).not.toBeNull() assertions before each property access, which also improves failure messages — previously a null result would throw a TypeError rather than a readable assertion failure. --- > [!NOTE] > **Low Risk** > Test-only type-safety refactor with no runtime code changes; primary risk is minor test assertion behavior changes if a helper unexpectedly returns `null`. > > **Overview** > Removes `@ts-expect-error` suppressions from two test suites by making nullability and return-type expectations explicit. > > `workflowSchema.test.ts` now asserts `validateComfyWorkflow` results are non-null before accessing `nodes[0]` fields, and `nodeDefUtil.test.ts` casts `mergeInputSpec` results to the expected spec subtype (or extracts typed options) so property assertions compile cleanly under stricter TS settings. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9f3829862bce11aab6891c6a851f51e58d28a4fd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11337-refactor-remove-ts-expect-error-suppressions-in-test-files-3456d73d3650815aa2a2fca5a9332377) by [Unito](https://www.unito.io) Co-authored-by: Alexander Brown --- .../validation/schemas/workflowSchema.test.ts | 16 ++-- src/utils/nodeDefUtil.test.ts | 75 ++++++++----------- 2 files changed, 38 insertions(+), 53 deletions(-) diff --git a/src/platform/workflow/validation/schemas/workflowSchema.test.ts b/src/platform/workflow/validation/schemas/workflowSchema.test.ts index cd1af360b0..b3ef7827fe 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.test.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.test.ts @@ -141,13 +141,13 @@ describe('parseComfyWorkflow', () => { // Should automatically transform the legacy format object to array. workflow.nodes[0].pos = { '0': 3, '1': 4 } let validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4]) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].pos).toEqual([3, 4]) workflow.nodes[0].pos = { 0: 3, 1: 4 } validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4]) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].pos).toEqual([3, 4]) // Should accept the legacy bugged format object. // https://github.com/Comfy-Org/ComfyUI_frontend/issues/710 @@ -164,8 +164,8 @@ describe('parseComfyWorkflow', () => { '9': 0 } validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].pos).toEqual([600, 340]) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].pos).toEqual([600, 340]) }) it('workflow.nodes.widget_values', async () => { @@ -183,8 +183,8 @@ describe('parseComfyWorkflow', () => { // dynamic widgets display. workflow.nodes[0].widgets_values = { foo: 'bar' } const validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].widgets_values).toEqual({ foo: 'bar' }) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].widgets_values).toEqual({ foo: 'bar' }) }) it('workflow.links', async () => { diff --git a/src/utils/nodeDefUtil.test.ts b/src/utils/nodeDefUtil.test.ts index a242cd5ecc..50061eb6fd 100644 --- a/src/utils/nodeDefUtil.test.ts +++ b/src/utils/nodeDefUtil.test.ts @@ -18,14 +18,12 @@ describe('nodeDefUtil', () => { const spec1: IntInputSpec = ['INT', { min: 0, max: 10 }] const spec2: IntInputSpec = ['INT', { min: 5, max: 15 }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as IntInputSpec | null expect(result).not.toBeNull() expect(result?.[0]).toBe('INT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].min).toBe(5) - // @ts-expect-error fixme ts strict error - expect(result?.[1].max).toBe(10) + expect(result?.[1]?.min).toBe(5) + expect(result?.[1]?.max).toBe(10) }) it('should return null for INT specs with non-overlapping ranges', () => { @@ -41,64 +39,57 @@ describe('nodeDefUtil', () => { const spec1: FloatInputSpec = ['FLOAT', { min: 0.5, max: 10.5 }] const spec2: FloatInputSpec = ['FLOAT', { min: 5.5, max: 15.5 }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as FloatInputSpec | null expect(result).not.toBeNull() expect(result?.[0]).toBe('FLOAT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].min).toBe(5.5) - // @ts-expect-error fixme ts strict error - expect(result?.[1].max).toBe(10.5) + expect(result?.[1]?.min).toBe(5.5) + expect(result?.[1]?.max).toBe(10.5) }) it('should handle specs with undefined min/max values', () => { const spec1: FloatInputSpec = ['FLOAT', { min: 0.5 }] const spec2: FloatInputSpec = ['FLOAT', { max: 15.5 }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as FloatInputSpec | null expect(result).not.toBeNull() expect(result?.[0]).toBe('FLOAT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].min).toBe(0.5) - // @ts-expect-error fixme ts strict error - expect(result?.[1].max).toBe(15.5) + expect(result?.[1]?.min).toBe(0.5) + expect(result?.[1]?.max).toBe(15.5) }) it('should merge step values using least common multiple', () => { const spec1: IntInputSpec = ['INT', { min: 0, max: 10, step: 2 }] const spec2: IntInputSpec = ['INT', { min: 0, max: 10, step: 3 }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as IntInputSpec | null expect(result).not.toBeNull() expect(result?.[0]).toBe('INT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(6) // LCM of 2 and 3 is 6 + expect(result?.[1]?.step).toBe(6) // LCM of 2 and 3 is 6 }) it('should use default step of 1 when step is not specified', () => { const spec1: IntInputSpec = ['INT', { min: 0, max: 10 }] const spec2: IntInputSpec = ['INT', { min: 0, max: 10, step: 4 }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as IntInputSpec | null expect(result).not.toBeNull() expect(result?.[0]).toBe('INT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(4) // LCM of 1 and 4 is 4 + expect(result?.[1]?.step).toBe(4) // LCM of 1 and 4 is 4 }) it('should handle step values for FLOAT specs', () => { const spec1: FloatInputSpec = ['FLOAT', { min: 0, max: 10, step: 0.5 }] const spec2: FloatInputSpec = ['FLOAT', { min: 0, max: 10, step: 0.25 }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as FloatInputSpec | null expect(result).not.toBeNull() expect(result?.[0]).toBe('FLOAT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(0.5) + expect(result?.[1]?.step).toBe(0.5) }) }) @@ -108,12 +99,11 @@ describe('nodeDefUtil', () => { const spec1: ComboInputSpecV2 = ['COMBO', { options: ['A', 'B', 'C'] }] const spec2: ComboInputSpecV2 = ['COMBO', { options: ['B', 'C', 'D'] }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as ComboInputSpecV2 | null expect(result).not.toBeNull() expect(result?.[0]).toBe('COMBO') - // @ts-expect-error fixme ts strict error - expect(result?.[1].options).toEqual(['B', 'C']) + expect(result?.[1]?.options).toEqual(['B', 'C']) }) it('should return null for COMBO specs with no overlapping options', () => { @@ -143,30 +133,25 @@ describe('nodeDefUtil', () => { } ] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as ComboInputSpecV2 | null expect(result).not.toBeNull() expect(result?.[0]).toBe('COMBO') - // @ts-expect-error fixme ts strict error - expect(result?.[1].options).toEqual(['B', 'C']) - // @ts-expect-error fixme ts strict error - expect(result?.[1].default).toBe('B') - // @ts-expect-error fixme ts strict error - expect(result?.[1].tooltip).toBe('Select an option') - // @ts-expect-error fixme ts strict error - expect(result?.[1].multiline).toBe(true) + expect(result?.[1]?.options).toEqual(['B', 'C']) + expect(result?.[1]?.default).toBe('B') + expect(result?.[1]?.tooltip).toBe('Select an option') + expect(result?.[1]?.multiline).toBe(true) }) it('should handle v1 and v2 combo specs', () => { const spec1: ComboInputSpec = [['A', 'B', 'C', 'D'], {}] const spec2: ComboInputSpecV2 = ['COMBO', { options: ['C', 'D'] }] - const result = mergeInputSpec(spec1, spec2) + const result = mergeInputSpec(spec1, spec2) as ComboInputSpecV2 | null expect(result).not.toBeNull() expect(result?.[0]).toBe('COMBO') - // @ts-expect-error fixme ts strict error - expect(result?.[1].options).toEqual(['C', 'D']) + expect(result?.[1]?.options).toEqual(['C', 'D']) }) }) @@ -206,12 +191,12 @@ describe('nodeDefUtil', () => { expect(result).not.toBeNull() expect(result?.[0]).toBe('STRING') - // @ts-expect-error fixme ts strict error - expect(result?.[1].default).toBe('value2') - // @ts-expect-error fixme ts strict error - expect(result?.[1].tooltip).toBe('Tooltip 2') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(1) + const options = result?.[1] as + | { default?: string; tooltip?: string; step?: number } + | undefined + expect(options?.default).toBe('value2') + expect(options?.tooltip).toBe('Tooltip 2') + expect(options?.step).toBe(1) }) it('should return null if non-ignored properties differ', () => { From 00c294297efc604c2ea0eb52e79cd8f5bba2874f Mon Sep 17 00:00:00 2001 From: Dante Date: Wed, 22 Apr 2026 02:36:10 +0900 Subject: [PATCH 046/460] test: add WidgetImageCrop unit tests (#11470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Splits the WidgetImageCrop test coverage out of #11446 so this widget can be reviewed independently. ## Changes - **What**: Adds WidgetImageCrop unit tests covering empty/loading/loaded states, ratio-control gating, bounding-box delegation, and disabled upstream behavior. ## Review Focus Focused test-only PR extracted from #11446. Includes small test-only cleanups from the earlier review: shared crop mock defaults, accessible image querying, and reactive upstream mock setup. Validated with `pnpm test:unit -- --run src/components/imagecrop/WidgetImageCrop.test.ts`. ## Screenshots (if applicable) N/A ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11470-test-add-WidgetImageCrop-unit-tests-3486d73d365081ff9a1eed159a8eb9a3) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- .../imagecrop/WidgetImageCrop.test.ts | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/components/imagecrop/WidgetImageCrop.test.ts diff --git a/src/components/imagecrop/WidgetImageCrop.test.ts b/src/components/imagecrop/WidgetImageCrop.test.ts new file mode 100644 index 0000000000..c78a46da7b --- /dev/null +++ b/src/components/imagecrop/WidgetImageCrop.test.ts @@ -0,0 +1,246 @@ +/* eslint-disable vue/one-component-per-file */ +/* eslint-disable vue/no-reserved-component-names */ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import type { Bounds } from '@/renderer/core/layout/types' +import type { SimplifiedWidget } from '@/types/simplifiedWidget' +import type { Ref } from 'vue' + +const cropHolder = vi.hoisted(() => ({ + state: null as Record | null +})) + +function createDefaultCropState() { + return { + imageUrl: ref(null), + isLoading: ref(false), + selectedRatio: ref('1:1'), + isLockEnabled: ref(false), + cropBoxStyle: ref({}), + resizeHandles: ref([]), + handleImageLoad: () => {}, + handleImageError: () => {}, + handleDragStart: () => {}, + handleDragMove: () => {}, + handleDragEnd: () => {}, + handleResizeStart: () => {}, + handleResizeMove: () => {}, + handleResizeEnd: () => {} + } +} + +vi.mock('@/composables/useImageCrop', async () => { + return { + ASPECT_RATIOS: { + '1:1': 1, + '4:3': 4 / 3, + custom: null + }, + useImageCrop: () => { + if (!cropHolder.state) { + cropHolder.state = createDefaultCropState() + } + return cropHolder.state + } + } +}) + +const upstreamHolder = vi.hoisted(() => ({ + ref: null as Ref | null +})) + +vi.mock('@/composables/useUpstreamValue', async () => { + const { ref } = await import('vue') + return { + useUpstreamValue: () => { + upstreamHolder.ref = upstreamHolder.ref ?? ref(undefined) + return upstreamHolder.ref + }, + boundsExtractor: () => () => undefined + } +}) + +import WidgetImageCrop from './WidgetImageCrop.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + imageCrop: { + loading: 'Loading...', + noInputImage: 'No input image connected', + cropPreviewAlt: 'Crop preview', + ratio: 'Ratio', + lockRatio: 'Lock aspect ratio', + unlockRatio: 'Unlock aspect ratio', + custom: 'Custom' + }, + boundingBox: { x: 'X', y: 'Y', width: 'Width', height: 'Height' } + } + } +}) + +const ButtonStub = defineComponent({ + name: 'Button', + inheritAttrs: false, + template: '' +}) + +const Passthrough = defineComponent({ + template: '
' +}) + +const WidgetBoundingBoxStub = defineComponent({ + name: 'WidgetBoundingBox', + props: { + modelValue: { type: Object, default: () => ({}) }, + disabled: { type: Boolean, default: false } + }, + // eslint-disable-next-line vue/no-unused-emit-declarations + emits: ['update:modelValue'], + template: `
` +}) + +function primeCropState(overrides: Record = {}) { + cropHolder.state = { + ...createDefaultCropState(), + ...overrides + } +} + +function makeWidget( + overrides: Partial> = {} +): SimplifiedWidget { + return { + name: 'crop', + type: 'imagecrop', + value: { x: 0, y: 0, width: 512, height: 512 }, + options: {}, + ...overrides + } as SimplifiedWidget +} + +function renderWidget( + widget: SimplifiedWidget = makeWidget(), + initialModel: Bounds = { x: 0, y: 0, width: 512, height: 512 } +) { + const value = ref(initialModel) + const Harness = defineComponent({ + components: { WidgetImageCrop }, + setup: () => ({ value, widget }), + template: + '' + }) + const utils = render(Harness, { + global: { + plugins: [i18n], + stubs: { + Button: ButtonStub, + Select: Passthrough, + SelectContent: Passthrough, + SelectTrigger: Passthrough, + SelectValue: Passthrough, + SelectItem: Passthrough, + WidgetBoundingBox: WidgetBoundingBoxStub + } + } + }) + return { ...utils, value } +} + +describe('WidgetImageCrop', () => { + beforeEach(() => { + cropHolder.state = null + upstreamHolder.ref = null + }) + + describe('Image states', () => { + it('shows the empty-state placeholder when imageUrl is null', () => { + primeCropState() + renderWidget() + expect(screen.getByTestId('crop-empty-state')).toBeInTheDocument() + expect(screen.getByText('No input image connected')).toBeInTheDocument() + }) + + it('shows the loading message when isLoading is true', () => { + primeCropState({ isLoading: ref(true), imageUrl: ref('/img.png') }) + renderWidget() + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.queryByTestId('crop-empty-state')).toBeNull() + }) + + it('renders an img when imageUrl is set and not loading', () => { + primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) }) + renderWidget() + expect( + screen.getByRole('img', { name: 'Crop preview' }) + ).toBeInTheDocument() + expect(screen.queryByText('Loading...')).toBeNull() + }) + + it('renders the crop overlay when an image is loaded', () => { + primeCropState({ imageUrl: ref('/img.png'), isLoading: ref(false) }) + renderWidget() + expect(screen.getByTestId('crop-overlay')).toBeInTheDocument() + }) + }) + + describe('Disabled state', () => { + it('hides the ratio controls when widget is disabled', () => { + renderWidget(makeWidget({ options: { disabled: true } })) + expect(screen.queryByText('Ratio')).toBeNull() + }) + + it('shows the ratio controls when widget is enabled', () => { + renderWidget() + expect(screen.getByText('Ratio')).toBeInTheDocument() + }) + + it('passes disabled=true to the bounding box child when disabled', () => { + renderWidget(makeWidget({ options: { disabled: true } })) + expect(screen.getByTestId('bbox-child').dataset.disabled).toBe('true') + }) + }) + + describe('Bounds delegation', () => { + it('forwards v-model to the bounding box child', () => { + renderWidget(undefined, { x: 5, y: 10, width: 100, height: 200 }) + const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!) + expect(parsed).toEqual({ x: 5, y: 10, width: 100, height: 200 }) + }) + + it('updates v-model when the bounding box emits a change', async () => { + const { value } = renderWidget() + const user = userEvent.setup() + await user.click(screen.getByTestId('bbox-child')) + expect(value.value).toEqual({ x: 1, y: 2, width: 3, height: 4 }) + }) + + it('uses upstream bounds when disabled and upstream is available', () => { + upstreamHolder.ref = ref({ + x: 7, + y: 8, + width: 20, + height: 30 + }) + renderWidget( + makeWidget({ + options: { disabled: true }, + linkedUpstream: { nodeId: 'n1' } + }), + { x: 0, y: 0, width: 512, height: 512 } + ) + const parsed = JSON.parse(screen.getByTestId('bbox-child').dataset.model!) + expect(parsed).toEqual({ x: 7, y: 8, width: 20, height: 30 }) + }) + }) +}) From 2c772077e0601b0ca506a1a17574bc3b6de9b0df Mon Sep 17 00:00:00 2001 From: Dante Date: Wed, 22 Apr 2026 08:17:58 +0900 Subject: [PATCH 047/460] test: add E2E tests for billing dialogs (CancelSubscription, TopUpCredits) (#10969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add Playwright E2E tests for `CancelSubscriptionDialogContent` and `TopUpCreditsDialogContentLegacy` - CancelSubscription tests: dialog display with date formatting, keep subscription dismiss, confirm cancel with mocked API, error handling on API failure - TopUpCredits tests: dialog display with preset amounts, insufficient credits variant, preset selection, close button dismiss, pricing link visibility Part of the FixIt Burndown test coverage initiative (Untested Dialogs). ## Test plan - [ ] Verify tests pass in CI against OSS build - [ ] `pnpm test:browser:local -- browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts` - [ ] `pnpm test:browser:local -- browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10969-test-add-E2E-tests-for-billing-dialogs-CancelSubscription-TopUpCredits-33c6d73d36508164b268c08c99464ca1) by [Unito](https://www.unito.io) --- .../components/CancelSubscriptionDialog.ts | 32 ++++++++++ .../fixtures/components/TopUpCreditsDialog.ts | 54 +++++++++++++++++ .../dialogs/cancelSubscriptionDialog.spec.ts | 44 ++++++++++++++ .../tests/dialogs/topUpCreditsDialog.spec.ts | 58 +++++++++++++++++++ .../TopUpCreditsDialogContentLegacy.vue | 3 +- .../TopUpCreditsDialogContentWorkspace.vue | 3 +- 6 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 browser_tests/fixtures/components/CancelSubscriptionDialog.ts create mode 100644 browser_tests/fixtures/components/TopUpCreditsDialog.ts create mode 100644 browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts create mode 100644 browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts diff --git a/browser_tests/fixtures/components/CancelSubscriptionDialog.ts b/browser_tests/fixtures/components/CancelSubscriptionDialog.ts new file mode 100644 index 0000000000..69a20932d2 --- /dev/null +++ b/browser_tests/fixtures/components/CancelSubscriptionDialog.ts @@ -0,0 +1,32 @@ +import type { Locator, Page } from '@playwright/test' + +import type { WorkspaceStore } from '@e2e/types/globals' +import { BaseDialog } from '@e2e/fixtures/components/BaseDialog' + +export class CancelSubscriptionDialog extends BaseDialog { + readonly heading: Locator + readonly keepSubscriptionButton: Locator + readonly confirmCancelButton: Locator + + constructor(page: Page) { + super(page) + this.heading = this.root.getByRole('heading', { + name: 'Cancel subscription' + }) + this.keepSubscriptionButton = this.root.getByRole('button', { + name: 'Keep subscription' + }) + this.confirmCancelButton = this.root.getByRole('button', { + name: 'Cancel subscription' + }) + } + + async open(cancelAt?: string) { + await this.page.evaluate((date) => { + void ( + window.app!.extensionManager as WorkspaceStore + ).dialog.showCancelSubscriptionDialog(date) + }, cancelAt) + await this.waitForVisible() + } +} diff --git a/browser_tests/fixtures/components/TopUpCreditsDialog.ts b/browser_tests/fixtures/components/TopUpCreditsDialog.ts new file mode 100644 index 0000000000..3c1f96462d --- /dev/null +++ b/browser_tests/fixtures/components/TopUpCreditsDialog.ts @@ -0,0 +1,54 @@ +import type { Locator, Page } from '@playwright/test' + +import type { WorkspaceStore } from '@e2e/types/globals' +import { BaseDialog } from '@e2e/fixtures/components/BaseDialog' + +export class TopUpCreditsDialog extends BaseDialog { + readonly heading: Locator + readonly insufficientHeading: Locator + readonly preset10: Locator + readonly preset25: Locator + readonly preset50: Locator + readonly preset100: Locator + readonly payAmountInput: Locator + readonly pricingLink: Locator + + constructor(page: Page) { + super(page) + this.heading = this.root.getByRole('heading', { name: 'Add more credits' }) + this.insufficientHeading = this.root.getByRole('heading', { + name: 'Add more credits to run' + }) + this.preset10 = this.root.getByRole('button', { + name: '$10', + exact: true + }) + this.preset25 = this.root.getByRole('button', { + name: '$25', + exact: true + }) + this.preset50 = this.root.getByRole('button', { + name: '$50', + exact: true + }) + this.preset100 = this.root.getByRole('button', { + name: '$100', + exact: true + }) + this.payAmountInput = this.root + .getByTestId('top-up-pay-amount') + .locator('input') + this.pricingLink = this.root.getByRole('link', { + name: 'View pricing details' + }) + } + + async open(options?: { isInsufficientCredits?: boolean }) { + await this.page.evaluate((opts) => { + void ( + window.app!.extensionManager as WorkspaceStore + ).dialog.showTopUpCreditsDialog(opts) + }, options) + await this.waitForVisible() + } +} diff --git a/browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts b/browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts new file mode 100644 index 0000000000..fd45964ca3 --- /dev/null +++ b/browser_tests/tests/dialogs/cancelSubscriptionDialog.spec.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog' + +test.describe('CancelSubscription dialog', { tag: '@ui' }, () => { + let dialog: CancelSubscriptionDialog + + test.beforeEach(async ({ comfyPage }) => { + dialog = new CancelSubscriptionDialog(comfyPage.page) + }) + + test('displays dialog with title and formatted date', async () => { + await dialog.open('2025-12-31T12:00:00Z') + + await expect(dialog.heading).toBeVisible() + await expect(dialog.root).toContainText('December 31, 2025') + }) + + test('"Keep subscription" button closes dialog', async () => { + await dialog.open() + + await dialog.keepSubscriptionButton.click() + await expect(dialog.root).toBeHidden() + }) + + test('Escape key closes dialog', async ({ comfyPage }) => { + await dialog.open() + + await comfyPage.page.keyboard.press('Escape') + await expect(dialog.root).toBeHidden() + }) + + test('"Cancel subscription" button initiates cancellation flow', async () => { + await dialog.open() + + await expect(dialog.confirmCancelButton).toBeEnabled() + + await dialog.confirmCancelButton.click() + + // Next state: dialog closes once the cancellation flow completes + await expect(dialog.root).toBeHidden() + }) +}) diff --git a/browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts b/browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts new file mode 100644 index 0000000000..36efcde81d --- /dev/null +++ b/browser_tests/tests/dialogs/topUpCreditsDialog.spec.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog' + +test.describe('TopUpCredits dialog', { tag: '@ui' }, () => { + let dialog: TopUpCreditsDialog + + test.beforeEach(async ({ comfyPage }) => { + dialog = new TopUpCreditsDialog(comfyPage.page) + }) + + test('displays dialog with heading and preset amounts', async () => { + await dialog.open() + + await expect(dialog.heading).toBeVisible() + await expect(dialog.preset10).toBeVisible() + await expect(dialog.preset25).toBeVisible() + await expect(dialog.preset50).toBeVisible() + await expect(dialog.preset100).toBeVisible() + }) + + test('displays insufficient credits message when opened with flag', async () => { + await dialog.open({ isInsufficientCredits: true }) + + await expect(dialog.insufficientHeading).toBeVisible() + await expect(dialog.root).toContainText( + "You don't have enough credits to run this workflow" + ) + }) + + test('selecting a preset amount updates the pay amount', async () => { + await dialog.open() + + // Default preset is $50, click $10 instead + await dialog.preset10.click() + + await expect(dialog.payAmountInput).toHaveValue('10') + }) + + test('close button dismisses dialog', async () => { + await dialog.open() + + await dialog.closeButton.click() + await expect(dialog.root).toBeHidden() + }) + + test('pricing details link points to docs pricing page', async () => { + await dialog.open() + + await expect(dialog.pricingLink).toBeVisible() + await expect(dialog.pricingLink).toHaveAttribute( + 'href', + /partner-nodes\/pricing/ + ) + await expect(dialog.pricingLink).toHaveAttribute('target', '_blank') + }) +}) diff --git a/src/components/dialog/content/TopUpCreditsDialogContentLegacy.vue b/src/components/dialog/content/TopUpCreditsDialogContentLegacy.vue index d9e39909ec..349f4ca098 100644 --- a/src/components/dialog/content/TopUpCreditsDialogContentLegacy.vue +++ b/src/components/dialog/content/TopUpCreditsDialogContentLegacy.vue @@ -13,6 +13,7 @@ ' +}) + +function renderBatch(count: number, initialIndex = 0) { + const index = ref(initialIndex) + const Harness = defineComponent({ + components: { BatchNavigation }, + setup: () => ({ index, count }), + template: '' + }) + const utils = render(Harness, { + global: { plugins: [i18n], stubs: { Button: ButtonStub } } + }) + return { ...utils, index } +} + +describe('BatchNavigation', () => { + describe('Visibility', () => { + it('renders nothing when count is 1', () => { + renderBatch(1) + expect(screen.queryByTestId('batch-counter')).toBeNull() + }) + + it('renders nothing when count is 0', () => { + renderBatch(0) + expect(screen.queryByTestId('batch-counter')).toBeNull() + }) + + it('renders the counter when count is greater than 1', () => { + renderBatch(3) + expect(screen.getByTestId('batch-counter')).toBeInTheDocument() + }) + }) + + describe('Counter display', () => { + it('formats counter as "current / total" using 1-based index', () => { + renderBatch(5, 0) + expect(screen.getByTestId('batch-counter')).toHaveTextContent('1 / 5') + }) + + it('updates the counter when index changes externally', () => { + renderBatch(5, 3) + expect(screen.getByTestId('batch-counter')).toHaveTextContent('4 / 5') + }) + }) + + describe('Navigation', () => { + it('advances the index when the next button is clicked', async () => { + const { index } = renderBatch(3, 0) + const user = userEvent.setup() + await user.click(screen.getByTestId('batch-next')) + expect(index.value).toBe(1) + }) + + it('decrements the index when the previous button is clicked', async () => { + const { index } = renderBatch(3, 2) + const user = userEvent.setup() + await user.click(screen.getByTestId('batch-prev')) + expect(index.value).toBe(1) + }) + + it('disables the previous button at the first item', () => { + renderBatch(3, 0) + expect(screen.getByTestId('batch-prev')).toBeDisabled() + expect(screen.getByTestId('batch-next')).not.toBeDisabled() + }) + + it('disables the next button at the last item', () => { + renderBatch(3, 2) + expect(screen.getByTestId('batch-prev')).not.toBeDisabled() + expect(screen.getByTestId('batch-next')).toBeDisabled() + }) + + it('enables both buttons in the middle of the range', () => { + renderBatch(3, 1) + expect(screen.getByTestId('batch-prev')).not.toBeDisabled() + expect(screen.getByTestId('batch-next')).not.toBeDisabled() + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts index 67460e8062..77a019913a 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ /* eslint-disable vue/no-unused-emit-declarations */ import { fireEvent, render, screen } from '@testing-library/vue' import { defineComponent } from 'vue' diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts index f3c6eba262..5ad6c56604 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ /* eslint-disable vue/no-unused-emit-declarations */ import { createTestingPinia } from '@pinia/testing' import { render, screen, fireEvent } from '@testing-library/vue' diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts index d6b05f2bc5..24eb16564e 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ /* eslint-disable vue/no-unused-emit-declarations */ import { render, screen, waitFor } from '@testing-library/vue' import userEvent from '@testing-library/user-event' diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts b/src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts new file mode 100644 index 0000000000..38cd26ee10 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.test.ts @@ -0,0 +1,161 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, ref } from 'vue' +import type { ComponentProps } from 'vue-component-type-helpers' +import { createI18n } from 'vue-i18n' + +import FormSearchInput from './FormSearchInput.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { + searchPlaceholder: 'Search {subject}', + clear: 'Clear' + } + } + } +}) + +type Searcher = NonNullable['searcher']> + +function renderSearch( + initialQuery: string = '', + searcher?: Searcher, + updateKey?: { value: unknown } +) { + const query = ref(initialQuery) + const key = updateKey + const Harness = defineComponent({ + components: { FormSearchInput }, + setup: () => ({ query, searcher, key }), + template: `` + }) + const utils = render(Harness, { global: { plugins: [i18n] } }) + return { ...utils, query, key } +} + +describe('FormSearchInput', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Input binding', () => { + it('renders the initial query', () => { + renderSearch('hello') + expect(screen.getByRole('textbox')).toHaveValue('hello') + }) + + it('updates v-model as the user types', async () => { + const { query } = renderSearch('') + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await user.type(screen.getByRole('textbox'), 'abc') + expect(query.value).toBe('abc') + }) + }) + + describe('Clear button', () => { + it('is hidden when the query is empty', () => { + renderSearch('') + expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull() + }) + + it('is hidden when the query only contains whitespace', () => { + renderSearch(' ') + expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull() + }) + + it('is shown when the query has non-whitespace text', () => { + renderSearch('abc') + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument() + }) + + it('clears the query when clicked', async () => { + const { query } = renderSearch('abc') + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + await user.click(screen.getByRole('button', { name: 'Clear' })) + expect(query.value).toBe('') + }) + }) + + describe('Searcher integration', () => { + it('calls searcher immediately on mount with the initial query', async () => { + const searcher = vi.fn(async () => {}) + renderSearch('initial', searcher) + await vi.advanceTimersByTimeAsync(0) + expect(searcher).toHaveBeenCalled() + expect(searcher.mock.calls[0][0]).toBe('initial') + }) + + it('debounces user input before calling searcher again', async () => { + const searcher = vi.fn(async () => {}) + const { query } = renderSearch('', searcher) + await vi.advanceTimersByTimeAsync(0) + searcher.mockClear() + + query.value = 'a' + query.value = 'ab' + query.value = 'abc' + // refDebounced delay is 250ms — 100ms is still within the window + await vi.advanceTimersByTimeAsync(100) + expect(searcher).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(300) + expect(searcher).toHaveBeenCalledTimes(1) + expect(searcher.mock.calls[0][0]).toBe('abc') + }) + }) + + describe('updateKey refresh', () => { + it('reruns the searcher when updateKey changes even if the query is unchanged', async () => { + const searcher = vi.fn(async () => {}) + const updateKey = ref(1) + renderSearch('query', searcher, updateKey) + await vi.advanceTimersByTimeAsync(0) + searcher.mockClear() + + updateKey.value = 2 + await vi.advanceTimersByTimeAsync(300) + + expect(searcher).toHaveBeenCalledTimes(1) + expect(searcher.mock.calls[0][0]).toBe('query') + }) + }) + + describe('Stale-result cancellation via onCleanup', () => { + it('invokes the cleanup registered by a superseded search before starting the next one', async () => { + const cleanupA = vi.fn() + const cleanupB = vi.fn() + let call = 0 + const searcher: Searcher = async (_q, onCleanup) => { + const current = ++call + onCleanup(current === 1 ? cleanupA : cleanupB) + } + + const { query } = renderSearch('', searcher) + await vi.advanceTimersByTimeAsync(0) + // First call registered its cleanup + expect(cleanupA).not.toHaveBeenCalled() + + // Supersede it with a new query + query.value = 'next' + await vi.advanceTimersByTimeAsync(300) + + // The first search's cleanup must run before the second registers its own + expect(cleanupA).toHaveBeenCalledTimes(1) + // Latest active search's cleanup has not fired yet + expect(cleanupB).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.test.ts b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.test.ts new file mode 100644 index 0000000000..53bb79cf0c --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.test.ts @@ -0,0 +1,178 @@ +import { fireEvent, render, screen } from '@testing-library/vue' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' + +import type { SimplifiedWidget } from '@/types/simplifiedWidget' +import { HideLayoutFieldKey } from '@/types/widgetTypes' + +import WidgetLayoutField from './WidgetLayoutField.vue' + +type WidgetShape = Pick< + SimplifiedWidget, + 'name' | 'label' | 'borderStyle' +> + +function renderField( + widget: WidgetShape, + { + hideLayoutField = false, + slotContent = 'content' + }: { hideLayoutField?: boolean; slotContent?: string } = {} +) { + const Harness = defineComponent({ + components: { WidgetLayoutField }, + setup: () => ({ widget }), + template: `${slotContent}` + }) + return render(Harness, { + global: { + provide: { [HideLayoutFieldKey as symbol]: hideLayoutField } + } + }) +} + +describe('WidgetLayoutField', () => { + describe('Label rendering', () => { + it('renders widget.name when label is absent', () => { + renderField({ name: 'seed' }) + expect(screen.getByText('seed')).toBeInTheDocument() + }) + + it('prefers widget.label over widget.name', () => { + renderField({ name: 'seed', label: 'Random Seed' }) + expect(screen.getByText('Random Seed')).toBeInTheDocument() + expect(screen.queryByText('seed')).toBeNull() + }) + + it('renders the label area without text when widget.name is empty', () => { + renderField({ name: '' }) + expect(screen.getByTestId('widget-layout-field-label')).toHaveTextContent( + '' + ) + expect(screen.getByTestId('slot')).toBeInTheDocument() + }) + }) + + describe('Label visibility', () => { + it('shows the label area by default', () => { + renderField({ name: 'seed' }) + expect( + screen.getByTestId('widget-layout-field-label') + ).toBeInTheDocument() + }) + + it('hides the label area when HideLayoutFieldKey is true', () => { + renderField({ name: 'seed' }, { hideLayoutField: true }) + expect(screen.queryByTestId('widget-layout-field-label')).toBeNull() + }) + + it('still renders the slotted content when the label is hidden', () => { + renderField({ name: 'seed' }, { hideLayoutField: true }) + expect(screen.getByTestId('slot')).toBeInTheDocument() + }) + }) + + describe('Slot content', () => { + it('renders the default slot', () => { + renderField({ name: 'seed' }) + expect(screen.getByTestId('slot')).toBeInTheDocument() + }) + + it('passes borderStyle to the default slot', () => { + const Harness = defineComponent({ + components: { WidgetLayoutField }, + setup: () => ({ + widget: { name: 'seed', borderStyle: 'custom-border' } + }), + template: ` + + + + ` + }) + render(Harness) + const el = screen.getByTestId('slot-border') + expect(el.dataset.border).toContain('custom-border') + }) + }) + + // user-event models clicks/keyboard but not raw pointerdown/move/up. + // fireEvent is the correct primitive for testing propagation stops on + // pointer events, so the testing-library/prefer-user-event rule is + // disabled within this block. + /* eslint-disable testing-library/prefer-user-event */ + describe('Pointer-event isolation', () => { + // The slot wrapper stops pointerdown/move/up so inner controls can capture + // drags without triggering node selection/drag on the outer canvas. + function renderInsideParent(onParentPointer: (type: string) => void) { + const Harness = defineComponent({ + components: { WidgetLayoutField }, + setup: () => ({ + widget: { name: 'seed' }, + onDown: () => onParentPointer('pointerdown'), + onMove: () => onParentPointer('pointermove'), + onUp: () => onParentPointer('pointerup') + }), + template: ` +
+ + + +
+ ` + }) + return render(Harness) + } + + it.for([ + ['pointerdown', fireEvent.pointerDown], + ['pointermove', fireEvent.pointerMove], + ['pointerup', fireEvent.pointerUp] + ] as const)( + 'stops %s from propagating to the parent', + async ([, dispatch]) => { + const parentSpy = vi.fn<(type: string) => void>() + renderInsideParent(parentSpy) + + const inner = screen.getByTestId('inner-input') + await dispatch(inner) + + expect(parentSpy).not.toHaveBeenCalled() + } + ) + + it('still allows the inner control itself to observe the event', async () => { + const parentSpy = vi.fn<(type: string) => void>() + const innerSpy = vi.fn() + const Harness = defineComponent({ + components: { WidgetLayoutField }, + setup: () => ({ + widget: { name: 'seed' }, + onParent: (t: string) => parentSpy(t), + onInner: () => innerSpy() + }), + template: ` +
+ + + +
+ ` + }) + render(Harness) + + await fireEvent.pointerDown(screen.getByTestId('inner-input')) + + expect(innerSpy).toHaveBeenCalledTimes(1) + expect(parentSpy).not.toHaveBeenCalled() + }) + }) + /* eslint-enable testing-library/prefer-user-event */ +}) diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue index f399f7a4f1..30f4b07ef0 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue @@ -32,7 +32,11 @@ const borderStyle = computed(() => ) " > -
+
From 66409488ce5be66a5fab68e0f241905331940182 Mon Sep 17 00:00:00 2001 From: Kelly Yang <124ykl@gmail.com> Date: Wed, 22 Apr 2026 07:12:20 -0700 Subject: [PATCH 054/460] Refactor/brush drawing utils (#11531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 1 of this https://github.com/Comfy-Org/ComfyUI_frontend/pull/11388 ## Changes * **`src/composables/maskeditor/brushDrawingUtils.ts` (New)** — Extracted `premultiplyData`, `formatRgba`, `drawShapeOnContext`, `createBrushGradient`, `getCachedBrushTexture`, `drawRgbShape`, `drawMaskShape`, `resetDirtyRect`, and `updateDirtyRect`; also exports `DirtyRect` / `MaskColor` types. * **`src/composables/maskeditor/brushDrawingUtils.test.ts` (New)** — 11 unit tests with zero module mocking. * **`src/composables/maskeditor/useBrushDrawing.ts`** — Replaced logic with imports; updated all `updateDirtyRect` call sites to use pure function calls, eliminating redundant calculations in `drawShape`. ## Test locally 1. Draw a few strokes on the canvas — verify brush marks appear correctly- ok 2. Switch to the eraser tool and erase part of the stroke — verify erasure works - ok 3. Press Ctrl+Z to undo — verify the canvas state is restored - ok 4. Alt+drag to adjust brush size/hardness — verify the brush parameters update correctly - ok https://github.com/user-attachments/assets/ba4ca54d-e1a9-4985-bc46-b996bbf13eee --- > [!NOTE] > **Medium Risk** > Refactors core brush rendering and dirty-rect tracking used during interactive drawing, so subtle regressions in brush appearance/performance or cache behavior are possible. Adds new error paths when brush texture canvas context/radius are invalid. > > **Overview** > Extracts CPU brush rendering utilities into new `brushDrawingUtils.ts`, including **shape drawing**, **soft brush gradients/rect textures with an LRU cache**, **alpha premultiplication**, and **dirty-rect reset/update** helpers. > > Updates `useBrushDrawing.ts` to import and use these helpers, switching dirty-rect tracking to a pure-function style (`dirtyRect.value = updateDirtyRect(...)`) and simplifying `drawShape` by computing effective radius/hardness once. > > Adds `brushDrawingUtils.test.ts` with focused unit coverage for premultiplication, dirty-rect bounds behavior, and RGB/mask drawing paths (including cached soft-rect textures and error handling when a 2D context can’t be created). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit abbc6813a68ce81e3a716fa6177b8f9a94996b7f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11531-Refactor-brush-drawing-utils-34a6d73d365081e1b404c384e099d1a9) by [Unito](https://www.unito.io) --- .../maskeditor/brushDrawingUtils.test.ts | 198 +++++++++ .../maskeditor/brushDrawingUtils.ts | 247 +++++++++++ src/composables/maskeditor/useBrushDrawing.ts | 398 ++---------------- 3 files changed, 485 insertions(+), 358 deletions(-) create mode 100644 src/composables/maskeditor/brushDrawingUtils.test.ts create mode 100644 src/composables/maskeditor/brushDrawingUtils.ts diff --git a/src/composables/maskeditor/brushDrawingUtils.test.ts b/src/composables/maskeditor/brushDrawingUtils.test.ts new file mode 100644 index 0000000000..8656d84951 --- /dev/null +++ b/src/composables/maskeditor/brushDrawingUtils.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { BrushShape } from '@/extensions/core/maskeditor/types' +import type { Point } from '@/extensions/core/maskeditor/types' + +import { + drawMaskShape, + drawRgbShape, + premultiplyData, + resetDirtyRect, + updateDirtyRect +} from './brushDrawingUtils' + +function makeMockCtx() { + const gradient = { addColorStop: vi.fn() } + return { + beginPath: vi.fn(), + fill: vi.fn(), + rect: vi.fn(), + arc: vi.fn(), + fillStyle: '', + drawImage: vi.fn(), + createRadialGradient: vi.fn(() => gradient) + } as unknown as CanvasRenderingContext2D +} + +function makeMockCanvas(nullCtx = false) { + const imageData = { data: new Uint8ClampedArray(40 * 40 * 4) } + const ctx2d = nullCtx + ? null + : { createImageData: vi.fn(() => imageData), putImageData: vi.fn() } + return { + width: 0, + height: 0, + getContext: vi.fn(() => ctx2d) + } as unknown as HTMLCanvasElement +} + +function spyOnCreateElement(nullCtx = false) { + const originalCreateElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return makeMockCanvas(nullCtx) + return originalCreateElement(tag) + }) +} + +const point: Point = { x: 10, y: 10 } + +describe('premultiplyData', () => { + it('leaves RGB unchanged when alpha=255', () => { + const data = new Uint8ClampedArray([100, 150, 200, 255]) + premultiplyData(data) + expect(data[0]).toBe(100) + expect(data[1]).toBe(150) + expect(data[2]).toBe(200) + }) + + it('halves RGB values when alpha=128', () => { + const data = new Uint8ClampedArray([200, 200, 200, 128]) + premultiplyData(data) + expect(data[0]).toBeCloseTo(100, 0) + }) + + it('zeroes RGB when alpha=0', () => { + const data = new Uint8ClampedArray([255, 255, 255, 0]) + premultiplyData(data) + expect(data[0]).toBe(0) + expect(data[1]).toBe(0) + expect(data[2]).toBe(0) + }) +}) + +describe('updateDirtyRect', () => { + it('includes padding around the point', () => { + const rect = updateDirtyRect(resetDirtyRect(), 50, 50, 10) + expect(rect.minX).toBe(50 - 10 - 2) + expect(rect.maxX).toBe(50 + 10 + 2) + }) + + it('never shrinks existing bounds on subsequent calls', () => { + let rect = updateDirtyRect(resetDirtyRect(), 50, 50, 10) + rect = updateDirtyRect(rect, 10, 10, 2) + expect(rect.minX).toBeLessThanOrEqual(10 - 2) + expect(rect.maxX).toBeGreaterThanOrEqual(50 + 10) + }) +}) + +describe('drawRgbShape', () => { + it('draws arc at the correct center and radius for Arc brush at hardness=1', () => { + const ctx = makeMockCtx() + drawRgbShape(ctx, point, BrushShape.Arc, 10, 1, 0.8, '#ff0000') + expect(ctx.arc).toHaveBeenCalledWith(10, 10, 10, 0, Math.PI * 2, false) + }) + + it('draws rect at the correct position for Rect brush at hardness=1', () => { + const ctx = makeMockCtx() + drawRgbShape(ctx, point, BrushShape.Rect, 10, 1, 0.8, '#ff0000') + expect(ctx.rect).toHaveBeenCalledWith(0, 0, 20, 20) + }) + + it('creates radial gradient centered on the point for Arc brush at hardness < 1', () => { + const ctx = makeMockCtx() + drawRgbShape(ctx, point, BrushShape.Arc, 10, 0.5, 0.8, '#ff0000') + expect(ctx.createRadialGradient).toHaveBeenCalledWith(10, 10, 0, 10, 10, 10) + }) + + describe('Rect brush with soft hardness', () => { + beforeEach(() => spyOnCreateElement()) + afterEach(() => vi.restoreAllMocks()) + + it('draws the cached brush texture at the correct offset without using a gradient', () => { + const ctx = makeMockCtx() + drawRgbShape(ctx, point, BrushShape.Rect, 10, 0.5, 0.8, '#00ff00') + expect(ctx.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0) + expect(ctx.createRadialGradient).not.toHaveBeenCalled() + }) + + it('reuses the cached texture on a second call with identical parameters', () => { + const ctx = makeMockCtx() + drawRgbShape(ctx, point, BrushShape.Rect, 10, 0.5, 0.8, '#00ff00') + drawRgbShape(ctx, point, BrushShape.Rect, 10, 0.5, 0.8, '#00ff00') + const [firstCall, secondCall] = vi.mocked(ctx.drawImage).mock.calls + expect(firstCall[0]).toBe(secondCall[0]) + }) + + it('throws when the canvas context is unavailable', () => { + vi.restoreAllMocks() + spyOnCreateElement(true) + const ctx = makeMockCtx() + expect(() => + drawRgbShape(ctx, point, BrushShape.Rect, 10, 0.5, 0.8, '#aabbcc') + ).toThrow('Unable to create 2D canvas context for brush texture') + }) + }) +}) + +describe('drawMaskShape', () => { + const maskColor = { r: 255, g: 0, b: 0 } + + it('draws arc at the correct center and radius when not erasing, hardness=1', () => { + const ctx = makeMockCtx() + drawMaskShape(ctx, point, BrushShape.Arc, 10, 1, 0.8, false, maskColor) + expect(ctx.arc).toHaveBeenCalledWith(10, 10, 10, 0, Math.PI * 2, false) + }) + + it('uses white fill color when erasing at hardness=1', () => { + const ctx = makeMockCtx() + drawMaskShape(ctx, point, BrushShape.Arc, 10, 1, 0.8, true, maskColor) + expect(ctx.fillStyle).toContain('255, 255, 255') + }) + + it('creates radial gradient centered on the point when hardness < 1', () => { + const ctx = makeMockCtx() + drawMaskShape(ctx, point, BrushShape.Arc, 10, 0.5, 0.8, false, maskColor) + expect(ctx.createRadialGradient).toHaveBeenCalledWith(10, 10, 0, 10, 10, 10) + }) + + describe('Rect brush with soft hardness', () => { + beforeEach(() => spyOnCreateElement()) + afterEach(() => vi.restoreAllMocks()) + + it('draws the cached texture without a gradient when not erasing', () => { + const ctx = makeMockCtx() + drawMaskShape(ctx, point, BrushShape.Rect, 10, 0.5, 0.8, false, { + r: 0, + g: 255, + b: 0 + }) + expect(ctx.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0) + expect(ctx.createRadialGradient).not.toHaveBeenCalled() + }) + + it('draws the cached texture without a gradient when erasing', () => { + const ctx = makeMockCtx() + drawMaskShape(ctx, point, BrushShape.Rect, 10, 0.5, 0.8, true, maskColor) + expect(ctx.drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0) + expect(ctx.createRadialGradient).not.toHaveBeenCalled() + }) + + it('uses a different cached texture for erase vs paint', () => { + const paintCtx = makeMockCtx() + const eraseCtx = makeMockCtx() + drawMaskShape(paintCtx, point, BrushShape.Rect, 10, 0.5, 0.8, false, { + r: 0, + g: 0, + b: 255 + }) + drawMaskShape(eraseCtx, point, BrushShape.Rect, 10, 0.5, 0.8, true, { + r: 0, + g: 0, + b: 255 + }) + const paintTexture = vi.mocked(paintCtx.drawImage).mock.calls[0][0] + const eraseTexture = vi.mocked(eraseCtx.drawImage).mock.calls[0][0] + expect(paintTexture).not.toBe(eraseTexture) + }) + }) +}) diff --git a/src/composables/maskeditor/brushDrawingUtils.ts b/src/composables/maskeditor/brushDrawingUtils.ts new file mode 100644 index 0000000000..f767e58e5c --- /dev/null +++ b/src/composables/maskeditor/brushDrawingUtils.ts @@ -0,0 +1,247 @@ +import QuickLRU from '@alloc/quick-lru' + +import { hexToRgb, parseToRgb } from '@/utils/colorUtil' +import { BrushShape } from '@/extensions/core/maskeditor/types' +import type { Point } from '@/extensions/core/maskeditor/types' + +export type DirtyRect = { + minX: number + minY: number + maxX: number + maxY: number +} + +type MaskColor = { r: number; g: number; b: number } + +const brushTextureCache = new QuickLRU({ + maxSize: 20 +}) + +export function resetDirtyRect(): DirtyRect { + return { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } +} + +export function updateDirtyRect( + rect: DirtyRect, + x: number, + y: number, + radius: number +): DirtyRect { + const padding = 2 + return { + minX: Math.min(rect.minX, x - radius - padding), + minY: Math.min(rect.minY, y - radius - padding), + maxX: Math.max(rect.maxX, x + radius + padding), + maxY: Math.max(rect.maxY, y + radius + padding) + } +} + +function formatRgba(hex: string, alpha: number): string { + const { r, g, b } = hexToRgb(hex) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +export function premultiplyData(data: Uint8ClampedArray): void { + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3] / 255 + data[i] = Math.round(data[i] * a) + data[i + 1] = Math.round(data[i + 1] * a) + data[i + 2] = Math.round(data[i + 2] * a) + } +} + +function drawShapeOnContext( + ctx: CanvasRenderingContext2D, + brushType: BrushShape, + x: number, + y: number, + radius: number +): void { + ctx.beginPath() + if (brushType === BrushShape.Rect) { + ctx.rect(x - radius, y - radius, radius * 2, radius * 2) + } else { + ctx.arc(x, y, radius, 0, Math.PI * 2, false) + } + ctx.fill() +} + +function createBrushGradient( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, + hardness: number, + color: string, + opacity: number, + isErasing: boolean +): CanvasGradient { + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(radius)) { + return ctx.createRadialGradient(0, 0, 0, 0, 0, 0) + } + + const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius) + + if (isErasing) { + gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`) + gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity})`) + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`) + } else { + const { r, g, b } = parseToRgb(color) + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${opacity})`) + gradient.addColorStop(hardness, `rgba(${r}, ${g}, ${b}, ${opacity})`) + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`) + } + + return gradient +} + +function getCachedBrushTexture( + radius: number, + hardness: number, + color: string, + opacity: number +): HTMLCanvasElement { + const cacheKey = `${radius}_${hardness}_${color}` + const cached = brushTextureCache.get(cacheKey) + if (cached) return cached + + const size = Math.max(1, Math.ceil(radius * 2)) + if (!Number.isFinite(size)) { + throw new Error(`Invalid brush radius: ${radius}`) + } + + const tempCanvas = document.createElement('canvas') + tempCanvas.width = size + tempCanvas.height = size + const tempCtx = tempCanvas.getContext('2d') + if (!tempCtx) { + throw new Error('Unable to create 2D canvas context for brush texture') + } + + const centerX = size / 2 + const centerY = size / 2 + const hardRadius = radius * hardness + const imageData = tempCtx.createImageData(size, size) + const data = imageData.data + const { r, g, b } = parseToRgb(color) + const fadeRange = radius - hardRadius + + for (let y = 0; y < size; y++) { + const dy = y + 0.5 - centerY + for (let x = 0; x < size; x++) { + const dx = x + 0.5 - centerX + const index = (y * size + x) * 4 + const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy)) + + let pixelOpacity = 0 + if (distFromEdge <= hardRadius) { + pixelOpacity = opacity + } else if (distFromEdge <= radius) { + const fadeProgress = (distFromEdge - hardRadius) / fadeRange + pixelOpacity = opacity * Math.pow(1 - fadeProgress, 2) + } + + data[index] = r + data[index + 1] = g + data[index + 2] = b + data[index + 3] = pixelOpacity * 255 + } + } + + tempCtx.putImageData(imageData, 0, 0) + brushTextureCache.set(cacheKey, tempCanvas) + return tempCanvas +} + +export function drawRgbShape( + ctx: CanvasRenderingContext2D, + point: Point, + brushType: BrushShape, + brushRadius: number, + hardness: number, + opacity: number, + rgbColor: string +): void { + const { x, y } = point + + if (brushType === BrushShape.Rect && hardness < 1) { + const rgbaColor = formatRgba(rgbColor, opacity) + const brushTexture = getCachedBrushTexture( + brushRadius, + hardness, + rgbaColor, + opacity + ) + ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius) + return + } + + if (hardness === 1) { + ctx.fillStyle = formatRgba(rgbColor, opacity) + drawShapeOnContext(ctx, brushType, x, y, brushRadius) + return + } + + const gradient = createBrushGradient( + ctx, + x, + y, + brushRadius, + hardness, + rgbColor, + opacity, + false + ) + ctx.fillStyle = gradient + drawShapeOnContext(ctx, brushType, x, y, brushRadius) +} + +export function drawMaskShape( + ctx: CanvasRenderingContext2D, + point: Point, + brushType: BrushShape, + brushRadius: number, + hardness: number, + opacity: number, + isErasing: boolean, + maskColor: MaskColor +): void { + const { x, y } = point + + if (brushType === BrushShape.Rect && hardness < 1) { + const baseColor = isErasing + ? `rgba(255, 255, 255, ${opacity})` + : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` + const brushTexture = getCachedBrushTexture( + brushRadius, + hardness, + baseColor, + opacity + ) + ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius) + return + } + + if (hardness === 1) { + ctx.fillStyle = isErasing + ? `rgba(255, 255, 255, ${opacity})` + : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` + drawShapeOnContext(ctx, brushType, x, y, brushRadius) + return + } + + const maskColorHex = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})` + const gradient = createBrushGradient( + ctx, + x, + y, + brushRadius, + hardness, + maskColorHex, + opacity, + isErasing + ) + ctx.fillStyle = gradient + drawShapeOnContext(ctx, brushType, x, y, brushRadius) +} diff --git a/src/composables/maskeditor/useBrushDrawing.ts b/src/composables/maskeditor/useBrushDrawing.ts index 79d40a5d31..da786609ea 100644 --- a/src/composables/maskeditor/useBrushDrawing.ts +++ b/src/composables/maskeditor/useBrushDrawing.ts @@ -1,8 +1,7 @@ /// import { ref, watch, nextTick, onUnmounted } from 'vue' -import QuickLRU from '@alloc/quick-lru' import { debounce } from 'es-toolkit/compat' -import { hexToRgb, parseToRgb } from '@/utils/colorUtil' +import { parseToRgb } from '@/utils/colorUtil' import { getStorageValue, setStorageValue } from '@/scripts/utils' import { Tools, @@ -17,6 +16,14 @@ import { tgpu } from 'typegpu' import { GPUBrushRenderer } from './gpu/GPUBrushRenderer' import { StrokeProcessor } from './StrokeProcessor' import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils' +import { + resetDirtyRect, + updateDirtyRect, + premultiplyData, + drawRgbShape, + drawMaskShape +} from './brushDrawingUtils' +import type { DirtyRect } from './brushDrawingUtils' /** * Saves the brush settings to local storage with a debounce. @@ -77,80 +84,6 @@ export function useBrushDrawing(initialSettings?: { // Flag to prevent redundant GPU updates const isSavingHistory = ref(false) - // Brush texture cache - const brushTextureCache = new QuickLRU({ - maxSize: 20 - }) - - /** - * Retrieves a cached brush texture or creates a new one if not found. - * @param radius - The radius of the brush. - * @param hardness - The hardness of the brush (0 to 1). - * @param color - The color of the brush. - * @param opacity - The opacity of the brush (0 to 1). - * @returns The canvas element containing the brush texture. - */ - function getCachedBrushTexture( - radius: number, - hardness: number, - color: string, - opacity: number - ): HTMLCanvasElement { - const cacheKey = `${radius}_${hardness}_${color}_${opacity}` - - if (brushTextureCache.has(cacheKey)) { - return brushTextureCache.get(cacheKey)! - } - - // Use integer dimensions - const size = Math.ceil(radius * 2) - const tempCanvas = document.createElement('canvas') - const tempCtx = tempCanvas.getContext('2d')! - tempCanvas.width = size - tempCanvas.height = size - - const centerX = size / 2 - const centerY = size / 2 - const hardRadius = radius * hardness - - const imageData = tempCtx.createImageData(size, size) - const data = imageData.data - const { r, g, b } = parseToRgb(color) - - const fadeRange = radius - hardRadius - - for (let y = 0; y < size; y++) { - // Calculate distance from pixel center - const dy = y + 0.5 - centerY - for (let x = 0; x < size; x++) { - const dx = x + 0.5 - centerX - const index = (y * size + x) * 4 - - // Calculate Chebyshev distance - const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy)) - - let pixelOpacity = 0 - if (distFromEdge <= hardRadius) { - pixelOpacity = opacity - } else if (distFromEdge <= radius) { - const fadeProgress = (distFromEdge - hardRadius) / fadeRange - // Apply quadratic falloff - pixelOpacity = opacity * Math.pow(1 - fadeProgress, 2) - } - - data[index] = r - data[index + 1] = g - data[index + 2] = b - data[index + 3] = pixelOpacity * 255 - } - } - - tempCtx.putImageData(imageData, 0, 0) - brushTextureCache.set(cacheKey, tempCanvas) - - return tempCanvas - } - const isDrawing = ref(false) const isDrawingLine = ref(false) const lineStartPoint = ref(null) @@ -158,40 +91,7 @@ export function useBrushDrawing(initialSettings?: { const smoothingLastDrawTime = ref(new Date()) const initialDraw = ref(true) - // Dirty rectangle tracking - const dirtyRect = ref({ - minX: Infinity, - minY: Infinity, - maxX: -Infinity, - maxY: -Infinity - }) - - /** - * Resets the dirty rectangle to its initial infinite state. - */ - function resetDirtyRect() { - dirtyRect.value = { - minX: Infinity, - minY: Infinity, - maxX: -Infinity, - maxY: -Infinity - } - } - - /** - * Updates the dirty rectangle to include the specified area. - * @param x - The x-coordinate of the center. - * @param y - The y-coordinate of the center. - * @param radius - The radius of the area. - */ - function updateDirtyRect(x: number, y: number, radius: number) { - // Add padding for anti-aliasing - const padding = 2 - dirtyRect.value.minX = Math.min(dirtyRect.value.minX, x - radius - padding) - dirtyRect.value.minY = Math.min(dirtyRect.value.minY, y - radius - padding) - dirtyRect.value.maxX = Math.max(dirtyRect.value.maxX, x + radius + padding) - dirtyRect.value.maxY = Math.max(dirtyRect.value.maxY, y + radius + padding) - } + const dirtyRect = ref(resetDirtyRect()) // Stroke processor instance let strokeProcessor: StrokeProcessor | null = null @@ -410,19 +310,6 @@ export function useBrushDrawing(initialSettings?: { } } - /** - * Premultiplies the alpha of an ImageData array in place. - * @param data - The Uint8ClampedArray to modify. - */ - function premultiplyData(data: Uint8ClampedArray) { - for (let i = 0; i < data.length; i += 4) { - const a = data[i + 3] / 255 - data[i] = Math.round(data[i] * a) - data[i + 1] = Math.round(data[i + 1] * a) - data[i + 2] = Math.round(data[i + 2] * a) - } - } - /** * Updates the GPU textures from the current canvas state. */ @@ -538,11 +425,6 @@ export function useBrushDrawing(initialSettings?: { } } - /** - * Draws a shape on the appropriate canvas based on the current tool and layer. - * @param point - The center point of the shape. - * @param overrideOpacity - Optional opacity override. - */ function drawShape(point: Point, overrideOpacity?: number) { const brush = store.brushSettings const mask_ctx = store.maskCtx @@ -556,6 +438,12 @@ export function useBrushDrawing(initialSettings?: { const brushRadius = brush.size const hardness = brush.hardness const opacity = overrideOpacity ?? brush.opacity + const effectiveRadius = getEffectiveBrushSize(brushRadius, hardness) + const effectiveHardness = getEffectiveHardness( + brushRadius, + hardness, + effectiveRadius + ) const isErasing = mask_ctx.globalCompositeOperation === 'destination-out' const currentTool = store.currentTool @@ -566,244 +454,34 @@ export function useBrushDrawing(initialSettings?: { currentTool && (currentTool === Tools.Eraser || currentTool === Tools.PaintPen) ) { - // Calculate effective size and hardness - const effectiveRadius = getEffectiveBrushSize(brushRadius, hardness) - const effectiveHardness = getEffectiveHardness( - brushRadius, - hardness, - effectiveRadius - ) - drawRgbShape( rgb_ctx, point, brushType, effectiveRadius, effectiveHardness, - opacity + opacity, + store.rgbColor + ) + } else { + drawMaskShape( + mask_ctx, + point, + brushType, + effectiveRadius, + effectiveHardness, + opacity, + isErasing, + store.maskColor ) - return } - // Calculate effective size and hardness - const effectiveRadius = getEffectiveBrushSize(brushRadius, hardness) - const effectiveHardness = getEffectiveHardness( - brushRadius, - hardness, + dirtyRect.value = updateDirtyRect( + dirtyRect.value, + point.x, + point.y, effectiveRadius ) - - drawMaskShape( - mask_ctx, - point, - brushType, - effectiveRadius, - effectiveHardness, - opacity, - isErasing - ) - - updateDirtyRect(point.x, point.y, effectiveRadius) - } - - /** - * Draws a shape on the RGB canvas. - * @param ctx - The canvas rendering context. - * @param point - The center point. - * @param brushType - The type of brush (circle/rect). - * @param brushRadius - The radius of the brush. - * @param hardness - The hardness of the brush. - * @param opacity - The opacity of the brush. - */ - function drawRgbShape( - ctx: CanvasRenderingContext2D, - point: Point, - brushType: BrushShape, - brushRadius: number, - hardness: number, - opacity: number - ): void { - const { x, y } = point - const rgbColor = store.rgbColor - - if (brushType === BrushShape.Rect && hardness < 1) { - const rgbaColor = formatRgba(rgbColor, opacity) - const brushTexture = getCachedBrushTexture( - brushRadius, - hardness, - rgbaColor, - opacity - ) - ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius) - updateDirtyRect(x, y, brushRadius) - return - } - - if (hardness === 1) { - const rgbaColor = formatRgba(rgbColor, opacity) - ctx.fillStyle = rgbaColor - drawShapeOnContext(ctx, brushType, x, y, brushRadius) - updateDirtyRect(x, y, brushRadius) - return - } - - const gradient = createBrushGradient( - ctx, - x, - y, - brushRadius, - hardness, - rgbColor, - opacity, - false - ) - ctx.fillStyle = gradient - drawShapeOnContext(ctx, brushType, x, y, brushRadius) - updateDirtyRect(x, y, brushRadius) - } - - /** - * Draws a shape on the Mask canvas. - * @param ctx - The canvas rendering context. - * @param point - The center point. - * @param brushType - The type of brush (circle/rect). - * @param brushRadius - The radius of the brush. - * @param hardness - The hardness of the brush. - * @param opacity - The opacity of the brush. - * @param isErasing - Whether the operation is erasing. - */ - function drawMaskShape( - ctx: CanvasRenderingContext2D, - point: Point, - brushType: BrushShape, - brushRadius: number, - hardness: number, - opacity: number, - isErasing: boolean - ): void { - const { x, y } = point - const maskColor = store.maskColor - - if (brushType === BrushShape.Rect && hardness < 1) { - const baseColor = isErasing - ? `rgba(255, 255, 255, ${opacity})` - : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` - - const brushTexture = getCachedBrushTexture( - brushRadius, - hardness, - baseColor, - opacity - ) - ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius) - updateDirtyRect(x, y, brushRadius) - return - } - - if (hardness === 1) { - ctx.fillStyle = isErasing - ? `rgba(255, 255, 255, ${opacity})` - : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` - drawShapeOnContext(ctx, brushType, x, y, brushRadius) - updateDirtyRect(x, y, brushRadius) - return - } - - const maskColorHex = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})` - const gradient = createBrushGradient( - ctx, - x, - y, - brushRadius, - hardness, - maskColorHex, - opacity, - isErasing - ) - ctx.fillStyle = gradient - drawShapeOnContext(ctx, brushType, x, y, brushRadius) - updateDirtyRect(x, y, brushRadius) - } - - /** - * Helper to draw the path of the shape on the context. - * @param ctx - The canvas rendering context. - * @param brushType - The type of brush. - * @param x - Center x. - * @param y - Center y. - * @param radius - Radius. - */ - function drawShapeOnContext( - ctx: CanvasRenderingContext2D, - brushType: BrushShape, - x: number, - y: number, - radius: number - ): void { - ctx.beginPath() - if (brushType === BrushShape.Rect) { - ctx.rect(x - radius, y - radius, radius * 2, radius * 2) - } else { - ctx.arc(x, y, radius, 0, Math.PI * 2, false) - } - ctx.fill() - } - - /** - * Formats a hex color and alpha into an rgba string. - * @param hex - The hex color string. - * @param alpha - The alpha value (0-1). - * @returns The rgba string. - */ - function formatRgba(hex: string, alpha: number): string { - const { r, g, b } = hexToRgb(hex) - return `rgba(${r}, ${g}, ${b}, ${alpha})` - } - - /** - * Creates a radial gradient for soft brushes. - * @param ctx - The canvas context. - * @param x - Center x. - * @param y - Center y. - * @param radius - Radius. - * @param hardness - Hardness (0-1). - * @param color - Color string. - * @param opacity - Opacity (0-1). - * @param isErasing - Whether erasing. - * @returns The canvas gradient. - */ - function createBrushGradient( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - radius: number, - hardness: number, - color: string, - opacity: number, - isErasing: boolean - ): CanvasGradient { - if ( - !Number.isFinite(x) || - !Number.isFinite(y) || - !Number.isFinite(radius) - ) { - return ctx.createRadialGradient(0, 0, 0, 0, 0, 0) - } - - const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius) - - if (isErasing) { - gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`) - gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity})`) - gradient.addColorStop(1, `rgba(255, 255, 255, 0)`) - } else { - const { r, g, b } = parseToRgb(color) - gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${opacity})`) - gradient.addColorStop(hardness, `rgba(${r}, ${g}, ${b}, ${opacity})`) - gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`) - } - - return gradient } /** @@ -904,7 +582,7 @@ export function useBrushDrawing(initialSettings?: { */ async function startDrawing(event: PointerEvent): Promise { isDrawing.value = true - resetDirtyRect() + dirtyRect.value = resetDirtyRect() try { // Initialize stroke accumulator @@ -1531,9 +1209,13 @@ export function useBrushDrawing(initialSettings?: { brushShape }) - // Update Dirty Rect for (const p of strokePoints) { - updateDirtyRect(p.x, p.y, effectiveSize) + dirtyRect.value = updateDirtyRect( + dirtyRect.value, + p.x, + p.y, + effectiveSize + ) } // 3. Blit to Preview with correct settings From ac728b92ae60606943abcf17db0d41ea66074e2b Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:29:08 +0100 Subject: [PATCH 055/460] fix: fix webcam node not showing preview in nodes 2.0 (#11549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds test coverage for webcam node & fixes issue found in testing where the captured image does not show in nodes 2.0 ## Changes - **What**: - call `setNodePreviewsByNodeId` alongside `node.imgs = [img]` - add tests for general coverage ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11549-fix-fix-webcam-node-not-showing-preview-in-nodes-2-0-34a6d73d3650810c89eee9c25cd07700) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- .../assets/nodes/webcam_capture.json | 58 +++ .../assets/nodes/webcam_capture_preset.json | 58 +++ browser_tests/tests/webcamCapture.spec.ts | 355 ++++++++++++++++++ .../boundingbox/WidgetBoundingBox.test.ts | 1 - .../graph/widgets/MultiSelectWidget.test.ts | 1 - .../graph/widgets/TextPreviewWidget.test.ts | 1 - .../imagecrop/WidgetImageCrop.test.ts | 1 - src/components/range/WidgetRange.test.ts | 1 - src/extensions/core/webcamCapture.ts | 3 + 9 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 browser_tests/assets/nodes/webcam_capture.json create mode 100644 browser_tests/assets/nodes/webcam_capture_preset.json create mode 100644 browser_tests/tests/webcamCapture.spec.ts diff --git a/browser_tests/assets/nodes/webcam_capture.json b/browser_tests/assets/nodes/webcam_capture.json new file mode 100644 index 0000000000..1bddacdb89 --- /dev/null +++ b/browser_tests/assets/nodes/webcam_capture.json @@ -0,0 +1,58 @@ +{ + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 1, + "type": "WebcamCapture", + "pos": [200, 160], + "size": [400, 360], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [1] + } + ], + "properties": { + "Node name for S&R": "WebcamCapture" + }, + "widgets_values": [null, 0, 0, true] + }, + { + "id": 2, + "type": "PreviewImage", + "pos": [700, 160], + "size": [210, 250], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + } + ], + "links": [[1, 1, 0, 2, 0, "IMAGE"]], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + } + }, + "version": 0.4 +} diff --git a/browser_tests/assets/nodes/webcam_capture_preset.json b/browser_tests/assets/nodes/webcam_capture_preset.json new file mode 100644 index 0000000000..04bda94cdc --- /dev/null +++ b/browser_tests/assets/nodes/webcam_capture_preset.json @@ -0,0 +1,58 @@ +{ + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 1, + "type": "WebcamCapture", + "pos": [200, 160], + "size": [400, 360], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [1] + } + ], + "properties": { + "Node name for S&R": "WebcamCapture" + }, + "widgets_values": [null, 123, 456, true] + }, + { + "id": 2, + "type": "PreviewImage", + "pos": [700, 160], + "size": [210, 250], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + } + ], + "links": [[1, 1, 0, 2, 0, "IMAGE"]], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [0, 0] + } + }, + "version": 0.4 +} diff --git a/browser_tests/tests/webcamCapture.spec.ts b/browser_tests/tests/webcamCapture.spec.ts new file mode 100644 index 0000000000..69eaa824b7 --- /dev/null +++ b/browser_tests/tests/webcamCapture.spec.ts @@ -0,0 +1,355 @@ +import type { + PromptResponse, + UploadImageResponse +} from '@comfyorg/ingest-types' +import { expect } from '@playwright/test' +import type { Locator, Page, Request } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' + +const NODE_TITLE = 'Webcam Capture' + +function denyCameraAccess(page: Page): Promise { + return page.evaluate(() => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: () => + Promise.reject(new Error('Permission denied by test')) + } + }) + }) +} + +function holdCameraAccess(page: Page): Promise { + return page.evaluate(() => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia: () => new Promise(() => {}) } + }) + }) +} + +function denyAccessInInsecureContext(page: Page): Promise { + return page.evaluate(() => { + Object.defineProperty(window, 'isSecureContext', { + configurable: true, + value: false + }) + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: () => + Promise.reject(new Error('Insecure context rejection')) + } + }) + }) +} + +async function parseMultipartRequest(request: Request): Promise { + const body = request.postDataBuffer() + if (!body) throw new Error('request has no body') + return new Response(new Uint8Array(body), { + headers: { 'content-type': request.headers()['content-type'] ?? '' } + }).formData() +} + +/** + * Stub /upload/image + /api/prompt so queueing succeeds without a backend. + * Returns the mutable list of captured upload requests - callers poll its + * length to wait for the upload to fire. + */ +async function captureUploadsDuringQueue(page: Page): Promise { + const uploadRequests: Request[] = [] + const uploadResponse: UploadImageResponse = { + name: 'captured.png', + subfolder: 'webcam', + type: 'temp' + } + const promptResponse: PromptResponse = { + prompt_id: 'test', + number: 1, + node_errors: {} + } + await page.route('**/upload/image', (route) => { + uploadRequests.push(route.request()) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(uploadResponse) + }) + }) + await page.route('**/api/prompt', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(promptResponse) + }) + ) + return uploadRequests +} + +class WebcamCaptureFixture { + public readonly node: Locator + public readonly captureButton: Locator + public readonly waitingButton: Locator + public readonly errorMessage: Locator + public readonly widthInput: Locator + public readonly heightInput: Locator + public readonly captureOnQueueToggle: Locator + public readonly previewImage: Locator + + constructor(comfyPage: ComfyPage) { + const { vueNodes } = comfyPage + this.node = vueNodes.getNodeByTitle(NODE_TITLE) + this.captureButton = this.node.getByRole('button', { + name: 'capture', + exact: true + }) + this.waitingButton = this.node.getByRole('button', { + name: 'waiting for camera...', + exact: true + }) + this.errorMessage = this.node.getByText('Unable to load webcam') + this.widthInput = vueNodes.getInputNumberControls( + vueNodes.getWidgetByName(NODE_TITLE, 'width') + ).input + this.heightInput = vueNodes.getInputNumberControls( + vueNodes.getWidgetByName(NODE_TITLE, 'height') + ).input + this.captureOnQueueToggle = this.node.getByRole('switch', { + name: 'capture_on_queue' + }) + this.previewImage = this.node.locator('img[src^="data:image/png"]').first() + } + + async waitForStreamReady(): Promise { + await expect(this.captureButton).toBeEnabled() + } +} + +test.use({ + launchOptions: { + args: [ + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream' + ] + } +}) + +test.describe( + 'Webcam Capture', + { tag: ['@widget', '@canvas', '@vue-nodes'] }, + () => { + test('enables the capture button once the stream is ready', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + + await expect(webcam.captureButton).toBeEnabled() + await expect(webcam.waitingButton).toBeHidden() + await expect(webcam.errorMessage).toBeHidden() + }) + + test('auto-populates width and height from the video stream', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + + // Workflow ships with width/height set to 0; the extension overwrites + // them with the resolved track's intrinsic resolution once the stream + // loads so users aren't left with a 0x0 capture surface. + await webcam.waitForStreamReady() + await expect(webcam.widthInput).not.toHaveValue('0') + await expect(webcam.heightInput).not.toHaveValue('0') + }) + + test('shows the waiting state while the permission prompt is pending', async ({ + comfyPage + }) => { + await holdCameraAccess(comfyPage.page) + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + + await expect(webcam.waitingButton).toBeDisabled() + await expect(webcam.captureButton).toBeHidden() + await expect(webcam.errorMessage).toBeHidden() + }) + + test('surfaces the underlying rejection reason when denied', async ({ + comfyPage + }) => { + await denyCameraAccess(comfyPage.page) + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + + await expect(webcam.errorMessage).toBeVisible() + await expect( + webcam.errorMessage.filter({ hasText: 'Permission denied by test' }) + ).toBeVisible() + await expect(webcam.waitingButton).toBeDisabled() + await expect(webcam.captureButton).toBeHidden() + }) + + test('auto-captures and uploads a frame when queued with capture-on-queue', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + await webcam.waitForStreamReady() + await expect( + webcam.captureOnQueueToggle, + 'workflow asset ships with capture_on_queue enabled' + ).toBeChecked() + + const uploads = await captureUploadsDuringQueue(comfyPage.page) + await comfyPage.runButton.click() + + await expect.poll(() => uploads.length).toBeGreaterThan(0) + const form = await parseMultipartRequest(uploads[0]) + expect(form.get('subfolder')).toBe('webcam') + expect(form.get('type')).toBe('temp') + expect(form.get('image')).toBeInstanceOf(Blob) + }) + + test('renders a preview image inside the node after clicking capture', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + await webcam.waitForStreamReady() + + await expect(webcam.previewImage).toBeHidden() + await webcam.captureButton.click() + await expect(webcam.previewImage).toBeVisible() + }) + + test('re-clicking capture replaces the preview with a fresh frame', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + await webcam.waitForStreamReady() + + await webcam.captureButton.click() + await expect(webcam.previewImage).toBeVisible() + const firstSrc = await webcam.previewImage.getAttribute('src') + + // Chromium's fake device cycles frame content, so a second capture a + // moment later must produce a different data URL. + await expect + .poll(async () => { + await webcam.captureButton.click() + return webcam.previewImage.getAttribute('src') + }) + .not.toBe(firstSrc) + }) + + test('uploads the manually captured frame when queued with capture-on-queue off', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + await webcam.waitForStreamReady() + await webcam.captureOnQueueToggle.click() + await expect( + webcam.captureOnQueueToggle, + 'precondition: capture_on_queue toggled off' + ).not.toBeChecked() + + await webcam.captureButton.click() + await expect(webcam.previewImage).toBeVisible() + + const uploads = await captureUploadsDuringQueue(comfyPage.page) + await comfyPage.runButton.click() + await expect.poll(() => uploads.length).toBeGreaterThan(0) + const form = await parseMultipartRequest(uploads[0]) + const uploaded = form.get('image') + if (!(uploaded instanceof Blob)) + throw new Error('uploaded image is not a Blob') + const uploadedBase64 = Buffer.from(await uploaded.arrayBuffer()).toString( + 'base64' + ) + await expect(webcam.previewImage).toHaveAttribute( + 'src', + `data:image/png;base64,${uploadedBase64}` + ) + }) + + test('explains the secure-context requirement on insecure origins', async ({ + comfyPage + }) => { + await denyAccessInInsecureContext(comfyPage.page) + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + + await expect( + webcam.errorMessage.filter({ hasText: 'secure context is required' }) + ).toBeVisible() + await expect( + webcam.errorMessage.filter({ hasText: 'Insecure context rejection' }) + ).toBeVisible() + }) + + test('preserves user-set width and height across stream ready', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture_preset') + const webcam = new WebcamCaptureFixture(comfyPage) + await webcam.waitForStreamReady() + + await expect(webcam.widthInput).toHaveValue('123') + await expect(webcam.heightInput).toHaveValue('456') + }) + + test('surfaces an error toast when the upload fails', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + await webcam.waitForStreamReady() + + await comfyPage.page.route('**/upload/image', (route) => + route.fulfill({ status: 500, body: 'Server exploded' }) + ) + await comfyPage.runButton.click() + + await expect( + comfyPage.toast.visibleToasts + .filter({ hasText: 'Error uploading camera image' }) + .first() + ).toBeVisible() + }) + + test('alerts the user when queued with no captured image', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('nodes/webcam_capture') + const webcam = new WebcamCaptureFixture(comfyPage) + await webcam.waitForStreamReady() + await webcam.captureOnQueueToggle.click() + await expect( + webcam.captureOnQueueToggle, + 'precondition: capture_on_queue toggled off' + ).not.toBeChecked() + + let uploadCalled = false + await comfyPage.page.route('**/upload/image', (route) => { + uploadCalled = true + return route.fulfill({ status: 200, body: '{}' }) + }) + + await comfyPage.runButton.click() + + await expect( + comfyPage.toast.visibleToasts + .filter({ hasText: 'No webcam image captured' }) + .first() + ).toBeVisible() + expect(uploadCalled).toBe(false) + }) + } +) diff --git a/src/components/boundingbox/WidgetBoundingBox.test.ts b/src/components/boundingbox/WidgetBoundingBox.test.ts index e075440791..6c172aae10 100644 --- a/src/components/boundingbox/WidgetBoundingBox.test.ts +++ b/src/components/boundingbox/WidgetBoundingBox.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ import { render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' diff --git a/src/components/graph/widgets/MultiSelectWidget.test.ts b/src/components/graph/widgets/MultiSelectWidget.test.ts index 11ced4429d..b2cd38c630 100644 --- a/src/components/graph/widgets/MultiSelectWidget.test.ts +++ b/src/components/graph/widgets/MultiSelectWidget.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ import { render, screen } from '@testing-library/vue' import PrimeVue from 'primevue/config' import { describe, expect, it } from 'vitest' diff --git a/src/components/graph/widgets/TextPreviewWidget.test.ts b/src/components/graph/widgets/TextPreviewWidget.test.ts index 316eddc3d4..458b46a0cf 100644 --- a/src/components/graph/widgets/TextPreviewWidget.test.ts +++ b/src/components/graph/widgets/TextPreviewWidget.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ import { render, screen } from '@testing-library/vue' import PrimeVue from 'primevue/config' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/components/imagecrop/WidgetImageCrop.test.ts b/src/components/imagecrop/WidgetImageCrop.test.ts index c78a46da7b..4835aabc54 100644 --- a/src/components/imagecrop/WidgetImageCrop.test.ts +++ b/src/components/imagecrop/WidgetImageCrop.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ /* eslint-disable vue/no-reserved-component-names */ import { render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' diff --git a/src/components/range/WidgetRange.test.ts b/src/components/range/WidgetRange.test.ts index d9bdec434b..30272cf2bf 100644 --- a/src/components/range/WidgetRange.test.ts +++ b/src/components/range/WidgetRange.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/one-component-per-file */ import { render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/extensions/core/webcamCapture.ts b/src/extensions/core/webcamCapture.ts index 1350d06a77..5f792215c5 100644 --- a/src/extensions/core/webcamCapture.ts +++ b/src/extensions/core/webcamCapture.ts @@ -1,6 +1,7 @@ import { t } from '@/i18n' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { useToastStore } from '@/platform/updates/common/toastStore' +import { useNodeOutputStore } from '@/stores/nodeOutputStore' import { api } from '../../scripts/api' import { app } from '../../scripts/app' @@ -84,6 +85,7 @@ app.registerExtension({ ) const canvas = document.createElement('canvas') + const nodeOutputStore = useNodeOutputStore() const capture = () => { // @ts-expect-error widget value type narrow down @@ -98,6 +100,7 @@ app.registerExtension({ const img = new Image() img.onload = () => { node.imgs = [img] + nodeOutputStore.setNodePreviewsByNodeId(node.id, [data]) app.canvas.setDirty(true) } img.src = data From a9efd4de6217c6f4dbfc375a01e738b764aa7fe7 Mon Sep 17 00:00:00 2001 From: guill Date: Wed, 22 Apr 2026 13:34:38 -0700 Subject: [PATCH 056/460] fix: render edit pencil icon correctly in properties panel header (#11487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *PR Created by the Glary-Bot Agent* --- ## Summary The edit pencil button next to the selected node's name at the top of the properties panel (rightSidePanel) rendered as a dark filled square instead of a pencil icon. ## Root cause The button was given `size-4` (16×16) while the inner iconify `` was also 16×16. The icon overflowed the button and was clipped, and `content-center` has no effect on a default `