Compare commits

..

1 Commits

Author SHA1 Message Date
Robin Huang
46ceec8021 feat: add custom nodes waitlist survey to Manager button on Cloud
Show the Manager button on Comfy Cloud and open a hosted survey in the
manager modal. The hosted survey URL is provided per environment by cloud
config (manager_survey_url) and embedded via iframe, with the logged-in
user's distinct_id appended so responses link to the user.
2026-06-24 20:28:33 -07:00
185 changed files with 1578 additions and 7291 deletions

View File

@@ -88,9 +88,9 @@ jobs:
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
'assets/images/*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
@@ -121,8 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--synthesize-missing
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

View File

@@ -1,63 +0,0 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

View File

@@ -47,11 +47,6 @@ test.describe('Download page @smoke', () => {
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
await expect(downloadBtn).toHaveAttribute(
'href',
'https://comfy.org/download/windows/nsis/x64'
)
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
@@ -78,7 +73,7 @@ test.describe('Download page @smoke', () => {
})
const windowsBtn = hero.locator(
'a[href="https://comfy.org/download/windows/nsis/x64"]'
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)

View File

@@ -72,7 +72,6 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
windows: 'https://comfy.org/download/windows/nsis/x64',
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const

View File

@@ -56,16 +56,12 @@ class ComfyPropertiesPanel {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
readonly toggleButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
this.toggleButton = page.getByRole('button', {
name: 'Toggle properties panel'
})
}
}

View File

@@ -112,10 +112,6 @@ export const TestIds = {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
assets: {
browserModal: 'asset-browser-modal',
card: 'asset-card'
},
subgraphEditor: {
hiddenSection: 'subgraph-editor-hidden-section',
iconEye: 'icon-eye',

View File

@@ -1,165 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* Billing facade consumers — FE-933 (B3) regression.
*
* The repointed surfaces (avatar popover tier badge / balance, free-tier
* dialog renewal date) must keep rendering from `useBillingContext`, which in
* a personal workspace routes through the legacy `/customers/*` endpoints
* (mocked here). Drives a raw `page` (not the `comfyPage` fixture) so the
* cloud app boots against fully mocked endpoints — same pattern as
* creditsTile.spec.ts. `team_workspaces_enabled: false` keeps the topbar on
* the legacy popover variant that FE-933 repointed.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay would otherwise intercept clicks on the topbar.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace: keeps the billing facade on the legacy
// `/customers/*` path when team workspaces are enabled.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
})
)
)
}
async function bootApp(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
}
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
test('avatar popover renders tier badge and balance from the facade', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page, {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
})
await bootApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
await expect(popover.getByText('12,660')).toBeVisible()
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
})
test('free-tier dialog shows the renewal date from the facade', async ({
page
}) => {
test.setTimeout(60_000)
// Boots with team workspaces enabled (production shape); the facade still
// routes a personal workspace through `/customers/*`. With subscription
// gating on, an inactive FREE user gets the "Subscribe to run" button,
// which opens the free-tier dialog on click. (refreshRemoteConfig
// overwrites window.__CONFIG__ from /api/features, so the flags must come
// from the features mock, not an init script.)
await mockCloudBoot(
page,
{
is_active: false,
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
// 10:00Z keeps the en-US calendar date stable across CI timezones.
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
},
{ team_workspaces_enabled: true, subscription_required: true }
)
await bootApp(page)
await page.getByTestId('subscribe-to-run-button').click()
// T5: the dialog must source the date from facade renewalDate — when this
// line read the legacy store it silently vanished for team users.
await expect(
page.getByText('Your credits refresh on Feb 20, 2099.')
).toBeVisible()
})
})

View File

@@ -223,23 +223,4 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
test('should focus keybindings search when opening manage shortcuts', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
await expect(
comfyPage.page.getByPlaceholder('Search Keybindings...')
).toBeFocused()
await expect(
comfyPage.page.getByPlaceholder('Search Settings...')
).not.toBeFocused()
})
})

View File

@@ -1,99 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
const WORKFLOW = 'missing/missing_model_promoted_widget'
const HOST_NODE_ID = 2
const WIDGET_NAME = 'ckpt_name'
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
interface WidgetSnapshot {
type: string
value: string
hasLayout: boolean
}
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
return await page.evaluate(
({ nodeId, widgetName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
return {
type: widget?.type ?? '',
value: String(widget?.value ?? ''),
hasLayout: widget?.last_y != null
}
},
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
)
}
test.describe(
'Promoted subgraph asset widgets',
{ tag: ['@cloud', '@canvas', '@widget'] },
() => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('legacy asset browser selection updates the promoted host widget value', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await expect
.poll(
() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
),
{ timeout: 10_000 }
)
.toBe(true)
await expect
.poll(() => getHostWidgetSnapshot(comfyPage.page))
.toMatchObject({
type: 'asset',
hasLayout: true
})
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
await hostNode.centerOnNode()
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
await promotedWidget.click()
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
await expect(modal).toBeVisible()
const assetCard = modal
.getByTestId(TestIds.assets.card)
.filter({ hasText: SELECTED_MODEL })
.first()
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use' }).click()
await expect(modal).toBeHidden()
await expect
.poll(() =>
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
)
.toBe(SELECTED_MODEL)
})
}
)

View File

@@ -1,9 +1,6 @@
import type { ConsoleMessage } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
@@ -98,225 +95,4 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
// widening the race window so a guard regression deterministically surfaces.
async function deferLegacyHandlers(comfyPage: ComfyPage) {
return await comfyPage.page.evaluateHandle(() => {
const graph = window.app!.graph!
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const originalNodeRemoved = graph.onNodeRemoved
const originalSelectionChange = canvas.onSelectionChange
graph.onNodeRemoved = function (node) {
queue.push(() => originalNodeRemoved?.call(this, node))
}
canvas.onSelectionChange = function (selected) {
queue.push(() => originalSelectionChange?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
graph.onNodeRemoved = originalNodeRemoved
canvas.onSelectionChange = originalSelectionChange
}
}
})
}
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
// Defers only the legacy selection-change callback, so the detached host
// node lingers in the reactive selection while onNodeRemoved still runs
// normally and clears it from the canvas. This isolates the panel render
// path: a panel mounted during this window reads the stale selection.
async function deferSelectionChange(
comfyPage: ComfyPage
): Promise<DeferredHandlers> {
return await comfyPage.page.evaluateHandle(() => {
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const original = canvas.onSelectionChange
canvas.onSelectionChange = function (selected) {
queue.push(() => original?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
canvas.onSelectionChange = original
}
}
})
}
function isNullGraphErrorText(text: string): boolean {
return text.includes('NullGraphError') || text.endsWith('has no graph')
}
// Vue's default errorHandler routes render throws to console.error,
// not pageerror - listen to both.
function captureNullGraphErrors(comfyPage: ComfyPage) {
const captured: string[] = []
const onPageError = (err: Error) => {
if (
err.name === 'NullGraphError' ||
isNullGraphErrorText(err.message ?? '')
) {
captured.push(`pageerror ${err.name}: ${err.message}`)
}
}
const onConsoleMessage = (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (isNullGraphErrorText(text)) {
captured.push(`console.error: ${text}`)
}
}
comfyPage.page.on('pageerror', onPageError)
comfyPage.page.on('console', onConsoleMessage)
return {
getErrors: () => [...captured],
stop: () => {
comfyPage.page.off('pageerror', onPageError)
comfyPage.page.off('console', onConsoleMessage)
}
}
}
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await comfyPage.contextMenu.openForVueNode(fixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
}
async function reopenRightSidePanel(comfyPage: ComfyPage) {
const { propertiesPanel } = comfyPage.menu
await propertiesPanel.toggleButton.click()
await expect(propertiesPanel.root).toBeHidden()
await propertiesPanel.toggleButton.click()
await comfyPage.nextFrame()
}
// Unpacks the subgraph behind deferred teardown, runs an optional
// interaction while the node is detached but not yet cleaned up, then
// drains the deferred handlers and reports any NullGraphErrors seen.
async function unpackAndCaptureNullGraphErrors(
comfyPage: ComfyPage,
options: {
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
}
): Promise<string[]> {
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
const errors = captureNullGraphErrors(comfyPage)
const deferred = await options.defer(comfyPage)
try {
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toHaveCount(0)
await options.duringWindow?.(comfyPage)
await deferred.evaluate((handlers) => handlers.drain())
// Let drained-handler reactive flushes settle before stop().
await comfyPage.nextFrame()
return errors.getErrors()
} finally {
await deferred.evaluate((handlers) => handlers.restore())
await deferred.dispose()
errors.stop()
}
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toBeVisible()
const fixture =
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
await fixture.header.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await comfyPage.nextFrame()
})
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'LGraphNode render path: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'SubgraphEditor panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'SubgraphEditor remount: stale selection must not surface NullGraphError'
).toEqual([])
})
})
})

View File

@@ -335,30 +335,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
})
test('pointerCancel stops autopan', async ({ comfyPage }) => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.header.click({ trial: true })
await comfyPage.page.mouse.down()
const getOffset = () => comfyPage.canvasOps.getOffset()
const initialOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
await test.step('move outside pan range and cancel drag', async () => {
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
await ksampler.header.evaluate((node) =>
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
)
})
const secondaryOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await comfyPage.nextFrame()
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -19,10 +19,7 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",

View File

@@ -13,7 +13,7 @@
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
@@ -163,6 +163,7 @@ import {
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useManagerSurveyDialog } from '@/workbench/extensions/manager/composables/useManagerSurveyDialog'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@comfyorg/tailwind-utils'
@@ -170,6 +171,7 @@ const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const managerSurveyDialog = useManagerSurveyDialog()
const { flags } = useFeatureFlags()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
@@ -337,6 +339,10 @@ onBeforeUnmount(() => {
})
const openCustomNodeManager = async () => {
if (isCloud) {
managerSurveyDialog.show()
return
}
try {
await managerState.openManager({
initialTab: ManagerTab.All,

View File

@@ -1,224 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
import boundingBoxes from '@/locales/en/main.json'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBoxes: boundingBoxes.boundingBoxes,
palette: { swatchTitle: 'Edit', addColor: 'Add' }
}
}
})
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
const fakeCtx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function prepCanvas(canvas: HTMLCanvasElement) {
Object.defineProperty(canvas, 'clientWidth', {
value: 100,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 100,
configurable: true
})
canvas.getContext = (() =>
fakeCtx) as unknown as HTMLCanvasElement['getContext']
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
canvas.setPointerCapture = () => {}
canvas.releasePointerCapture = () => {}
}
function renderWidget(modelValue: BoundingBox[]) {
const result = render(WidgetBoundingBoxes, {
props: { nodeId: '1', modelValue },
global: { plugins: [i18n] }
})
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
prepCanvas(canvas)
return { ...result, canvas }
}
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0] as BoundingBox[]
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('WidgetBoundingBoxes', () => {
it('renders the canvas and editor shell', () => {
renderWidget([])
expect(
screen.getByTestId('bounding-boxes').querySelector('canvas')
).not.toBeNull()
})
it('shows the region editor panel when a region is active', () => {
renderWidget([box()])
expect(screen.getByText('obj')).toBeTruthy()
expect(screen.getByText('text')).toBeTruthy()
})
it('reveals the text field after switching the region to text', async () => {
renderWidget([box()])
expect(
screen.queryByPlaceholderText('text to render (verbatim)')
).toBeNull()
await userEvent.click(screen.getByText('text'))
expect(
screen.getByPlaceholderText('text to render (verbatim)')
).toBeTruthy()
})
it('clears all regions via the clear button', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('Clear all'))
expect(lastBoxes(emitted)).toEqual([])
})
it('draws a region through canvas pointer events', async () => {
const { canvas, emitted } = renderWidget([])
await fireEvent.pointerDown(canvas, {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 1
})
await fireEvent.pointerMove(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
await fireEvent.pointerUp(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
expect(lastBoxes(emitted)).toHaveLength(1)
})
it('tracks focus and blur on the canvas', async () => {
const { canvas } = renderWidget([box()])
await fireEvent.focus(canvas)
await fireEvent.blur(canvas)
expect(canvas).toBeTruthy()
})
it('opens an inline editor on double click', async () => {
const { canvas, container } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
expect(container.querySelector('textarea')).not.toBeNull()
})
it('syncs description edits back to the model', async () => {
const { emitted } = renderWidget([box()])
await fireEvent.update(
screen.getByPlaceholderText('description of this region'),
'a caption'
)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
})
it('edits the text field once the region is a text region', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('text'))
await fireEvent.update(
screen.getByPlaceholderText('text to render (verbatim)'),
'hello'
)
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
})
it('deletes the active region with the Delete key', async () => {
const { canvas, emitted } = renderWidget([box()])
await fireEvent.keyDown(canvas, { key: 'Delete' })
expect(lastBoxes(emitted)).toEqual([])
})
it('clears hover state on pointer leave', async () => {
const { canvas } = renderWidget([
box({ x: 10, y: 10, width: 256, height: 256 })
])
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
await fireEvent.pointerLeave(canvas)
expect(canvas).toBeTruthy()
})
it('commits the inline editor on blur', async () => {
const { canvas, container, emitted } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
const editor = container.querySelector('textarea')!
await fireEvent.update(editor, 'committed')
await fireEvent.blur(editor)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
})
})

View File

@@ -1,181 +0,0 @@
<template>
<div
class="widget-expands flex size-full flex-col gap-1 select-none"
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
<div
v-if="activeRegion"
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'obj'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('obj')"
>
{{ $t('boundingBoxes.typeObj') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'text'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('text')"
>
{{ $t('boundingBoxes.typeText') }}
</Button>
</div>
<div
v-if="activeRegion.type === 'text'"
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.textLabel') }}
</span>
<Textarea
v-model="activeRegion.text"
:placeholder="$t('boundingBoxes.textPlaceholder')"
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.descLabel') }}
</span>
<Textarea
v-model="activeRegion.desc"
:placeholder="$t('boundingBoxes.descPlaceholder')"
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div class="flex items-center gap-2">
<span class="shrink-0 truncate text-sm text-muted-foreground">
{{ $t('boundingBoxes.colors') }}
</span>
<PaletteSwatchRow
v-model="activeRegion.palette"
:max="maxColors"
@update:model-value="syncState"
/>
</div>
</div>
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { nodeId } = defineProps<{ nodeId: string }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
const {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
</script>

View File

@@ -427,6 +427,7 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
@@ -452,14 +453,16 @@ onMounted(() => {
// Wrap onClose to track session end
const onClose = () => {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
}
originalOnClose()
}

View File

@@ -8,7 +8,6 @@
v-model="filters['global'].value"
class="max-w-96"
size="lg"
autofocus
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"

View File

@@ -1,126 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HdrViewerContent from './HdrViewerContent.vue'
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
vi.mock('@/composables/useHdrViewer', () => ({
useHdrViewer: () => holder.viewer,
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading', downloadImage: 'Download' },
hdrViewer: {
failedToLoad: 'Failed',
exposure: 'Exposure',
normalizeExposure: 'Auto exposure',
channel: 'Channel',
channels: {
rgb: 'RGB',
r: 'R',
g: 'G',
b: 'B',
a: 'Alpha',
luminance: 'Luminance'
},
sourceGamut: 'Source gamut',
dither: 'Dither',
clipWarnings: 'Clip warnings',
fitView: 'Fit',
histogram: 'Histogram',
resolution: 'Resolution',
min: 'Min',
max: 'Max',
mean: 'Mean',
stdDev: 'Std dev',
nan: 'NaN',
inf: 'Inf'
}
}
}
})
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
exposureStops: ref(0),
dither: ref(true),
clipWarnings: ref(false),
gamut: ref('sRGB'),
channel: ref('r'),
loading: ref(false),
error: ref(null),
dimensions: ref('512 x 512'),
stats: ref({
min: 0,
max: 4,
mean: 0.5,
stdDev: 0.2,
nanCount: 2,
infCount: 1
}),
histogram: ref(new Uint32Array([1, 2, 3, 4])),
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
mount: vi.fn(),
dispose: vi.fn(),
fitView: vi.fn(),
normalizeExposure: vi.fn(),
...overrides
}
}
function renderViewer() {
return render(HdrViewerContent, {
props: { imageUrl: '/api/view?filename=out.exr' },
global: { plugins: [i18n], stubs: { Button: true } }
})
}
describe('HdrViewerContent', () => {
beforeEach(() => {
holder.viewer = makeViewer()
})
it('renders the full statistics set including NaN/Inf', () => {
renderViewer()
for (const label of [
'Resolution',
'Min',
'Max',
'Mean',
'Std dev',
'NaN',
'Inf'
]) {
screen.getByText(label)
}
})
it('shows the pixel readout when a pixel is hovered', () => {
renderViewer()
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
})
it('colors the histogram according to the selected channel', () => {
holder.viewer = makeViewer({ channel: ref('g') })
const { container } = renderViewer()
const path = container.querySelector('svg path')
expect(path?.getAttribute('class')).toContain('text-green-500')
})
it('renders an option for each channel mode', () => {
renderViewer()
expect(
screen.getByRole('option', { name: 'Luminance' })
).toBeInTheDocument()
})
})

View File

@@ -1,258 +0,0 @@
<template>
<div class="flex size-full bg-base-background">
<div class="relative flex-1">
<div
ref="containerRef"
class="absolute size-full"
data-testid="hdr-viewer-canvas"
/>
<div
v-if="viewer.loading.value"
class="absolute inset-0 flex items-center justify-center text-base-foreground"
>
{{ $t('g.loading') }}...
</div>
<div
v-else-if="viewer.error.value"
role="alert"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
>
<i class="icon-[lucide--image-off] size-12" />
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
</div>
<div
v-if="viewer.pixel.value"
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
data-testid="hdr-pixel-readout"
>
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
<div>
{{ formatNum(viewer.pixel.value.r) }}
{{ formatNum(viewer.pixel.value.g) }}
{{ formatNum(viewer.pixel.value.b) }}
<template v-if="viewer.pixel.value.a !== null">
{{ formatNum(viewer.pixel.value.a) }}
</template>
</div>
</div>
</div>
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
<input
v-model.number="viewer.exposureStops.value"
type="range"
min="-10"
max="10"
step="0.1"
class="w-full"
:aria-label="$t('hdrViewer.exposure')"
/>
</div>
<Button
variant="secondary"
class="w-full"
@click="viewer.normalizeExposure"
>
{{ $t('hdrViewer.normalizeExposure') }}
</Button>
</div>
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.channel') }}</label>
<select
v-model="viewer.channel.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.channel')"
>
<option v-for="mode in channelModes" :key="mode" :value="mode">
{{ channelLabels[mode] }}
</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
<select
v-model="viewer.gamut.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.sourceGamut')"
>
<option v-for="name in gamutNames" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
</div>
<div class="space-y-4 p-2">
<div class="flex items-center gap-2">
<input
id="hdr-dither"
v-model="viewer.dither.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-dither" class="cursor-pointer">
{{ $t('hdrViewer.dither') }}
</label>
</div>
<div class="flex items-center gap-2">
<input
id="hdr-clip"
v-model="viewer.clipWarnings.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-clip" class="cursor-pointer">
{{ $t('hdrViewer.clipWarnings') }}
</label>
</div>
</div>
<div v-if="histogramPath" class="space-y-2 p-2">
<label>{{ $t('hdrViewer.histogram') }}</label>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
>
<path
:d="histogramPath"
:class="histogramColorClass"
fill="currentColor"
fill-opacity="0.5"
stroke="none"
/>
</svg>
</div>
<div
v-if="viewer.stats.value"
class="space-y-1 p-2 text-xs tabular-nums"
>
<div v-if="viewer.dimensions.value" class="flex justify-between">
<span>{{ $t('hdrViewer.resolution') }}</span>
<span>{{ viewer.dimensions.value }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.min') }}</span>
<span>{{ formatNum(viewer.stats.value.min) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.max') }}</span>
<span>{{ formatNum(viewer.stats.value.max) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.mean') }}</span>
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.stdDev') }}</span>
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
</div>
<div
v-if="viewer.stats.value.nanCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.nan') }}</span>
<span>{{ viewer.stats.value.nanCount }}</span>
</div>
<div
v-if="viewer.stats.value.infCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.inf') }}</span>
<span>{{ viewer.stats.value.infCount }}</span>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
{{ $t('hdrViewer.fitView') }}
</Button>
<Button variant="secondary" class="flex-1" @click="handleDownload">
{{ $t('g.downloadImage') }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import type { ChannelMode } from '@/composables/useHdrViewer'
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
import { histogramToPath } from '@/utils/histogramUtil'
const { imageUrl } = defineProps<{ imageUrl: string }>()
const { t } = useI18n()
const viewer = useHdrViewer()
const gamutNames = GAMUT_NAMES
const channelModes = CHANNEL_MODES
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const exposureLabel = computed(() => {
const value = viewer.exposureStops.value
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
})
const histogramPath = computed(() =>
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
)
const histogramColorClass = computed(() => {
switch (viewer.channel.value) {
case 'r':
return 'text-red-500'
case 'g':
return 'text-green-500'
case 'b':
return 'text-blue-500'
default:
return 'text-base-foreground'
}
})
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
rgb: t('hdrViewer.channels.rgb'),
r: t('hdrViewer.channels.r'),
g: t('hdrViewer.channels.g'),
b: t('hdrViewer.channels.b'),
a: t('hdrViewer.channels.a'),
luminance: t('hdrViewer.channels.luminance')
}))
function formatNum(value: number): string {
if (!Number.isFinite(value)) return String(value)
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
? value.toExponential(3)
: value.toFixed(4)
}
function handleDownload() {
downloadFile(toFullResolutionUrl(imageUrl))
}
onMounted(() => {
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
})
</script>

View File

@@ -1,70 +0,0 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderRow(modelValue: string[], max = 5) {
return render(PaletteSwatchRow, {
props: { modelValue, max },
global: { plugins: [i18n] }
})
}
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0]
}
describe('PaletteSwatchRow', () => {
it('renders one swatch per color', () => {
const { container } = renderRow(['#ff0000', '#00ff00'])
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
it('removes a color on right click', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
expect(lastEmit(emitted)).toEqual(['#00ff00'])
})
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
})
it('starts a drag on pointer down without emitting', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
button: 0,
clientX: 5,
clientY: 5
})
expect(emitted()['update:modelValue']).toBeUndefined()
})
})

View File

@@ -1,48 +0,0 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
<button
v-if="modelValue.length < max"
type="button"
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
:title="t('palette.addColor')"
@click="addColor"
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
</script>

View File

@@ -1,54 +0,0 @@
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetColors from './WidgetColors.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderWidget(modelValue: string[], widget?: { name: string }) {
return render(WidgetColors, {
props: { modelValue, widget },
global: { plugins: [i18n] }
})
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length) cleanups.pop()?.()
})
describe('WidgetColors', () => {
it('renders the palette swatch row for each color', () => {
renderWidget(['#ff0000', '#00ff00'])
const root = screen.getByTestId('colors')
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('shows the widget name as an inline label', () => {
renderWidget(['#ff0000'], { name: 'color_palette' })
expect(screen.getByText('color_palette')).toBeInTheDocument()
})
it('emits an updated palette when a color is added', async () => {
const { emitted } = renderWidget([])
await userEvent.click(screen.getByRole('button'))
const calls = emitted()['update:modelValue'] as unknown[][]
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
})
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
const { container } = renderWidget(['#ff0000'])
const onDocMove = vi.fn()
document.addEventListener('pointermove', onDocMove)
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
expect(onDocMove).toHaveBeenCalled()
})
})

View File

@@ -1,29 +0,0 @@
<template>
<div
class="flex size-full items-center gap-2"
data-testid="colors"
@pointerdown.stop
>
<span
v-if="widget?.name"
class="shrink-0 truncate text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</span>
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
</div>
</template>
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const MAX_COLORS = 16
const { widget } = defineProps<{
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
}>()
const modelValue = defineModel<string[]>({ default: () => [] })
</script>

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/vue'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
@@ -166,9 +165,7 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
@@ -178,9 +175,7 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)

View File

@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
@@ -24,7 +23,7 @@ type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
@@ -38,7 +37,7 @@ const singleErrorCard: ErrorCardData = {
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: createNodeExecutionId([24]),
nodeId: '24',
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
@@ -56,7 +55,7 @@ const multipleErrorsCard: ErrorCardData = {
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: createNodeExecutionId([45]),
nodeId: '45',
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
@@ -76,7 +75,7 @@ const runtimeErrorCard: ErrorCardData = {
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: createNodeExecutionId([3, 15]),
nodeId: '3:15',
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [

View File

@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
@@ -157,7 +156,7 @@ describe('ErrorNodeCard.vue', () => {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
@@ -250,7 +249,7 @@ describe('ErrorNodeCard.vue', () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
@@ -388,7 +387,7 @@ describe('ErrorNodeCard.vue', () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: createNodeExecutionId([10]),
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{

View File

@@ -1,5 +1,4 @@
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
export interface ErrorItem extends ResolvedErrorMessage {
/** Raw source/API-compatible message. */
@@ -13,7 +12,7 @@ export interface ErrorItem extends ResolvedErrorMessage {
export interface ErrorCardData {
id: string
title: string
nodeId?: NodeExecutionId
nodeId?: string
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean

View File

@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -39,8 +39,8 @@ import {
resolveRunErrorMessage
} from '@/platform/errorCatalog/errorMessageResolver'
import {
compareExecutionId,
tryNormalizeNodeExecutionId
isNodeExecutionId,
compareExecutionId
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/** Resolve display info for a node by its execution ID. */
function resolveNodeInfo(nodeId: NodeExecutionId) {
function resolveNodeInfo(nodeId: string) {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
@@ -119,7 +119,7 @@ function getOrCreateGroup(
}
function createErrorCard(
nodeId: NodeExecutionId,
nodeId: string,
classType: string,
idPrefix: string
): ErrorCardData {
@@ -130,7 +130,7 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: nodeId.includes(':'),
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
}
}
@@ -288,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return map
})
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -305,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: NodeExecutionId,
nodeId: string,
classType: string,
idPrefix: string,
error: CataloguedErrorItem,
@@ -371,11 +371,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
) {
if (!executionErrorStore.lastNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
for (const [nodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
for (const e of nodeError.errors) {
@@ -406,12 +404,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
if (!nodeId) return
addNodeErrorToGroup(
groupsMap,
nodeId,
String(e.node_id),
e.node_type,
'exec',
{
@@ -422,7 +417,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
},
filterBySelection
@@ -673,7 +669,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
function isAssetErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -695,17 +691,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return false
}
function isAssetCandidateInSelection(nodeId: string | number): boolean {
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
@@ -716,7 +707,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)

View File

@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
@@ -104,7 +103,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: createNodeExecutionId([42]),
nodeId: '42',
errors: [],
...overrides
}
@@ -182,7 +181,7 @@ describe('useErrorReport', () => {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: createNodeExecutionId([42]),
nodeId: '42',
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',

View File

@@ -3,12 +3,17 @@ import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
type SidebarIconProps = {
icon: string
selected: boolean
tooltip?: string
class?: string
iconBadge?: string | (() => string | null)
}
const i18n = createI18n({
legacy: false,
@@ -79,20 +84,4 @@ describe('SidebarIcon', () => {
tooltipText
)
})
it('falls back to label for tooltip when no tooltip is provided', async () => {
const labelText = 'WASNodeSuitePreprocessors'
const { user } = renderSidebarIcon({ label: labelText })
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
await user.hover(screen.getByRole('button'))
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
},
{ timeout: 1000 }
)
})
})

View File

@@ -40,11 +40,9 @@
</span>
</div>
</slot>
<!-- w-max sizes the label to the rail instead of the padding-inset
button content box, which is too narrow for one-line labels -->
<span
v-if="label && !isSmall"
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
class="side-bar-button-label text-center text-2xs"
>{{ st(label, label) }}</span
>
</div>
@@ -85,14 +83,7 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
/**
* Falls back to the label when no tooltip is provided, so labels clamped
* to two lines can always be recovered in full on hover.
*/
const computedTooltip = computed(() => {
const text = tooltip || label
return st(text, text) + tooltipSuffix
})
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
</script>
<style>

View File

@@ -1,18 +1,37 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
@@ -21,6 +40,7 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
@@ -30,6 +50,7 @@ afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
@@ -40,50 +61,60 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockIsFreeTier = ref(false)
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
const mockBalance = ref<BalanceInfo | null>(null)
const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
isFreeTier: mockIsFreeTier,
tier: mockTier,
subscription: mockSubscription,
balance: mockBalance,
isLoading: mockIsLoading,
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
}))
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
}))
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
@@ -96,6 +127,7 @@ vi.mock(
})
)
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
@@ -105,10 +137,22 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
@@ -118,12 +162,14 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -132,37 +178,25 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: defineComponent({
default: {
name: 'SubscribeButtonMock',
emits: ['subscribed'],
setup(_, { emit }) {
return () =>
h(
'button',
{
'data-testid': 'subscribe-button-mock',
onClick: () => emit('subscribed')
},
'Subscribe Button'
)
render() {
return h('div', 'Subscribe Button')
}
})
}
}))
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockIsFreeTier.value = false
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 100_000,
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
}
mockIsLoading.value = false
mockAuthStoreState.isFetchingBalance = false
})
function renderComponent() {
@@ -196,47 +230,7 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('fetches the balance through the billing facade on mount', () => {
renderComponent()
expect(mockFetchBalance).toHaveBeenCalled()
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
expect(mockFetchStatus).toHaveBeenCalled()
})
describe('subscription tier badge', () => {
it('renders the tier name derived from the facade tier', () => {
renderComponent()
expect(screen.getByText('Creator')).toBeInTheDocument()
})
it('renders the yearly tier name when the facade subscription is annual', () => {
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
renderComponent()
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
})
it('hides the badge when the facade reports no tier', () => {
mockTier.value = null
mockSubscription.value = null
renderComponent()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
})
it('formats and displays the facade balance', () => {
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
@@ -251,14 +245,6 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows a skeleton instead of the balance while billing is loading', () => {
mockIsLoading.value = true
renderComponent()
expect(screen.queryByText('1000')).not.toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
renderComponent()
@@ -338,11 +324,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('facade balance handling', () => {
it('uses effectiveBalanceMicros when present (positive balance)', () => {
mockBalance.value = {
amountMicros: 200_000,
effectiveBalanceMicros: 150_000,
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
currency: 'usd'
}
@@ -359,10 +345,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effectiveBalanceMicros when zero', () => {
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 0,
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
currency: 'usd'
}
@@ -379,10 +365,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effectiveBalanceMicros when negative', () => {
mockBalance.value = {
amountMicros: 0,
effectiveBalanceMicros: -50_000,
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
currency: 'usd'
}
@@ -399,9 +385,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
mockBalance.value = {
amountMicros: 100_000,
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
currency: 'usd'
}
@@ -418,8 +404,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when the facade reports no balance', () => {
mockBalance.value = null
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
renderComponent()
@@ -478,11 +466,8 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
renderComponent()
expect(
screen.queryByTestId('subscribe-button-mock')
).not.toBeInTheDocument()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {

View File

@@ -32,7 +32,12 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
@@ -157,15 +162,16 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -175,29 +181,25 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
tier,
subscription,
balance,
isLoading,
fetchStatus,
fetchBalance
} = useBillingContext()
const { formatTierName } = useWorkspaceTierLabel()
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const subscriptionTierName = computed(() =>
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
)
const formattedBalance = computed(() => {
const cents =
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -209,12 +211,12 @@ const formattedBalance = computed(() => {
})
const canUpgrade = computed(() => {
const currentTier = tier.value
const tier = subscriptionTier.value
return (
currentTier === 'FREE' ||
currentTier === 'FOUNDERS_EDITION' ||
currentTier === 'STANDARD' ||
currentTier === 'CREATOR'
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
)
})
@@ -268,6 +270,6 @@ const handleSubscribed = async () => {
}
onMounted(() => {
void fetchBalance()
void authActions.fetchBalance()
})
</script>

View File

@@ -1,237 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { HitMode, Region } from './boundingBoxesUtil'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from './boundingBoxesUtil'
const region = (over: Partial<Region> = {}): Region => ({
x: 0.2,
y: 0.2,
w: 0.2,
h: 0.2,
type: 'obj',
text: '',
desc: '',
palette: [],
...over
})
describe('applyDrag', () => {
it('moves without resizing and keeps width/height', () => {
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
expect(out.x).toBeCloseTo(0.3)
expect(out.y).toBeCloseTo(0.3)
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.2)
})
it('clamps a move so the box stays inside the unit square', () => {
const out = applyDrag(
'move',
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
0.5,
0.5
)
expect(out.x).toBeCloseTo(0.8)
expect(out.y).toBeCloseTo(0.8)
})
it('grows from the bottom-right for draw and resize-br', () => {
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
const out = applyDrag(
mode,
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
0.1,
0.2
)
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.3)
}
})
it('moves the top-left corner on resize-tl', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.1,
0.1
)
expect(out.x).toBeCloseTo(0.6)
expect(out.y).toBeCloseTo(0.6)
expect(out.w).toBeCloseTo(0.1)
expect(out.h).toBeCloseTo(0.1)
})
it('normalizes a corner drag that inverts the box', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.3,
0
)
expect(out.x).toBeCloseTo(0.7)
expect(out.w).toBeCloseTo(0.1)
expect(out.y).toBeCloseTo(0.5)
expect(out.h).toBeCloseTo(0.2)
})
it('resizes single edges', () => {
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
expect(top.y).toBeCloseTo(0.5)
expect(top.h).toBeCloseTo(0.1)
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
expect(left.x).toBeCloseTo(0.5)
expect(left.w).toBeCloseTo(0.1)
})
})
describe('boxesAt', () => {
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
it('detects a corner handle', () => {
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
})
it('detects an interior move', () => {
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
})
it('returns nothing when the pointer misses every box', () => {
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
})
it('brings the active box to the front of overlapping candidates', () => {
const overlapping: Region[] = [
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
]
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
expect(hits).toHaveLength(2)
expect(hits[0].index).toBe(1)
})
})
describe('tagRects', () => {
const measure = (s: string) => s.length * 7
it('places the first tag at the top-left corner', () => {
const rects = tagRects(
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
100,
100,
measure
)
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
expect(rects[0].w).toBe(measure('01') + 8)
})
it('moves a colliding tag to a different corner', () => {
const boxes = [
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
]
const rects = tagRects(boxes, 100, 100, measure)
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
expect(sameSpot).toBe(false)
})
})
describe('fromBoundingBoxes', () => {
it('converts pixel boxes to normalized regions with metadata', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.4,
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
})
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
type: 'obj',
text: '',
desc: '',
palette: []
})
})
it('drops entries that are not bounding boxes', () => {
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
})
it('guards against zero dimensions', () => {
const boxes: BoundingBox[] = [
{
x: 5,
y: 5,
width: 5,
height: 5,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
x: 5,
y: 5,
w: 5,
h: 5
})
})
})
describe('toBoundingBoxes', () => {
it('rounds normalized regions back to pixels and copies the palette', () => {
const palette = ['#abc']
const regions: Region[] = [
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
]
const [box] = toBoundingBoxes(regions, 1000, 1000)
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
expect(box.metadata.palette).toEqual(['#abc'])
expect(box.metadata.palette).not.toBe(palette)
})
it('round-trips from pixels to regions and back', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
const restored = toBoundingBoxes(
fromBoundingBoxes(boxes, 1000, 1000),
1000,
1000
)
expect(restored).toEqual(boxes)
})
})

View File

@@ -1,246 +0,0 @@
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
export type HitMode =
| 'move'
| 'draw'
| 'resize-tl'
| 'resize-tr'
| 'resize-bl'
| 'resize-br'
| 'resize-t'
| 'resize-b'
| 'resize-l'
| 'resize-r'
export interface Region extends BoundingBoxMetadata {
x: number
y: number
w: number
h: number
}
interface BoxCandidate {
index: number
mode: HitMode
}
interface TagRect {
x: number
y: number
w: number
h: number
tag: string
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function normalizeBox(b: Region): Region {
let { x, y, w, h } = b
if (w < 0) {
x += w
w = -w
}
if (h < 0) {
y += h
h = -h
}
x = clamp01(x)
y = clamp01(y)
w = Math.min(w, 1 - x)
h = Math.min(h, 1 - y)
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
}
function rectHitTest(
mx: number,
my: number,
x1: number,
y1: number,
x2: number,
y2: number,
rx: number,
ry: number
): HitMode | null {
const h = (cx: number, cy: number) =>
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
if (h(x1, y1)) return 'resize-tl'
if (h(x2, y1)) return 'resize-tr'
if (h(x1, y2)) return 'resize-bl'
if (h(x2, y2)) return 'resize-br'
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
return null
}
export function applyDrag(
mode: HitMode,
start: Region,
dx: number,
dy: number
): Region {
let { x, y, w, h } = start
switch (mode) {
case 'move':
x += dx
y += dy
x = clamp01(Math.min(x, 1 - w))
y = clamp01(Math.min(y, 1 - h))
break
case 'draw':
case 'resize-br':
w += dx
h += dy
break
case 'resize-tl':
x += dx
y += dy
w -= dx
h -= dy
break
case 'resize-tr':
y += dy
w += dx
h -= dy
break
case 'resize-bl':
x += dx
w -= dx
h += dy
break
case 'resize-t':
y += dy
h -= dy
break
case 'resize-b':
h += dy
break
case 'resize-l':
x += dx
w -= dx
break
case 'resize-r':
w += dx
break
}
return mode === 'move'
? { ...start, x, y }
: normalizeBox({ ...start, x, y, w, h })
}
export function boxesAt(
regions: readonly Region[],
mxN: number,
myN: number,
handlePx: number,
logW: number,
logH: number,
activeIdx: number
): BoxCandidate[] {
const rx = handlePx / Math.max(1, logW)
const ry = handlePx / Math.max(1, logH)
const res: BoxCandidate[] = []
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
if (mode) res.push({ index: i, mode })
}
const ai = res.findIndex((c) => c.index === activeIdx)
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
return res
}
export function tagRects(
regions: readonly Region[],
logW: number,
logH: number,
measureWidth: (s: string) => number,
height = 14
): TagRect[] {
const placed: TagRect[] = []
const rects: TagRect[] = []
const hits = (a: TagRect, b: TagRect) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const x1 = b.x * logW
const y1 = b.y * logH
const x2 = (b.x + b.w) * logW
const y2 = (b.y + b.h) * logH
const tag = String(i + 1).padStart(2, '0')
const w = measureWidth(tag) + 8
let pick: [number, number] = [x1, y1]
for (const [cx, cy] of [
[x1, y1],
[x2 - w, y1],
[x2 - w, y2 - height],
[x1, y2 - height]
] as const) {
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
if (!placed.some((p) => hits(candidate, p))) {
pick = [cx, cy]
break
}
}
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
placed.push(r)
rects[i] = r
}
return rects
}
function isBoundingBox(b: unknown): b is BoundingBox {
if (!b || typeof b !== 'object') return false
const box = b as Record<string, unknown>
return (
typeof box.x === 'number' &&
typeof box.y === 'number' &&
typeof box.width === 'number' &&
typeof box.height === 'number'
)
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
height: number
): Region[] {
const w = width || 1
const h = height || 1
return boxes.filter(isBoundingBox).map((box) => {
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
return {
x: box.x / w,
y: box.y / h,
w: box.width / w,
h: box.height / h,
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
}
})
}
export function toBoundingBoxes(
regions: readonly Region[],
width: number,
height: number
): BoundingBox[] {
return regions.map((r) => ({
x: Math.round(r.x * width),
y: Math.round(r.y * height),
width: Math.round(r.w * width),
height: Math.round(r.h * height),
metadata: {
type: r.type,
text: r.text,
desc: r.desc,
palette: r.palette.slice()
}
}))
}

View File

@@ -1,249 +0,0 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
el.focus = () => {}
el.setPointerCapture = () => {}
el.releasePointerCapture = () => {}
return el
}
function makeNode() {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const pe = (
clientX: number,
clientY: number,
over: Partial<PointerEvent> = {}
) =>
({
button: 0,
clientX,
clientY,
altKey: false,
pointerId: 1,
preventDefault: () => {},
stopPropagation: () => {},
...over
}) as unknown as PointerEvent
const flush = async () => {
await Promise.resolve()
await nextTick()
}
type Api = ReturnType<typeof useBoundingBoxes>
interface Captured extends Api {
canvasEl: ShallowRef<HTMLCanvasElement | null>
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial)
const api = useBoundingBoxes('1', {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
captured = { canvasEl, modelValue, ...api }
return () => h('div')
}
})
render(Harness)
captured!.canvasEl.value = makeCanvas()
return captured!
}
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useBoundingBoxes initialization', () => {
it('derives regions from the initial model value', () => {
const c = setup([box()])
expect(c.hasRegions.value).toBe(true)
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
})
it('exposes an aspect-ratio canvas style from the node width/height', () => {
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
})
it('starts with no active region when empty', () => {
const c = setup()
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
})
describe('useBoundingBoxes drawing', () => {
it('draws a new region and syncs it to the model value', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
it('changes the active region type', async () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('clears all regions', async () => {
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
})
describe('useBoundingBoxes inline editor', () => {
it('opens on double click and commits the description', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
it('closes the inline editor on Escape', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
expect(c.canvasCursor.value).toBe('crosshair')
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})

View File

@@ -1,614 +0,0 @@
import { useElementSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import type {
HitMode,
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
interface InlineEditorState {
value: string
style: Record<string, string>
index: number
}
interface UseBoundingBoxesOptions {
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
modelValue: Ref<BoundingBox[]>
}
export function useBoundingBoxes(
nodeId: string,
{
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
}: UseBoundingBoxesOptions
) {
const focused = ref(false)
const drawing = ref(false)
const dragMode = ref<HitMode | null>(null)
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
const boxAtStart = ref<Region | null>(null)
const hoverIndex = ref<number | null>(null)
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const { width: containerWidth } = useElementSize(canvasContainer)
const litegraphNode = computed(() =>
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
)
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isNodeSelected = computed(() =>
selectedNodeIds.value.has(String(nodeId))
)
function dimWidget(name: 'width' | 'height'): number | undefined {
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
return typeof v === 'number' && v > 0 ? v : undefined
}
const widthValue = computed(() => dimWidget('width') ?? 1024)
const heightValue = computed(() => dimWidget('height') ?? 1024)
const state = ref({
regions: fromBoundingBoxes(
modelValue.value ?? [],
widthValue.value,
heightValue.value
)
})
const activeIndex = ref(state.value.regions.length ? 0 : -1)
const aspectRatio = computed(
() => `${widthValue.value} / ${heightValue.value}`
)
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
const activeRegion = computed(() =>
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
)
const hasRegions = computed(() => state.value.regions.length > 0)
function clampToCanvas(n: number) {
return Math.max(0, Math.min(1, n))
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
}
function pointerNorm(e: PointerEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
let rafHandle = 0
function requestDraw() {
if (rafHandle) return
rafHandle = requestAnimationFrame(() => {
rafHandle = 0
drawCanvas()
})
}
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
return ctx.measureText(s).width
}
function drawCanvas() {
const el = canvasEl.value
if (!el) return
const { w: W, h: H } = logicalSize()
const dpr = window.devicePixelRatio || 1
const bw = Math.max(1, Math.round(W * dpr))
const bh = Math.max(1, Math.round(H * dpr))
if (el.width !== bw || el.height !== bh) {
el.width = bw
el.height = bh
}
const ctx = el.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (bgImage.value) {
ctx.drawImage(bgImage.value, 0, 0, W, H)
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
ctx.fillRect(0, 0, W, H)
}
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
.map((_, i) => i)
.filter((i) => i !== aIdx)
.reverse()
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
ctx.font = 'bold 11px monospace'
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
for (const i of order) {
const b = state.value.regions[i]
const active = i === aIdx
const pal = (b.palette || []).filter(Boolean)
const col = pal.length ? pal[0] : '#8c8c8c'
const x1 = b.x * W
const y1 = b.y * H
const x2 = (b.x + b.w) * W
const y2 = (b.y + b.h) * H
const w = x2 - x1
const h = y2 - y1
const hovered = i === hoverIndex.value || active
if (active) {
ctx.fillStyle = 'rgba(26,26,26,0.88)'
ctx.fillRect(x1, y1, w, h)
}
ctx.fillStyle = col + (hovered ? '3a' : '22')
ctx.fillRect(x1, y1, w, h)
const lw = active ? 2 : hovered ? 1.5 : 1
ctx.strokeStyle = col
ctx.lineWidth = lw
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
if (pal.length) {
const sw = w / pal.length
const sh = 7
for (let p = 0; p < pal.length; p++) {
const sx = x1 + Math.round(p * sw)
ctx.fillStyle = pal[p]
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
}
}
ctx.save()
ctx.beginPath()
ctx.rect(x1, y1, w, h)
ctx.clip()
let body = b.desc || ''
if (b.type === 'text' && b.text)
body = `"${b.text}"` + (body ? `${body}` : '')
if (body) {
ctx.font = '12px monospace'
ctx.fillStyle = readableTextColor(col)
const pad = 4
const lh = 14
let ty = y1 + 15 + 12
for (const line of wrapLines(ctx, body, w - pad * 2)) {
if (ty > y1 + h) break
ctx.fillText(line, x1 + pad, ty)
ty += lh
}
}
const tr = tag_rects[i]
ctx.font = 'bold 11px monospace'
ctx.fillStyle = col
ctx.fillRect(tr.x, tr.y, tr.w, 14)
if (i === hoverTagIndex.value) {
ctx.fillStyle = 'rgba(255,255,255,0.25)'
ctx.fillRect(tr.x, tr.y, tr.w, 14)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
}
ctx.fillStyle = textOnColor(col)
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
ctx.restore()
}
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxW: number
): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/)) {
if (!word) continue
const test = line ? `${line} ${word}` : word
if (line && ctx.measureText(test).width > maxW) {
out.push(line)
line = word
} else {
line = test
}
}
out.push(line)
}
return out
}
const hitTestPoint = (mN: { x: number; y: number }) => {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
const titleAt = (mN: { x: number; y: number }) => {
const el = canvasEl.value
if (!el) return null
const ctx = el.getContext('2d')
if (!ctx) return null
const { w: W, h: H } = logicalSize()
const rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
const px = mN.x * W
const py = mN.y * H
for (let i = state.value.regions.length - 1; i >= 0; i--) {
const r = rects[i]
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
return i
}
return null
}
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
const activeResize = cands.find(
(c) => c.index === activeIndex.value && c.mode !== 'move'
)
if (activeResize && !cycle) return activeResize
const ti = titleAt(mN)
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
if (cycle && cands.length > 1) {
const pos = cands.findIndex((c) => c.index === activeIndex.value)
return cands[(pos + 1) % cands.length]
}
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
canvasEl.value?.focus()
hoverTagIndex.value = null
hoverIndex.value = null
const mN = pointerNorm(e)
const hit = pickForSelection(mN, e.altKey)
if (hit) {
activeIndex.value = hit.index
dragMode.value = hit.mode
boxAtStart.value = { ...state.value.regions[hit.index] }
} else {
dragMode.value = 'draw'
const nb: Region = {
x: mN.x,
y: mN.y,
w: 0,
h: 0,
type: 'obj',
text: '',
desc: '',
palette: []
}
state.value.regions.push(nb)
activeIndex.value = state.value.regions.length - 1
boxAtStart.value = { ...nb }
}
drawing.value = true
dragStartNorm.value = mN
canvasEl.value?.setPointerCapture(e.pointerId)
e.preventDefault()
requestDraw()
}
function onDocPointerMove(e: PointerEvent) {
if (
!drawing.value ||
!boxAtStart.value ||
!dragStartNorm.value ||
!dragMode.value
)
return
const mN = pointerNorm(e)
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
requestDraw()
}
function onDocPointerUp(e: PointerEvent) {
if (!drawing.value) return
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
removeRegion(activeIndex.value)
}
syncState()
}
function onCanvasPointerMove(e: PointerEvent) {
if (drawing.value) onDocPointerMove(e)
else onPointerMove(e)
}
function onPointerMove(e: PointerEvent) {
if (drawing.value) return
const mN = pointerNorm(e)
const ti = titleAt(mN)
const hit = hitTestPoint(mN)
const hb = ti !== null ? ti : hit ? hit.index : null
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
hoverTagIndex.value = ti
hoverIndex.value = hb
requestDraw()
}
}
function onPointerLeave() {
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
hoverTagIndex.value = null
hoverIndex.value = null
requestDraw()
}
}
const canvasCursor = computed(() =>
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
)
function onDoubleClick(e: MouseEvent) {
e.preventDefault()
const mN = pointerNormFromMouse(e)
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
if (!target) return
openInlineEditor(target.index)
}
function pointerNormFromMouse(e: MouseEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
function openInlineEditor(index: number) {
const b = state.value.regions[index]
if (!b) return
activeIndex.value = index
const { w: W, h: H } = logicalSize()
const w = Math.min(W, Math.max(70, b.w * W))
const h = Math.min(H, Math.max(42, b.h * H))
const left = Math.max(0, Math.min(b.x * W, W - w))
const top = Math.max(0, Math.min(b.y * H, H - h))
inlineEditor.value = {
value: b.desc || '',
index,
style: {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`,
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
}
}
void nextTick(() => {
inlineEditorEl.value?.focus()
inlineEditorEl.value?.select()
})
}
function onInlineKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
inlineEditor.value = null
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
commitInlineEditor()
}
}
function commitInlineEditor() {
const ed = inlineEditor.value
if (!ed) return
const b = state.value.regions[ed.index]
if (b) b.desc = ed.value
inlineEditor.value = null
syncState()
}
function onCanvasKeyDown(e: KeyboardEvent) {
if (drawing.value) return
const idx = activeIndex.value
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
e.preventDefault()
e.stopPropagation()
removeRegion(idx)
syncState()
}
}
function removeRegion(i: number) {
state.value.regions.splice(i, 1)
if (!state.value.regions.length) activeIndex.value = -1
else if (i <= activeIndex.value)
activeIndex.value = Math.max(0, activeIndex.value - 1)
}
function setActiveType(t: 'obj' | 'text') {
if (activeRegion.value) {
activeRegion.value.type = t
syncState()
}
}
function clearAll() {
state.value.regions = []
activeIndex.value = -1
syncState()
}
function syncState() {
modelValue.value = toBoundingBoxes(
state.value.regions,
widthValue.value,
heightValue.value
)
requestDraw()
}
watch(containerWidth, () => requestDraw())
watch(
() => state.value.regions.length,
() => requestDraw()
)
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
if (!node) return
const snap = (v: number) =>
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
const targetW = snap(naturalWidth)
const targetH = snap(naturalHeight)
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && widthWidget.value !== targetW) {
widthWidget.value = targetW
widthWidget.callback?.(targetW)
}
if (heightWidget && heightWidget.value !== targetH) {
heightWidget.value = targetH
heightWidget.callback?.(targetH)
}
}
let lastBgUrl = ''
function updateBgImage() {
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('background')
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
const url = inputNode
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
: undefined
if (!url) {
if (bgImage.value) {
bgImage.value = null
lastBgUrl = ''
requestDraw()
}
return
}
if (url === lastBgUrl) return
lastBgUrl = url
const currentUrl = url
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
if (currentUrl !== lastBgUrl) return
bgImage.value = img
applyImageDimensions(img.naturalWidth, img.naturalHeight)
requestDraw()
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
if (rafHandle) cancelAnimationFrame(rafHandle)
})
return {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors: MAX_ELEMENT_COLORS,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
}
}

View File

@@ -21,7 +21,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -51,11 +50,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -67,11 +62,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -90,11 +81,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
@@ -116,11 +103,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'model'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -246,11 +229,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'image'
)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
@@ -300,11 +279,7 @@ describe('installErrorClearingHooks lifecycle', () => {
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([lateNode.id]),
'value'
)
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,

View File

@@ -34,7 +34,6 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { appendNodeExecutionId } from '@/types/nodeIdentification'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
@@ -84,7 +83,7 @@ function installNodeHooks(node: LGraphNode): void {
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
? `${hostExecId}:${promotedSource.nodeId}`
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name

View File

@@ -703,55 +703,3 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
})
describe('Pre-remove vueNodeData drain', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('drops vueNodeData entry before node.onRemoved fires', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.has(String(node.id))).toBe(true)
let dataPresentInOnRemoved: boolean | undefined
node.onRemoved = () => {
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
}
graph.remove(node)
expect(
dataPresentInOnRemoved,
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
).toBe(false)
})
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
const graph = new LGraph()
const nodeA = new LGraphNode('a')
const nodeB = new LGraphNode('b')
graph.add(nodeA)
graph.add(nodeB)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.size).toBe(2)
const beforeRemovedSpy = vi.fn()
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
graph.clear()
expect(
beforeRemovedSpy,
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
).toHaveBeenCalledTimes(2)
expect(
vueNodeData.size,
'node:before-removed listener must drain vueNodeData when clear() removes every node'
).toBe(0)
})
})

View File

@@ -30,8 +30,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -96,7 +94,7 @@ export interface SafeWidgetData {
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: NodeExecutionId
sourceExecutionId?: string
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
@@ -139,7 +137,7 @@ export interface GraphNodeManager {
vueNodeData: ReadonlyMap<string, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: WorkflowNodeId): LGraphNode | undefined
getNode(id: string): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
@@ -227,7 +225,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: NodeExecutionId
sourceExecutionId?: string
sourceWidgetName?: string
}
@@ -518,8 +516,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
return nodeRefs.get(String(id))
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
@@ -610,20 +608,27 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
const dropNodeReferences = (node: LGraphNode) => {
const id = String(node.id)
nodeRefs.delete(id)
vueNodeData.delete(id)
}
/**
* Handles node removal from the graph - cleans up all references
*/
const handleNodeRemoved = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
originalCallback?.(node)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
// Call original callback if provided
if (originalCallback) {
originalCallback(node)
}
}
/**
@@ -632,8 +637,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
) => {
return () => {
// Restore original callbacks
@@ -641,17 +645,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
graph.events.removeEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
/**
* Sets up event listeners - now simplified with extracted handlers
*/
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
@@ -667,16 +669,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const beforeNodeRemovedListener = (
e: CustomEvent<{ node: LGraphNode }>
) => {
dropNodeReferences(e.detail.node)
}
graph.events.addEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
@@ -825,11 +817,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,
originalOnNodeRemoved || undefined,
originalOnTrigger || undefined,
beforeNodeRemovedListener
originalOnTrigger || undefined
)
}

View File

@@ -137,18 +137,6 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
const setup = createSetup()
const parentGraph = setup.subgraphNode.graph!
parentGraph.add(setup.subgraphNode)
parentGraph.remove(setup.subgraphNode)
expect(setup.subgraphNode.graph).toBeNull()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(() => promotedPreviews.value).not.toThrow()
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })

View File

@@ -6,11 +6,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import {
appendNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
sourceNodeId: string
@@ -42,7 +38,7 @@ export function usePromotedPreviews(
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: NodeExecutionId,
leafExecutionId: string,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
@@ -72,7 +68,6 @@ export function usePromotedPreviews(
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
if (node.isDetached) return []
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
@@ -126,7 +121,7 @@ export function usePromotedPreviews(
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
`${leafHostLocator}:${leaf.sourceNodeId}`,
interiorNode
)
if (!urls?.length) return []

View File

@@ -1,114 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
const scopes: EffectScope[] = []
afterEach(() => {
while (scopes.length) scopes.pop()?.stop()
})
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
addColor()
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
})
it('removes a color by index', () => {
const { modelValue, remove } = setup(['#a', '#b', '#c'])
remove(1)
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
})
it('reorders via drag when the pointer crosses another swatch', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#b', '#a'])
})
it('cancels a stale drag when the primary button is no longer pressed', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
})

View File

@@ -1,114 +0,0 @@
import { useEventListener } from '@vueuse/core'
import type { Ref, ShallowRef } from 'vue'
import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
modelValue.value = next
}
function remove(i: number) {
const next = modelValue.value.slice()
next.splice(i, 1)
modelValue.value = next
}
function addColor() {
modelValue.value = [...modelValue.value, '#ffffff']
}
const drag = ref<{
index: number
startX: number
startY: number
active: boolean
} | null>(null)
function onPointerDown(i: number, e: PointerEvent) {
if (e.button !== 0) return
drag.value = {
index: i,
startX: e.clientX,
startY: e.clientY,
active: false
}
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
const d = drag.value
if (!d) return
if ((e.buttons & 1) === 0) {
drag.value = null
return
}
if (!d.active) {
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
return
d.active = true
}
const rows =
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
if (!rows) return
for (const other of rows) {
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
const r = other.getBoundingClientRect()
if (
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top - 6 &&
e.clientY <= r.bottom + 6
) {
const oi = parseInt(other.dataset.index || '-1', 10)
if (oi < 0) continue
const next = modelValue.value.slice()
const [moved] = next.splice(d.index, 1)
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
modelValue.value = next
drag.value = null
return
}
}
})
useEventListener(document, 'pointerup', () => {
drag.value = null
})
useEventListener(document, 'pointercancel', () => {
drag.value = null
})
return {
openPicker,
onPickerInput,
remove,
addColor,
onPointerDown
}
}

View File

@@ -1,443 +0,0 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
import {
detectGamutFromChromaticities,
gamutToSrgbMatrix
} from '@/renderer/hdr/colorGamut'
import {
HDR_VIEWER_FRAGMENT_SHADER,
HDR_VIEWER_VERTEX_SHADER
} from '@/renderer/hdr/hdrViewerShader'
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
import {
computeChannelHistograms,
computeImageStats
} from '@/renderer/hdr/hdrStats'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
const MIN_ZOOM = 0.05
const MAX_ZOOM = 64
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
export const CHANNEL_MODES: ChannelMode[] = [
'rgb',
'r',
'g',
'b',
'a',
'luminance'
]
const CHANNEL_INDEX: Record<ChannelMode, number> = {
rgb: 0,
r: 1,
g: 2,
b: 3,
a: 4,
luminance: 5
}
export interface PixelReadout {
x: number
y: number
r: number
g: number
b: number
a: number | null
}
interface ExrTexData {
header?: { chromaticities?: ChromaticityCoords }
}
function createLoader(url: string) {
const filename = getImageFilenameFromUrl(url)
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
const loader = new EXRLoader()
loader.setDataType(THREE.FloatType)
return loader
}
function makeReader(
data: ArrayLike<number>,
type: THREE.TextureDataType
): (index: number) => number {
if (type === THREE.HalfFloatType) {
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
}
return (index) => data[index]
}
function loadHdrTexture(
url: string
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
return new Promise((resolve, reject) => {
createLoader(url).load(
url,
(texture, texData) => {
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
resolve({
texture,
gamut: detectGamutFromChromaticities(chromaticities)
})
},
undefined,
reject
)
})
}
export function useHdrViewer() {
const exposureStops = ref(0)
const dither = ref(true)
const clipWarnings = ref(false)
const gamut = ref<GamutName>('sRGB')
const channel = ref<ChannelMode>('rgb')
const loading = ref(true)
const error = ref<string | null>(null)
const dimensions = ref<string | null>(null)
const stats = ref<ImageStats | null>(null)
const histograms = shallowRef<ChannelHistograms | null>(null)
const pixel = ref<PixelReadout | null>(null)
const histogram = computed<Uint32Array | null>(() => {
const channelHistograms = histograms.value
if (!channelHistograms) return null
switch (channel.value) {
case 'r':
return channelHistograms.r
case 'g':
return channelHistograms.g
case 'b':
return channelHistograms.b
case 'a':
return channelHistograms.a
default:
return channelHistograms.luminance
}
})
const containerRef = shallowRef<HTMLElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let viewport: WebGLViewport | null = null
let scene: THREE.Scene | null = null
let camera: THREE.OrthographicCamera | null = null
let material: THREE.ShaderMaterial | null = null
let mesh: THREE.Mesh | null = null
let texture: THREE.Texture | null = null
let imageAspect = 1
let frameRequested = false
let readSample: ((index: number) => number) | null = null
let imageWidth = 0
let imageHeight = 0
let imageChannels = 4
const raycaster = new THREE.Raycaster()
const pointerNdc = new THREE.Vector2()
function requestRender() {
if (!renderer || frameRequested) return
frameRequested = true
requestAnimationFrame(() => {
frameRequested = false
if (renderer && scene && camera) renderer.render(scene, camera)
})
}
function containerSize() {
const el = containerRef.value
return {
width: el?.clientWidth || 1,
height: el?.clientHeight || 1
}
}
function updateProjection() {
if (!camera) return
const { width, height } = containerSize()
const halfH = 0.5
const halfW = (0.5 * width) / height
camera.left = -halfW
camera.right = halfW
camera.top = halfH
camera.bottom = -halfH
camera.updateProjectionMatrix()
}
function fitView() {
if (!camera) return
const { width, height } = containerSize()
const containerAspect = width / height
camera.zoom = Math.min(1, containerAspect / imageAspect)
camera.position.set(0, 0, 1)
camera.updateProjectionMatrix()
requestRender()
}
function applyUniforms() {
if (!material) return
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
material.uniforms.uDither.value = dither.value
material.uniforms.uClipWarnings.value = clipWarnings.value
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
const m = gamutToSrgbMatrix(gamut.value)
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
m[0],
m[1],
m[2],
m[3],
m[4],
m[5],
m[6],
m[7],
m[8]
)
requestRender()
}
function buildScene() {
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
viewport = new WebGLViewport(renderer)
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x0a0a0a, 1)
scene = new THREE.Scene()
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
camera.position.set(0, 0, 1)
material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: HDR_VIEWER_VERTEX_SHADER,
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
uniforms: {
uImage: { value: null },
uGamutToSRGB: { value: new THREE.Matrix3() },
uGain: { value: 1 },
uChannel: { value: 0 },
uDither: { value: true },
uClipWarnings: { value: false },
uClipRange: { value: new THREE.Vector2(0, 1) }
}
})
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
scene.add(mesh)
}
function resize() {
if (!renderer) return
const { width, height } = containerSize()
renderer.setSize(width, height, false)
updateProjection()
requestRender()
}
function setTexture(loaded: THREE.DataTexture) {
if (!material || !mesh) return
loaded.colorSpace = THREE.LinearSRGBColorSpace
loaded.minFilter = THREE.LinearFilter
loaded.magFilter = THREE.LinearFilter
loaded.needsUpdate = true
const { width, height, data } = loaded.image
texture = loaded
imageAspect = width / height
mesh.scale.set(imageAspect, 1, 1)
material.uniforms.uImage.value = loaded
dimensions.value = `${width} x ${height}`
if (!data) return
imageWidth = width
imageHeight = height
imageChannels = data.length / (width * height)
readSample = makeReader(data, loaded.type)
stats.value = computeImageStats(readSample, data.length, imageChannels)
histograms.value = computeChannelHistograms(
readSample,
data.length,
imageChannels
)
}
async function mount(container: HTMLElement, url: string) {
containerRef.value = container
loading.value = true
error.value = null
try {
buildScene()
container.appendChild(renderer!.domElement)
renderer!.domElement.classList.add('block', 'size-full')
resize()
applyUniforms()
attachInteractions(renderer!.domElement)
viewport!.observeResize(container, resize)
const { texture: loaded, gamut: detectedGamut } =
await loadHdrTexture(url)
if (!material || !mesh) {
loaded.dispose()
return
}
gamut.value = detectedGamut
setTexture(loaded)
applyUniforms()
fitView()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
dispose()
} finally {
loading.value = false
}
}
function normalizeExposure() {
const max = stats.value?.max ?? 0
exposureStops.value = max > 0 ? -Math.log2(max) : 0
}
function attachInteractions(canvas: HTMLCanvasElement) {
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onHoverMove)
canvas.addEventListener('pointerleave', onHoverLeave)
}
function onWheel(event: WheelEvent) {
if (!camera) return
event.preventDefault()
const factor = Math.exp(-event.deltaY * 0.001)
const nextZoom = THREE.MathUtils.clamp(
camera.zoom * factor,
MIN_ZOOM,
MAX_ZOOM
)
camera.zoom = nextZoom
camera.updateProjectionMatrix()
requestRender()
}
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
null
function onPointerDown(event: PointerEvent) {
if (!camera) return
dragStart = {
x: event.clientX,
y: event.clientY,
camX: camera.position.x,
camY: camera.position.y
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!camera || !dragStart) return
const { height } = containerSize()
const worldPerPixel = 1 / (height * camera.zoom)
camera.position.x =
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
camera.position.y =
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
requestRender()
}
function onPointerUp() {
dragStart = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
function onHoverMove(event: PointerEvent) {
if (!camera || !mesh || !renderer || dragStart || !readSample) return
const rect = renderer.domElement.getBoundingClientRect()
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
raycaster.setFromCamera(pointerNdc, camera)
const hit = raycaster.intersectObject(mesh)[0]
if (!hit?.uv) {
pixel.value = null
return
}
const col = THREE.MathUtils.clamp(
Math.floor(hit.uv.x * imageWidth),
0,
imageWidth - 1
)
const row = THREE.MathUtils.clamp(
Math.floor(hit.uv.y * imageHeight),
0,
imageHeight - 1
)
const base = (row * imageWidth + col) * imageChannels
pixel.value = {
x: col,
y: imageHeight - 1 - row,
r: readSample(base),
g: readSample(base + 1),
b: readSample(base + 2),
a: imageChannels === 4 ? readSample(base + 3) : null
}
}
function onHoverLeave() {
pixel.value = null
}
function dispose() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
if (renderer) {
renderer.domElement.removeEventListener('wheel', onWheel)
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
renderer.domElement.removeEventListener('pointermove', onHoverMove)
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
}
viewport?.disposeRenderer()
texture?.dispose()
material?.dispose()
mesh?.geometry.dispose()
renderer = null
viewport = null
scene = null
camera = null
material = null
mesh = null
texture = null
readSample = null
}
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
onUnmounted(dispose)
return {
exposureStops,
dither,
clipWarnings,
gamut,
channel,
loading,
error,
dimensions,
stats,
histogram,
pixel,
mount,
dispose,
fitView,
normalizeExposure
}
}

View File

@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
const executionStore = useExecutionStore()
watch(
() => {
const workflow = workflowStore.activeWorkflow
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
},
([workflow, status]) => {
if (workflow && status !== undefined && status !== 'running') {
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
executionStore.clearWorkflowStatus(workflow)
}
},

View File

@@ -522,22 +522,6 @@ describe('hasUnpromotedWidgets', () => {
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('returns false (does not throw) when SubgraphNode is detached', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})
describe('isLinkedPromotion', () => {

View File

@@ -633,7 +633,6 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
if (subgraphNode.isDetached) return false
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>

View File

@@ -1,89 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { state } = vi.hoisted(() => ({
state: {
extension: null as { nodeCreated: (node: unknown) => void } | null,
widgetState: undefined as { options: Record<string, unknown> } | undefined
}
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: { nodeCreated: (node: unknown) => void }) => {
state.extension = ext
}
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => state.widgetState })
}))
await import('./createBoundingBoxes')
interface MockWidget {
name: string
hidden: boolean
options: Record<string, unknown>
widgetId?: string
}
function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
size: [100, 100] as [number, number],
setSize: vi.fn(),
findInputSlot: () => 0,
isInputConnected: () => connected,
widgets,
onConnectionsChange: undefined as unknown
}
}
beforeEach(() => {
state.widgetState = undefined
})
describe('Comfy.CreateBoundingBoxes extension', () => {
it('ignores nodes of other classes', () => {
const node = makeNode(true, 'SomethingElse')
state.extension!.nodeCreated(node)
expect(node.setSize).not.toHaveBeenCalled()
})
it('enlarges the node and hides width/height when a background is connected', () => {
const node = makeNode(true)
state.extension!.nodeCreated(node)
expect(node.setSize).toHaveBeenCalledWith([420, 560])
expect(node.widgets[0].hidden).toBe(true)
expect(node.widgets[1].hidden).toBe(true)
expect(node.widgets[0].options.hidden).toBe(true)
expect(node.widgets[2].hidden).toBe(false)
})
it('shows width/height when no background is connected', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(node.widgets[0].hidden).toBe(false)
expect(node.widgets[0].options.hidden).toBe(false)
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)
node.widgets[0].widgetId = 'w-0'
state.extension!.nodeCreated(node)
expect(state.widgetState.options.hidden).toBe(true)
})
it('chains a connections-change handler that re-syncs visibility', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(typeof node.onConnectionsChange).toBe('function')
})
})

View File

@@ -1,38 +0,0 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useExtensionService } from '@/services/extensionService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const DIMENSION_WIDGETS = new Set(['width', 'height'])
useExtensionService().registerExtension({
name: 'Comfy.CreateBoundingBoxes',
nodeCreated(node) {
if (node.constructor.comfyClass !== 'CreateBoundingBoxes') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 420), Math.max(oldHeight, 560)])
const widgetValueStore = useWidgetValueStore()
const syncDimensionVisibility = () => {
const slot = node.findInputSlot('background')
const hidden = slot >= 0 && node.isInputConnected(slot)
for (const widget of node.widgets ?? []) {
if (!DIMENSION_WIDGETS.has(widget.name)) continue
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
}
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
syncDimensionVisibility
)
}
})

View File

@@ -2,7 +2,6 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './createBoundingBoxes'
import './customWidgets'
import './dynamicPrompts'
import './editAttention'

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
@@ -29,7 +27,8 @@ export type Viewport3dDeps = {
viewHelperManager: ViewHelperManager
}
export class Viewport3d extends WebGLViewport {
export class Viewport3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
@@ -53,6 +52,7 @@ export class Viewport3d extends WebGLViewport {
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
@@ -63,7 +63,6 @@ export class Viewport3d extends WebGLViewport {
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
super(deps.renderer)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -74,6 +73,7 @@ export class Viewport3d extends WebGLViewport {
this.applyTargetSize(options.width, options.height)
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
@@ -94,7 +94,7 @@ export class Viewport3d extends WebGLViewport {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.observeResize(container, () => this.handleResize())
this.initResizeObserver(container)
}
start(): void {
@@ -118,6 +118,16 @@ export class Viewport3d extends WebGLViewport {
this.targetAspectRatio = width / height
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
@@ -390,15 +400,29 @@ export class Viewport3d extends WebGLViewport {
this.initialRenderTimer = null
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.disposeManagers()
this.disposeRenderer()
this.renderer.dispose()
this.renderer.domElement.remove()
}
protected disposeManagers(): void {

View File

@@ -26,7 +26,6 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))

View File

@@ -246,37 +246,3 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
expect(mockFetchApi).not.toHaveBeenCalled()
})
})
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
async function loadAudioUIWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.AudioWidget')
if (!extension)
throw new Error('Comfy.AudioWidget extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
}
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
it('excludes the audio player from workflow and prompt serialization', async () => {
const AUDIO_UI = await loadAudioUIWidget()
const domWidget = {
serialize: true,
options: {} as Record<string, unknown>
}
const node = fromAny<LGraphNode, unknown>({
addDOMWidget: vi.fn(() => domWidget),
constructor: { nodeData: { output_node: false } }
})
AUDIO_UI(node, 'audioUI')
expect(domWidget.serialize).toBe(false)
expect(domWidget.options.serialize).toBe(false)
})
})

View File

@@ -128,7 +128,6 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
audioUIWidget.options.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')

View File

@@ -7,6 +7,7 @@ import type {
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
TWidgetValue
@@ -272,7 +273,17 @@ export class PrimitiveNode extends LGraphNode {
widgetName: 'value',
nodeTypeForBrowser: targetNode.comfyClass ?? '',
inputNameForBrowser: targetInputName,
defaultValue
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
}
})
}

View File

@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphGroup,
LGraphNode,
LiteGraph,
LLink,
@@ -324,96 +323,6 @@ describe('Graph Clearing and Callbacks', () => {
})
})
describe('node:before-removed event', () => {
it('fires node:before-removed for a successful node removal', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
graph.events.addEventListener('node:before-removed', (e) => {
events.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
graph.remove(node)
expect(events).toHaveLength(1)
expect(events[0].node).toBe(node)
expect(events[0].graphAtDispatch).toBe(graph)
expect(node.graph).toBeNull()
})
it('does not fire node:before-removed for a node not in the graph', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when removing an LGraphGroup', () => {
const graph = new LGraph()
const group = new LGraphGroup('test-group')
graph.add(group)
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(group)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when ignore_remove is set', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
node.ignore_remove = true
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
expect(graph.nodes).toContain(node)
})
it('fires node:before-removed before node.onRemoved and detach', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const order: string[] = []
graph.events.addEventListener('node:before-removed', () => {
order.push(
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
)
})
node.onRemoved = () => {
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
}
graph.onNodeRemoved = (n) => {
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
}
graph.remove(node)
expect(order).toEqual([
'before-removed(graph=set)',
'onRemoved(graph=set)',
'onNodeRemoved(graph=null)'
])
})
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -466,53 +375,6 @@ describe('Subgraph Definition Garbage Collection', () => {
expect(graphRemovedNodeIds.size).toBe(2)
})
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
subgraph.events.addEventListener('node:before-removed', (e) => {
dispatched.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
for (const entry of dispatched) {
expect(entry.graphAtDispatch).toBe(subgraph)
}
})
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
const innerNode = innerNodes[0]
const order: string[] = []
subgraph.events.addEventListener('node:before-removed', () => {
order.push('before-removed')
})
innerNode.onRemoved = () => {
order.push('onRemoved')
}
subgraph.onNodeRemoved = () => {
order.push('onNodeRemoved')
}
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
})
it('subgraph definition is removed when SubgraphNode is removed', () => {
const rootGraph = new LGraph()
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)

View File

@@ -155,13 +155,6 @@ export interface BaseLGraph {
readonly rootGraph: LGraph
}
function fireNodeRemovalLifecycle(node: LGraphNode): void {
const graph: LGraph | null = node.graph
graph?.events.dispatch('node:before-removed', { node })
node.onRemoved?.()
graph?.onNodeRemoved?.(node)
}
/**
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
@@ -393,7 +386,8 @@ export class LGraph
// safe clear
if (this._nodes) {
for (const _node of this._nodes) {
fireNodeRemovalLifecycle(_node)
_node.onRemoved?.()
this.onNodeRemoved?.(_node)
}
}
@@ -1052,8 +1046,6 @@ export class LGraph
// sure? - almost sure is wrong
this.beforeChange()
this.events.dispatch('node:before-removed', { node })
const { inputs, outputs } = node
// disconnect inputs
@@ -1089,7 +1081,10 @@ export class LGraph
)
if (!hasRemainingReferences) {
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}

View File

@@ -829,7 +829,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this._lowQualityZoomThreshold > 0) {
this._isLowQuality = scale < this._lowQualityZoomThreshold
}
this.setDirty(true, true)
}
// Initialize link renderer if graph is available

View File

@@ -1,5 +1,5 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
@@ -51,13 +51,6 @@ export interface LGraphEventMap {
closingGraph: LGraph | Subgraph
}
/**
* Fires on the owning graph before per-node teardown begins
*/
'node:before-removed': {
node: LGraphNode
}
'node:property:changed': {
nodeId: NodeId
property: string

View File

@@ -85,19 +85,6 @@ describe('SubgraphNode Construction', () => {
expect(subgraphNode.graph).toBeNull()
})
it('should return empty widgets array (not throw) after removal', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => subgraphNode.widgets).not.toThrow()
expect(subgraphNode.widgets).toEqual([])
})
subgraphTest(
'should synchronize slots with subgraph definition',
({ subgraphWithNode }) => {

View File

@@ -68,10 +68,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return this.graph.rootGraph
}
get isDetached(): boolean {
return !this.graph
}
override get displayType(): string {
return 'Subgraph node'
}

View File

@@ -1,6 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -142,8 +141,6 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IBoundingBoxesWidget
| IColorsWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -346,19 +343,6 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
value: string
}
export interface IBoundingBoxesWidget extends IBaseWidget<
BoundingBox[],
'boundingboxes'
> {
type: 'boundingboxes'
value: BoundingBox[]
}
export interface IColorsWidget extends IBaseWidget<string[], 'colors'> {
type: 'colors'
value: string[]
}
export interface RangeValue {
min: number
max: number

View File

@@ -1,42 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('BoundingBoxesWidget', () => {
it('has the boundingboxes type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new BoundingBoxesWidget(
{
type: 'boundingboxes',
name: 'editor_state',
value: [],
options: {},
y: 0
},
node
)
expect(widget.type).toBe('boundingboxes')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -1,16 +0,0 @@
import type { IBoundingBoxesWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class BoundingBoxesWidget
extends BaseWidget<IBoundingBoxesWidget>
implements IBoundingBoxesWidget
{
override type = 'boundingboxes' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Bounding Boxes')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -1,36 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { ColorsWidget } from './ColorsWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('ColorsWidget', () => {
it('has the colors type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new ColorsWidget(
{ type: 'colors', name: 'palette', value: [], options: {}, y: 0 },
node
)
expect(widget.type).toBe('colors')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -1,16 +0,0 @@
import type { IColorsWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class ColorsWidget
extends BaseWidget<IColorsWidget>
implements IColorsWidget
{
override type = 'colors' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Colors')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -21,8 +21,6 @@ import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
import { ColorsWidget } from './ColorsWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { ImageCropWidget } from './ImageCropWidget'
@@ -64,8 +62,6 @@ export type WidgetTypeMap = {
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
boundingboxes: BoundingBoxesWidget
colors: ColorsWidget
[key: string]: BaseWidget
}
@@ -148,10 +144,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
case 'boundingboxes':
return toClass(BoundingBoxesWidget, narrowedWidget, node)
case 'colors':
return toClass(ColorsWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
"yearly": "سنوي",
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
"saveYearly": "وفّر 20%",
"yearlyDiscount": "خصم 20%",
"yourPlanIncludes": "خطتك تشمل:"
},
"tabMenu": {

View File

@@ -387,37 +387,13 @@
"collapseAll": "Collapse all",
"expandAll": "Expand all"
},
"hdrViewer": {
"title": "HDR Viewer",
"openInHdrViewer": "Open in HDR Viewer",
"hdrImage": "HDR image",
"failedToLoad": "Failed to load HDR image",
"exposure": "Exposure",
"normalizeExposure": "Auto exposure",
"channel": "Channel",
"channels": {
"rgb": "RGB",
"r": "R",
"g": "G",
"b": "B",
"a": "Alpha",
"luminance": "Luminance"
},
"sourceGamut": "Source gamut",
"dither": "Dither",
"clipWarnings": "Clip warnings",
"fitView": "Fit",
"histogram": "Histogram",
"resolution": "Resolution",
"min": "Min",
"max": "Max",
"mean": "Mean",
"stdDev": "Std dev",
"nan": "NaN",
"inf": "Inf"
},
"manager": {
"title": "Nodes Manager",
"survey": {
"title": "Join the waitlist",
"intro": "Installing custom nodes is coming soon to Comfy Cloud. Answer a few quick questions to join the waitlist and help shape this feature.",
"error": "This survey couldn't be loaded. Please try again later."
},
"nodePackInfo": "Node Pack Info",
"basicInfo": "Basic Info",
"actions": "Actions",
@@ -895,8 +871,8 @@
"nodes": "Nodes",
"models": "Models",
"assets": "Assets",
"workflows": "Work\u00adflows",
"templates": "Tem\u00adplates",
"workflows": "Workflows",
"templates": "Templates",
"console": "Console",
"menu": "Menu",
"imported": "Imported",
@@ -2142,21 +2118,6 @@
"monotone_cubic": "Smooth",
"linear": "Linear"
},
"boundingBoxes": {
"clearAll": "Clear all",
"clickRegionToEdit": "Click a region to edit it.",
"typeObj": "obj",
"typeText": "text",
"textLabel": "Text",
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette"
},
"palette": {
"addColor": "Add a color",
"swatchTitle": "Click edit · drag reorder · right-click remove"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
@@ -2563,7 +2524,6 @@
"billedYearly": "{total} Billed yearly",
"monthly": "Monthly",
"yearly": "Yearly",
"saveYearly": "Save 20%",
"tierNameYearly": "{name} Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
@@ -2574,6 +2534,7 @@
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"free": {
"name": "Free"
@@ -2990,7 +2951,7 @@
"share": "Share"
},
"shortcuts": {
"shortcuts": "Short\u00adcuts",
"shortcuts": "Shortcuts",
"essentials": "Essential",
"viewControls": "View Controls",
"manageShortcuts": "Manage Shortcuts",

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuales",
"saveYearly": "Ahorra 20%",
"yearlyDiscount": "20% DESCUENTO",
"yourPlanIncludes": "Tu plan incluye:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
"yearly": "سالانه",
"yearlyCreditsLabel": "کل اعتبار سالانه",
"saveYearly": "٪۲۰ صرفه‌جویی",
"yearlyDiscount": "٪۲۰ تخفیف",
"yourPlanIncludes": "طرح شما شامل:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Cet espace de travail na pas dabonnement",
"yearly": "Annuel",
"yearlyCreditsLabel": "Crédits annuels totaux",
"saveYearly": "Économisez 20 %",
"yearlyDiscount": "20% DE RÉDUCTION",
"yourPlanIncludes": "Votre forfait comprend :"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
"yearly": "年額",
"yearlyCreditsLabel": "年間合計クレジット",
"saveYearly": "20%お得",
"yearlyDiscount": "20%割引",
"yourPlanIncludes": "ご利用プランに含まれるもの:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다",
"yearly": "연간",
"yearlyCreditsLabel": "연간 총 크레딧",
"saveYearly": "20% 절감",
"yearlyDiscount": "20% 할인",
"yourPlanIncludes": "귀하의 플랜 포함 사항:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "Este espaço de trabalho não possui uma assinatura",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuais",
"saveYearly": "Economize 20%",
"yearlyDiscount": "20% DE DESCONTO",
"yourPlanIncludes": "Seu plano inclui:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Это рабочее пространство не имеет подписки",
"yearly": "Ежегодно",
"yearlyCreditsLabel": "Годовые кредиты",
"saveYearly": "Экономия 20%",
"yearlyDiscount": "СКИДКА 20%",
"yourPlanIncludes": "Ваш план включает:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Bu çalışma alanı bir aboneliğe sahip değil",
"yearly": "Yıllık",
"yearlyCreditsLabel": "Toplam yıllık krediler",
"saveYearly": "%20 tasarruf",
"yearlyDiscount": "%20 İNDİRİM",
"yourPlanIncludes": "Planınız şunları içerir:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "此工作區尚未訂閱",
"yearly": "每年",
"yearlyCreditsLabel": "年度總點數",
"saveYearly": "節省 20%",
"yearlyDiscount": "八折優惠",
"yourPlanIncludes": "您的方案包含:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "此工作区未订阅",
"yearly": "年度",
"yearlyCreditsLabel": "总共年度积分",
"saveYearly": "立省 20%",
"yearlyDiscount": "20% 减免",
"yourPlanIncludes": "您的计划包括:"
},
"tabMenu": {

View File

@@ -1,7 +1,6 @@
<template>
<BaseModalLayout
v-model:right-panel-open="isRightPanelOpen"
data-testid="asset-browser-modal"
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="displayTitle"

View File

@@ -1,6 +1,5 @@
<template>
<div
data-testid="asset-card"
data-component-id="AssetCard"
:data-asset-id="asset.id"
:aria-labelledby="titleId"

View File

@@ -10,7 +10,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
import { useMediaAssetActions } from './useMediaAssetActions'
// Use vi.hoisted to create a mutable reference for isCloud
@@ -146,17 +145,6 @@ vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: mockGetOutputAssetMetadata
}))
const mockResolveOutputAssetItems = vi.hoisted(() =>
vi.fn().mockResolvedValue([])
)
vi.mock('../utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal<typeof outputAssetUtilModule>()
return {
...actual,
resolveOutputAssetItems: mockResolveOutputAssetItems
}
})
const mockDeleteAsset = vi.hoisted(() => vi.fn())
const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
@@ -295,8 +283,6 @@ describe('useMediaAssetActions', () => {
mockGetOutputAssetMetadata.mockReset()
mockGetOutputAssetMetadata.mockReturnValue(null)
mockGetAssetType.mockReset()
mockResolveOutputAssetItems.mockReset()
mockResolveOutputAssetItems.mockResolvedValue([])
})
describe('addWorkflow', () => {
@@ -596,236 +582,6 @@ describe('useMediaAssetActions', () => {
})
})
describe('downloadAssets - OSS multi-output expansion', () => {
beforeEach(() => {
mockIsCloud.value = false
mockGetAssetType.mockReturnValue('output')
mockGetOutputAssetMetadata.mockImplementation(
(meta: Record<string, unknown> | undefined) =>
meta && 'jobId' in meta ? meta : null
)
})
function createOutputAsset(
id: string,
name: string,
jobId: string,
outputCount?: number,
previewUrl?: string
): AssetItem {
return createMockAsset({
id,
name,
tags: ['output'],
preview_url: previewUrl ?? `https://example.com/${name}`,
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
})
}
it('expands a grouped asset into individual downloads', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockResolvedValueOnce([
createOutputAsset('g1-out1', 'out1.png', 'job1'),
createOutputAsset('g1-out2', 'out2.png', 'job1'),
createOutputAsset('g1-out3', 'out3.png', 'job1')
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job1', outputCount: 3 }),
expect.objectContaining({ createdAt: expect.any(String) })
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
1,
'https://example.com/out1.png',
'out1.png'
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
2,
'https://example.com/out2.png',
'out2.png'
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
3,
'https://example.com/out3.png',
'out3.png'
)
expect(mockCreateAssetExport).not.toHaveBeenCalled()
})
it('mixes grouped and single-output assets in one selection', async () => {
const grouped = createOutputAsset('g1', 'cover.png', 'job1', 2)
const single = createOutputAsset('s1', 'solo.png', 'job2')
mockResolveOutputAssetItems.mockResolvedValueOnce([
createOutputAsset('g1-a', 'a.png', 'job1'),
createOutputAsset('g1-b', 'b.png', 'job1')
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped, single])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['a.png', 'b.png', 'solo.png'])
})
it('falls back to the original asset when resolveOutputAssetItems returns empty', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockResolvedValueOnce([])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/cover.png',
'cover.png'
)
})
it('does not call resolveOutputAssetItems when no grouped assets are selected', () => {
const single1 = createOutputAsset(
's1',
'a.png',
'job1',
undefined,
'https://example.com/a.png'
)
const single2 = createOutputAsset(
's2',
'b.png',
'job2',
1,
'https://example.com/b.png'
)
const actions = useMediaAssetActions()
actions.downloadAssets([single1, single2])
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
})
it('deduplicates downloads when an expanded child is also selected alongside its parent', async () => {
const grouped = createOutputAsset('job1-cover', 'cover.png', 'job1', 3)
const child = createMockAsset({
id: 'job1-child-a',
name: 'out1.png',
tags: ['output'],
preview_url: 'https://example.com/out1.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
})
mockResolveOutputAssetItems.mockResolvedValueOnce([
createMockAsset({
id: 'job1-child-a',
name: 'out1.png',
tags: ['output'],
preview_url: 'https://example.com/out1.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
}),
createMockAsset({
id: 'job1-child-b',
name: 'out2.png',
tags: ['output'],
preview_url: 'https://example.com/out2.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
})
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped, child])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
})
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['out1.png', 'out2.png'])
})
it('falls back to the preview download when resolveOutputAssetItems rejects', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockRejectedValueOnce(new Error('boom'))
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/cover.png',
'cover.png'
)
})
it('still downloads resolvable assets when one grouped asset fails to expand', async () => {
const failingGrouped = createOutputAsset(
'g1',
'cover1.png',
'job1',
3,
'https://example.com/cover1.png'
)
const okGrouped = createOutputAsset('g2', 'cover2.png', 'job2', 2)
mockResolveOutputAssetItems.mockImplementation(
(metadata: { jobId: string }) => {
if (metadata.jobId === 'job1') {
return Promise.reject(new Error('job1 lookup failed'))
}
return Promise.resolve([
createOutputAsset('g2-a', 'out2a.png', 'job2'),
createOutputAsset('g2-b', 'out2b.png', 'job2')
])
}
)
const actions = useMediaAssetActions()
actions.downloadAssets([failingGrouped, okGrouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['cover1.png', 'out2a.png', 'out2b.png'])
})
})
describe('downloadAssets - cloud zip filters', () => {
beforeEach(() => {
mockIsCloud.value = true

View File

@@ -27,10 +27,7 @@ import { getAssetUrl } from '../utils/assetUrlUtil'
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
import {
getAssetOutputCount,
resolveOutputAssetItems
} from '../utils/outputAssetUtil'
import { getAssetOutputCount } from '../utils/outputAssetUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { isResultItemType } from '@/utils/typeGuardUtil'
@@ -112,9 +109,8 @@ export function useMediaAssetActions() {
* Download one or more assets.
* In cloud mode, creates a ZIP export via the backend when called with
* 2+ assets or with any asset whose job has `outputCount > 1`.
* In OSS mode, downloads each file directly, expanding grouped assets
* (`outputCount > 1`) into their individual outputs.
* With no argument, uses the asset from `MediaAssetKey` context.
* Falls back to direct downloads in OSS mode and for single single-output
* assets. With no argument, uses the asset from `MediaAssetKey` context.
*/
const downloadAssets = (assets?: AssetItem[]) => {
const targetAssets =
@@ -131,13 +127,13 @@ export function useMediaAssetActions() {
return
}
if (hasMultiOutputJobs) {
void downloadAssetsIndividually(targetAssets)
return
}
try {
targetAssets.forEach((asset) => downloadSingleAsset(asset))
targetAssets.forEach((asset) => {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
toast.add({
severity: 'success',
summary: t('g.success'),
@@ -154,66 +150,6 @@ export function useMediaAssetActions() {
}
}
function downloadSingleAsset(asset: AssetItem) {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
}
async function expandAssetForDownload(
asset: AssetItem
): Promise<AssetItem[]> {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (
!metadata ||
typeof metadata.outputCount !== 'number' ||
metadata.outputCount <= 1
) {
return [asset]
}
try {
const resolved = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
return resolved.length > 0 ? resolved : [asset]
} catch (error) {
console.error('Failed to expand grouped asset for download:', error)
return [asset]
}
}
async function downloadAssetsIndividually(assets: AssetItem[]) {
try {
const expanded = await Promise.all(assets.map(expandAssetForDownload))
const seenAssetIds = new Set<string>()
const filesToDownload = expanded.flat().filter((asset) => {
if (seenAssetIds.has(asset.id)) return false
seenAssetIds.add(asset.id)
return true
})
filesToDownload.forEach((asset) => downloadSingleAsset(asset))
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t(
'mediaAsset.selection.downloadsStarted',
filesToDownload.length
),
life: 2000
})
} catch (error) {
console.error('Failed to download assets:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
}
async function downloadAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()

View File

@@ -1,169 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createAssetWidget } from './createAssetWidget'
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
const show = vi.fn()
const browse = vi.fn()
return {
useAssetBrowserDialog: () => ({ show, browse })
}
})
interface HostAssetWidget extends IBaseWidget<
string,
'asset',
IWidgetAssetOptions
> {
node: LGraphNode
}
type OnWidgetChanged = NonNullable<LGraphNode['onWidgetChanged']>
function checkpointAsset(name: string): AssetItem {
return {
id: `asset-${name}`,
name,
hash: 'checkpoint-hash',
mime_type: 'application/octet-stream',
tags: []
}
}
function createAssetWidgetNode() {
const node = new LGraphNode('TestNode')
const onWidgetChanged = vi.fn<OnWidgetChanged>()
node.onWidgetChanged = onWidgetChanged
return { node, onWidgetChanged }
}
function assertAssetOptions(
options: unknown
): asserts options is IWidgetAssetOptions {
if (!options || typeof options !== 'object') {
throw new Error('Expected asset widget options')
}
if (!('openModal' in options) || typeof options.openModal !== 'function') {
throw new Error('Expected asset widget options')
}
}
function firstShowOptions() {
const showOptions = vi.mocked(useAssetBrowserDialog().show).mock.calls[0]?.[0]
if (!showOptions) {
throw new Error('Expected the asset browser dialog to open')
}
return showOptions
}
describe('createAssetWidget', () => {
let captureCanvasState: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.resetAllMocks()
captureCanvasState = vi.fn()
setActivePinia(
createTestingPinia({
stubActions: false,
initialState: {
workflow: {
activeWorkflow: {
changeTracker: { captureCanvasState }
}
}
}
})
)
})
it('preserves regular asset widget change handling for the owning widget', async () => {
const { node, onWidgetChanged } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
inputNameForBrowser: 'ckpt_name',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
const showOptions = firstShowOptions()
expect(showOptions).toMatchObject({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: 'fake_model.safetensors'
})
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
expect(widget.value).toBe('real_model.safetensors')
expect(onWidgetChanged).toHaveBeenCalledWith(
'ckpt_name',
'real_model.safetensors',
'fake_model.safetensors',
widget
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('commits cloned asset modal selections through the promoted host widget', async () => {
const { node, onWidgetChanged: sourceOnWidgetChanged } =
createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
inputNameForBrowser: 'ckpt_name',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(sourceWidget.options)
const hostCallback = vi.fn<NonNullable<IBaseWidget['callback']>>()
const hostNode = new LGraphNode('PromotedHostNode')
const hostOnWidgetChanged = vi.fn<OnWidgetChanged>()
hostNode.onWidgetChanged = hostOnWidgetChanged
const hostWidget: HostAssetWidget = {
type: 'asset',
name: 'host_ckpt_name',
value: 'fake_model.safetensors',
callback: hostCallback,
options: sourceWidget.options,
node: hostNode,
y: 0
}
await sourceWidget.options.openModal(hostWidget)
const showOptions = firstShowOptions()
expect(showOptions).toMatchObject({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: 'fake_model.safetensors'
})
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
expect(sourceOnWidgetChanged).not.toHaveBeenCalled()
expect(hostWidget.value).toBe('real_model.safetensors')
expect(hostCallback).toHaveBeenCalledWith('real_model.safetensors')
expect(hostOnWidgetChanged).toHaveBeenCalledWith(
'host_ckpt_name',
'real_model.safetensors',
'fake_model.safetensors',
hostWidget
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
})

View File

@@ -12,11 +12,8 @@ import {
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
type WidgetWithNode = IBaseWidget & { node: LGraphNode }
interface CreateAssetWidgetParams {
/** The node to add the widget to */
node: LGraphNode
@@ -28,26 +25,34 @@ interface CreateAssetWidgetParams {
inputNameForBrowser?: string
/** Default value for the widget */
defaultValue?: string
/** Callback when widget value changes */
onValueChange?: (
widget: IBaseWidget,
newValue: string,
oldValue: unknown
) => void
}
interface CreateAssetWidgetOptionsParams {
widgetName: string
nodeTypeForBrowser: string
inputNameForBrowser?: string
}
/**
* Creates an asset widget that opens the Asset Browser dialog for model selection.
* Used by both regular nodes (via useComboWidget) and PrimitiveNode.
*
* @param params - Configuration for the asset widget
* @returns The created asset widget
*/
export function createAssetWidget(
params: CreateAssetWidgetParams
): IBaseWidget {
const {
node,
widgetName,
nodeTypeForBrowser,
inputNameForBrowser,
defaultValue,
onValueChange
} = params
function hasOwnerNode(widget: IBaseWidget): widget is WidgetWithNode {
return (
'node' in widget && typeof widget.node === 'object' && widget.node !== null
)
}
function createAssetWidgetOptions({
widgetName,
nodeTypeForBrowser,
inputNameForBrowser
}: CreateAssetWidgetOptionsParams): IWidgetAssetOptions {
const inputName = inputNameForBrowser ?? widgetName
const displayLabel = defaultValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
@@ -55,8 +60,8 @@ function createAssetWidgetOptions({
await assetBrowserDialog.show({
nodeType: nodeTypeForBrowser,
inputName,
currentValue: String(widget.value ?? ''),
inputName: inputNameForBrowser ?? widgetName,
currentValue: widget.value as string,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
@@ -93,44 +98,15 @@ function createAssetWidgetOptions({
const oldValue = widget.value
widget.value = validatedFilename.data
widget.callback?.(widget.value)
if (hasOwnerNode(widget)) {
widget.node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
if (oldValue !== validatedFilename.data) {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
onValueChange?.(widget, validatedFilename.data, oldValue)
}
})
}
return {
const options: IWidgetAssetOptions = {
openModal,
nodeType: nodeTypeForBrowser
}
}
export function createAssetWidget(
params: CreateAssetWidgetParams
): IBaseWidget {
const {
node,
widgetName,
nodeTypeForBrowser,
inputNameForBrowser,
defaultValue
} = params
const displayLabel = defaultValue ?? t('widgets.selectModel')
const options = createAssetWidgetOptions({
widgetName,
nodeTypeForBrowser,
inputNameForBrowser
})
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
}

View File

@@ -135,6 +135,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
@@ -187,7 +188,9 @@ const signUpWithEmail = async (values: SignUpData) => {
}
onMounted(async () => {
telemetry?.trackSignupOpened()
if (isCloud) {
telemetry?.trackSignupOpened()
}
userIsInChina.value = await isInChina()
})

View File

@@ -18,6 +18,7 @@ import {
getSurveyCompletedStatus,
submitSurvey
} from '@/platform/cloud/onboarding/auth'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { useTelemetry } from '@/platform/telemetry'
@@ -45,7 +46,9 @@ onMounted(async () => {
await router.replace({ name: 'cloud-user-check' })
return
}
useTelemetry()?.trackSurvey('opened')
if (isCloud) {
useTelemetry()?.trackSurvey('opened')
}
} catch (error) {
console.error('Failed to check survey status:', error)
}
@@ -59,7 +62,9 @@ const onSubmitSurvey = async (payload: Record<string, unknown>) => {
isSubmitting.value = true
try {
await submitSurvey(payload)
useTelemetry()?.trackSurvey('submitted', payload)
if (isCloud) {
useTelemetry()?.trackSurvey('submitted', payload)
}
await router.push({ name: 'cloud-user-check' })
} finally {
isSubmitting.value = false

View File

@@ -1,46 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
renewalDate: mockRenewalDate
}))
}))
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(FreeTierDialogContent, {
global: {
plugins: [i18n]
}
})
}
describe('FreeTierDialogContent', () => {
it('renders the next refresh line formatted from the facade renewalDate', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent()
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('hides the next refresh line when renewalDate is null', () => {
mockRenewalDate.value = null
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -102,9 +102,9 @@
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
@@ -116,17 +116,7 @@ defineEmits<{
upgrade: []
}>()
const { renewalDate } = useBillingContext()
const formattedRenewalDate = computed(() => {
if (!renewalDate.value) return ''
return new Date(renewalDate.value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const { formattedRenewalDate } = useSubscription()
const freeTierCredits = computed(() => getTierCredits('free'))
</script>

View File

@@ -7,7 +7,6 @@ import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
async function flushPromises() {
@@ -24,8 +23,10 @@ function createDeferredPromise<T>() {
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
@@ -64,25 +65,13 @@ Object.defineProperty(globalThis, 'localStorage', {
writable: true
})
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
tier: computed(() => mockSubscriptionTier.value),
subscription: computed(() =>
mockSubscriptionTier.value
? {
isActive: mockIsActiveSubscription.value,
tier: mockSubscriptionTier.value,
duration: mockSubscriptionDuration.value,
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true
}
: null
)
isFreeTier: computed(() => false),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
subscriptionStatus: ref(null)
})
}))
@@ -228,7 +217,7 @@ describe('PricingTable', () => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockSubscriptionDuration.value = 'MONTHLY'
mockIsYearlySubscription.value = false
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
@@ -373,7 +362,6 @@ describe('PricingTable', () => {
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'ANNUAL'
renderComponent()
await flushPromises()
@@ -382,29 +370,12 @@ describe('PricingTable', () => {
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeDefined()
expect(currentPlanButton).toBeDisabled()
await userEvent.click(currentPlanButton!)
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'MONTHLY'
renderComponent()
await flushPromises()
const currentPlanButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeUndefined()
})
it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false

View File

@@ -30,9 +30,9 @@
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
>
{{ t('subscription.saveYearly') }}
-20%
</div>
</div>
</template>
@@ -67,15 +67,15 @@
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground tabular-nums"
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground"
>
${{ getPrice(tier) }}
<span
v-show="currentBillingCycle === 'yearly'"
class="text-2xl text-muted-foreground line-through"
>
${{ tier.pricing.monthly }}
</span>
${{ getPrice(tier) }}
</span>
<span class="font-inter text-xl/normal text-base-foreground">
{{ t('subscription.usdPerMonth') }}
@@ -122,12 +122,9 @@
}}
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
aria-hidden="true"
/>
<i class="icon-[lucide--component] text-sm text-amber-400" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
class="font-inter text-sm/normal font-bold text-base-foreground"
>
{{ n(getCreditsDisplay(tier)) }}
</span>
@@ -139,7 +136,7 @@
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
class="font-inter text-sm/normal font-bold text-base-foreground"
>
{{ tier.maxDuration }}
</span>
@@ -189,7 +186,7 @@
</div>
</div>
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
class="font-inter text-sm/normal font-bold text-base-foreground"
>
~{{ n(tier.pricing.videoEstimate) }}
</span>
@@ -266,8 +263,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import {
TIER_PRICING,
TIER_TO_KEY
@@ -364,13 +361,9 @@ const tiers: PricingTierConfig[] = [
const {
isActiveSubscription,
isFreeTier,
tier: subscriptionTier,
subscription
} = useBillingContext()
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
subscriptionTier,
isYearlySubscription
} = useSubscription()
const telemetry = useTelemetry()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()

View File

@@ -16,6 +16,7 @@ import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@comfyorg/tailwind-utils'
@@ -37,8 +38,8 @@ const emit = defineEmits<{
subscribed: []
}>()
const { isActiveSubscription, showSubscriptionDialog, tier } =
useBillingContext()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { subscriptionTier } = useSubscription()
const isAwaitingStripeSubscription = ref(false)
watch(
@@ -52,9 +53,11 @@ watch(
)
const handleSubscribe = () => {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: tier.value?.toLowerCase()
})
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
}
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
}

View File

@@ -237,10 +237,12 @@ function useSubscriptionInternal() {
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
if (isCloud) {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
}
void showSubscriptionRequiredDialog(options)
}

View File

@@ -2,26 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
const mockBillingFetchBalance = vi.fn()
const mockAuthFetchBalance = vi.fn()
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
fetchBalance: mockAuthFetchBalance
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockBillingFetchBalance,
fetchStatus: mockFetchStatus
})
}))
@@ -38,19 +38,6 @@ vi.mock('@/stores/commandStore', () => ({
})
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackHelpResourceClicked: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
: null
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
@@ -61,7 +48,6 @@ Object.defineProperty(window, 'open', {
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
})
describe('handleAddApiCredits', () => {
@@ -87,27 +73,6 @@ describe('useSubscriptionActions', () => {
expect(isLoadingSupport.value).toBe(false)
})
it('tracks help-resource telemetry when messaging support in cloud', async () => {
const { handleMessageSupport } = useSubscriptionActions()
await handleMessageSupport()
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
})
it('does not fire telemetry when messaging support in OSS builds', async () => {
mockIsCloud.value = false
const { handleMessageSupport } = useSubscriptionActions()
await handleMessageSupport()
expect(mockTrackHelpResourceClicked).not.toHaveBeenCalled()
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
const { handleMessageSupport, isLoadingSupport } =
@@ -119,21 +84,20 @@ describe('useSubscriptionActions', () => {
})
describe('handleRefresh', () => {
it('should refresh balance and status through the billing facade', async () => {
it('should call both fetchBalance and fetchStatus', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockBillingFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
expect(mockAuthFetchBalance).not.toHaveBeenCalled()
})
it('swallows refresh failures without surfacing a toast', async () => {
mockBillingFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,8 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -10,9 +12,10 @@ import { useCommandStore } from '@/stores/commandStore'
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchBalance, fetchStatus } = useBillingContext()
const { fetchStatus } = useBillingContext()
const isLoadingSupport = ref(false)
@@ -27,11 +30,13 @@ export function useSubscriptionActions() {
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
if (isCloud) {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
}
await commandStore.execute('Comfy.ContactSupport')
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)
@@ -42,7 +47,7 @@ export function useSubscriptionActions() {
const handleRefresh = async () => {
try {
await Promise.all([fetchBalance(), fetchStatus()])
await Promise.all([authActions.fetchBalance(), fetchStatus()])
} catch (error) {
console.error('[useSubscriptionActions] Error refreshing data:', error)
}

View File

@@ -129,21 +129,6 @@ describe('useSubscriptionDialog', () => {
expect(props).not.toHaveProperty('onChooseTeam')
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is silently ignored and collapses the wide table to the
// default md (576px) frame.
expect(dialogComponentProps).toHaveProperty('contentClass')
expect(dialogComponentProps).not.toHaveProperty('style')
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true

Some files were not shown because too many files have changed in this diff Show More