Compare commits

...

4 Commits

Author SHA1 Message Date
Terry Jia
8a3dc9f3d6 feat: output model_info from Load3D node
Expose per-object gizmo transforms (uuid, name, type, position,
rotation, quaternion, scale, up, visible, matrix) as a new model_info
output on the Load3D node. GizmoManager.getModelInfo() reads the live
target object and the Load3D widget serializes it into the node payload.

The payload is a list to support multiple objects later; the viewer
currently renders a single main object, so it emits a one-element list.
2026-05-27 17:39:40 -04:00
Luke Mino-Altherr
db6b7a315c chore: remediate 51 Dependabot vulnerabilities (#12345)
## Summary

Remediate 51 of 63 open Dependabot security alerts by bumping direct
dependencies, bumping parent dependencies, and adding targeted pnpm
overrides for transitive dependencies.

## Changes

- **What**: Two batches of dependency security fixes
- **Batch 1**: Bump catalog minimums for axios, dompurify, happy-dom,
vite, uuid. Fix axios header type narrowing in api.ts.
- **Batch 2**: Bump parent deps (@iconify/tailwind4, vue, knip) to pull
fixed transitive deps. Add tilde-pinned pnpm overrides for protobufjs,
flatted, defu where no parent fix is available. Unexport 6 unused types
flagged by knip upgrade.
- **Dependencies**: vue 3.5.13->3.5.34 required two type fixes
(LazyImage ClassValue, dialogStore deep instantiation)

## Review Focus

- pnpm overrides in package.json: protobufjs ~7.6.0, flatted ~3.4.2,
defu ~6.1.7
- Vue 3.5.34 type narrowing fixes in LazyImage.vue and dialogStore.ts

## Remaining (12 alerts, separate PRs)

- minimatch (4H) - 4 major version lines, needs per-consumer analysis
- picomatch (2M) - two major version lines
- brace-expansion (2M) - multiple major version lines
- astro (2: 1L+1M) - major version bump 5->6
- postcss 8.5.8 (1M) - dev-only, from @vue/compiler-sfc@3.5.28 via
storybook/devtools
- yaml 1.10.2 (1M) - from cosmiconfig->nx, no upstream fix in yaml v1
- lodash/lodash-es (4: 2H+2M) - dev-only, upstream still uses 4.17.x
- @babel/plugin-transform-modules-systemjs (1H) - dev-only via nx
- fast-uri (2H) - dev-only via ajv->nx/stylelint

Fixes #FE-762

---------

Co-authored-by: Austin Mroz <austin@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-27 14:07:34 -07:00
AustinMroz
b89940134f Better preview grid tiling (#12463)
The previous image preview tiling code was less than ideal. It had fixed
breakpoints based on the number of images. Outputs with many images
would become comically long.

This PR instead tiles images to fill the available space.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/e793ce65-8efc-44ca-b049-98f066a65b7d"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/ca891ce2-335f-42ce-aeec-a99579f669c8"
/>|
2026-05-27 20:26:44 +00:00
Christian Byrne
7ac1cbbd53 test: add E2E coverage for NE, SW, NW corner node resizing (#11408)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Adds parameterized Playwright E2E tests covering all non-SE resize
corners (NE, SW, NW), closing the coverage gap in the `useNodeResize.ts`
switch statement
- Adds `resizeFromCorner()` and `getResizeHandle()` to `VueNodeFixture`
for reuse across tests
- Test cases are derived from the production `RESIZE_HANDLES` config so
they stay in sync with the actual handle definitions

## Test Groups (8 new tests)

| Group | Tests | Coverage |
|-------|-------|----------|
| Corner resize directions | NE, SW, NW — size increases and correct
edges shift | Lines 110-124, 184 |
| Opposite edge anchoring | NE, SW, NW — opposite corner stays fixed |
Position compensation end-to-end |
| Minimum size enforcement | SW width clamp (≥ MIN_NODE_WIDTH), NE
height clamp | Lines 162-176 |

## Design Decisions

**Locator-based handle discovery**: `resizeFromCorner()` finds handles
via `getByRole('button', { name: ariaLabel })` instead of coordinate
offsets. The resize handles have `opacity-0 pointer-events-auto`,
meaning they're always interactive even when visually transparent —
Playwright considers elements with `opacity: 0` as visible (it only
gates on `visibility: hidden` / `display: none` / zero-size bounding
box). If this approach turns out to be flaky in CI due to handle
discoverability, we can fall back to coordinate-based targeting
(computing offsets from the node's bounding box corners), which is what
the original SE-corner test uses.

**Parameterization from production config**: Tests import
`RESIZE_HANDLES` from `resizeHandleConfig.ts` and derive test case data
(drag direction, which axes move) from the corner name. An upfront guard
throws if any expected corner is missing from the config, preventing
silent coverage loss.

**Aria-label coupling**: `RESIZE_HANDLE_LABELS` in `VueNodeFixture`
hardcodes the English aria-label strings. This is intentional — tests
run in English locale, and aria-labels are the accessibility interface
contract. If a more stable hook is needed (e.g., `data-testid` per
handle), that can be added to `LGraphNode.vue` in a follow-up.

**Frame settlement**: `resizeFromCorner()` calls `nextFrame()` after the
mouse-up to ensure layout settles before assertions run, per
`FLAKE_PREVENTION_RULES.md`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11408-test-add-E2E-coverage-for-NE-SW-NW-corner-node-resizing-3476d73d3650818d8a5ce5d6d535b38c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-27 17:26:59 +00:00
29 changed files with 1370 additions and 983 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
export type NavDropdownItem = {
type NavDropdownItem = {
label: string
href: string
badge?: string

View File

@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
interface DroppedRole {
title: string
reason: string
}

View File

@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedNode {
interface DroppedNode {
name: string
reason: string
}

View File

@@ -128,7 +128,8 @@ export const TestIds = {
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
slotConnectionDot: 'slot-connection-dot',
imageGrid: 'image-grid'
},
selectionToolbox: {
root: 'selection-toolbox',

View File

@@ -1,8 +1,15 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
interface BoxOrigin {
readonly x: number
readonly y: number
}
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
@@ -15,7 +22,9 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly imageGrid: Locator
public readonly content: Locator
public readonly resize: { bottomRight: Locator }
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -28,7 +37,10 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
this.content = locator.locator('.lg-node-content')
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
this.resize = { bottomRight }
}
async getTitle(): Promise<string> {
@@ -77,4 +89,100 @@ export class VueNodeFixture {
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
/**
* Click the node header to select it, then return its bounding box.
* Throws if the node is not laid out because geometry-sensitive tests
* cannot proceed without coordinates.
*/
async selectAndGetBox(): Promise<{
x: number
y: number
width: number
height: number
}> {
await this.header.click()
const box = await this.boundingBox()
if (!box) {
throw new Error('Node bounding box not found after select')
}
return box
}
/**
* Assert this node's top-left origin stays within `precision` decimal
* places of `expected`. Wraps the polled bounding-box pattern that drift
* tests repeat for both axes.
*/
async expectAnchoredAt(
expected: BoxOrigin,
{ precision = 1 }: { precision?: number } = {}
): Promise<void> {
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
}
/** Poll the node's left/x edge for use with `expect.poll`. */
pollLeftEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.x ?? null
/** Poll the node's top/y edge for use with `expect.poll`. */
pollTopEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.y ?? null
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
pollRightEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.x + b.width : null
}
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
pollBottomEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.y + b.height : null
}
/** Poll the node's width for use with `expect.poll`. */
pollWidth = async (): Promise<number | null> =>
(await this.boundingBox())?.width ?? null
/** Poll the node's height for use with `expect.poll`. */
pollHeight = async (): Promise<number | null> =>
(await this.boundingBox())?.height ?? null
/** Locator for the resize handle at the given corner, scoped to this node. */
getResizeHandle(corner: CompassCorners): Locator {
return this.root.locator(`[data-corner="${corner}"]`)
}
/**
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
* Uses `hover()` to land the pointer on the handle with Playwright's
* actionability checks before starting the mouse sequence, which protects
* against occluding overlays and subpixel hit-test misses.
*/
async resizeFromCorner(
corner: CompassCorners,
deltaX: number,
deltaY: number
): Promise<void> {
const handle = this.getResizeHandle(corner)
await handle.hover()
const box = await handle.boundingBox()
if (!box) {
throw new Error(
`Resize handle for corner "${corner}" has no bounding box`
)
}
const page = this.locator.page()
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, {
steps: 5
})
await page.mouse.up()
}
}

View File

@@ -1,11 +1,15 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
@@ -136,3 +140,44 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
}
)
})
async function countColumns(locator: Locator) {
return await locator.locator('img').evaluateAll((images) => {
const yOffsets = images.map((image) => image.getBoundingClientRect().y)
return yOffsets.filter((yOffset) => yOffset === yOffsets[0]).length
})
}
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
wstest(
'Image previews tile to fit node',
async ({ comfyMouse, comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Inject multiple previews', async () => {
const file = { filename: 'example.png', type: 'input' }
const images = new Array(100).fill(file)
execution.executed('', '1', { images })
await expect(node.imageGrid.locator('img')).toHaveCount(100)
})
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
})

View File

@@ -1,56 +1,165 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
import {
RESIZE_HANDLES,
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
test.describe('Vue Node Resizing', { tag: '@vue-nodes' }, () => {
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
async function setupResizableNode(comfyPage: ComfyPage, title: string) {
await expect(comfyPage.vueNodes.getNodeByTitle(title)).toHaveCount(1)
const node = await comfyPage.vueNodes.getFixtureByTitle(title)
const box = await node.selectAndGetBox()
return { node, box }
}
// Select the node first (this was causing the bug)
await node.header.click()
test.describe(
'Vue Node Resizing',
{ tag: ['@vue-nodes', '@canvas', '@node'] },
() => {
let originalMinimapVisible: boolean | undefined
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Minimap overlays the canvas and intercepts pointer events that land in
// its hit area during resize drags, so disable it for this suite. Capture
// and restore the prior value to avoid leaking the override to other specs
// that run on the same user-data-dir.
test.beforeEach(async ({ comfyPage }) => {
originalMinimapVisible = await comfyPage.settings.getSetting<boolean>(
'Comfy.Minimap.Visible'
)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.canvasOps.resetView()
})
// Verify position unchanged after selection
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
test.afterEach(async ({ comfyPage }) => {
if (originalMinimapVisible !== undefined) {
await comfyPage.settings.setSetting(
'Comfy.Minimap.Visible',
originalMinimapVisible
)
}
})
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
const { node, box: initialBox } = await setupResizableNode(
comfyPage,
'Load Checkpoint'
)
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
await node.expectAnchoredAt(initialBox)
// Position should NOT have changed (the bug was position drift)
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
await node.resizeFromCorner('SE', 50, 30)
// Size should have increased
await expect
.poll(async () => (await node.boundingBox())?.width)
.toBeGreaterThan(initialBox.width)
await expect
.poll(async () => (await node.boundingBox())?.height)
.toBeGreaterThan(initialBox.height)
})
})
await node.expectAnchoredAt(initialBox)
await expect.poll(node.pollWidth).toBeGreaterThan(initialBox.width)
await expect.poll(node.pollHeight).toBeGreaterThan(initialBox.height)
})
const cornerCases = RESIZE_HANDLES.map((h) => ({
corner: h.corner,
dragX: hasWestEdge(h.corner) ? -50 : 50,
dragY: hasNorthEdge(h.corner) ? -40 : 40
}))
test.describe('corner resize directions', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner}: size increases and correct edges shift`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(node.pollWidth).toBeGreaterThan(box.width)
await expect.poll(node.pollHeight).toBeGreaterThan(box.height)
if (hasWestEdge(corner)) {
await expect.poll(node.pollLeftEdge).toBeLessThan(box.x)
} else {
await expect.poll(node.pollLeftEdge).toBeCloseTo(box.x, 0)
}
if (hasNorthEdge(corner)) {
await expect.poll(node.pollTopEdge).toBeLessThan(box.y)
} else {
await expect.poll(node.pollTopEdge).toBeCloseTo(box.y, 0)
}
})
})
})
test.describe('opposite edge anchoring', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner} resize keeps opposite corner fixed`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const pollAnchorX = hasWestEdge(corner)
? node.pollRightEdge
: node.pollLeftEdge
const pollAnchorY = hasNorthEdge(corner)
? node.pollBottomEdge
: node.pollTopEdge
const anchorX = hasWestEdge(corner) ? box.x + box.width : box.x
const anchorY = hasNorthEdge(corner) ? box.y + box.height : box.y
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(pollAnchorX).toBeCloseTo(anchorX, 0)
await expect.poll(pollAnchorY).toBeCloseTo(anchorY, 0)
})
})
})
test.describe('minimum size enforcement', () => {
test('SW resize clamps width, keeping right edge fixed', async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const rightEdge = box.x + box.width
await node.resizeFromCorner('SW', box.width + 100, 0)
await expect.poll(node.pollRightEdge).toBeCloseTo(rightEdge, 0)
await expect.poll(node.pollWidth).toBeGreaterThanOrEqual(MIN_NODE_WIDTH)
})
test('NE resize clamps height at its lower bound', async ({
comfyPage
}) => {
const { node } = await setupResizableNode(comfyPage, 'KSampler')
// Default nodes render at content-minimum height; grow from SE so NE
// has room to shrink back down to the clamp.
await node.resizeFromCorner('SE', 0, 200)
const expandedBox = await node.boundingBox()
if (!expandedBox)
throw new Error('Node bounding box not found after SE grow')
const bottomEdge = expandedBox.y + expandedBox.height
// Overdrag once to hit the clamp, then again to prove further dragging
// does not shrink past the minimum (idempotent clamp).
await node.resizeFromCorner('NE', 0, expandedBox.height + 100)
const clampedHeight = (await node.boundingBox())?.height
if (clampedHeight === undefined)
throw new Error('Node bounding box not found after NE clamp')
expect(clampedHeight).toBeLessThan(expandedBox.height)
await node.resizeFromCorner('NE', 0, 200)
await expect.poll(node.pollHeight).toBeCloseTo(clampedHeight, 0)
await expect.poll(node.pollBottomEdge).toBeCloseTo(bottomEdge, 0)
})
})
}
)

View File

@@ -97,7 +97,7 @@
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "^3.2.5",
"dompurify": "catalog:",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
@@ -193,7 +193,7 @@
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "^11.1.0",
"uuid": "catalog:",
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",

1685
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ catalog:
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
'@iconify/tailwind4': ^1.2.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
@@ -66,10 +66,10 @@ catalog:
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
axios: ^1.15.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.3.1
dompurify: ^3.4.5
dotenv: ^16.4.5
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
@@ -87,12 +87,12 @@ catalog:
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.0.11
happy-dom: ^20.8.9
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.3.1
knip: ^6.14.1
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
@@ -108,13 +108,13 @@ catalog:
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
reka-ui: ^2.5.0
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
@@ -123,13 +123,14 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.0
vite: ^8.0.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vue: ^3.5.13
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
@@ -160,3 +161,13 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
protobufjs: ~7.6.0
flatted: ~3.4.2
defu: ~6.1.7
# Security overrides (see pnpm.overrides in package.json for the actual pins):
# protobufjs ~7.6.0 — CVE-2026-41242 (CVSS 9.8): arbitrary code execution.
# Transitive via firebase, posthog-js. Remove after firebase upgrades protobufjs.
# flatted ~3.4.2 — GHSA-x7hr-w5r2-h6qg: prototype pollution.
# Transitive via eslint flat-cache@4.0.1. Dev-only. Remove after eslint upgrades flat-cache.
# defu ~6.1.7 — GHSA-47f6-5gq3-vx9c: prototype pollution.
# Transitive via reka-ui, c12, unplugin-typegpu. Remove after reka-ui upgrades defu.

View File

@@ -42,7 +42,8 @@ import type { StyleValue } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
import type { ClassValue } from '@comfyorg/tailwind-utils'
type ClassValue = string | Record<string, boolean> | ClassValue[]
const {
src,

View File

@@ -17,7 +17,7 @@ export const dialogContentVariants = cva({
}
})
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
type DialogContentVariants = VariantProps<typeof dialogContentVariants>
export type DialogContentSize = NonNullable<DialogContentVariants['size']>

View File

@@ -1,7 +1,7 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
export interface ResolvedPreviewChainStep {
interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure

View File

@@ -4,7 +4,7 @@ import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
export const previewExposureSchema = z.object({
const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()

View File

@@ -6,7 +6,7 @@ import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { parseNodePropertyArray } from './parseNodePropertyArray'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
export const proxyWidgetQuarantineReasonSchema = z.enum([
const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
@@ -18,7 +18,7 @@ export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),

View File

@@ -6,7 +6,8 @@ import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState
CameraState,
ModelInfo
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
@@ -402,6 +403,9 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_info: ModelInfo = modelInfo ? [modelInfo] : []
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
@@ -409,7 +413,8 @@ useExtensionService().registerExtension({
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: ''
recording: '',
model_info
}
const recordingData = currentLoad3d.getRecordingData()

View File

@@ -314,6 +314,38 @@ describe('GizmoManager', () => {
})
})
describe('getModelInfo', () => {
it('returns the full transform payload for the target object', () => {
manager.init()
const model = new THREE.Object3D()
model.name = 'my-model'
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const info = manager.getModelInfo()
expect(info).not.toBeNull()
expect(info!.uuid).toBe(model.uuid)
expect(info!.name).toBe('my-model')
expect(info!.type).toBe('Object3D')
expect(info!.position).toEqual({ x: 1, y: 2, z: 3 })
expect(info!.rotation.x).toBeCloseTo(0.1)
expect(info!.rotation.order).toBe(model.rotation.order)
expect(info!.quaternion.w).toBeCloseTo(model.quaternion.w)
expect(info!.scale).toEqual({ x: 4, y: 5, z: 6 })
expect(info!.up).toEqual({ x: 0, y: 1, z: 0 })
expect(info!.visible).toBe(true)
expect(info!.matrix).toHaveLength(16)
})
it('returns null when there is no target', () => {
manager.init()
expect(manager.getModelInfo()).toBeNull()
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()

View File

@@ -3,7 +3,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode } from './interfaces'
import type { GizmoMode, ModelTransform } from './interfaces'
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -215,6 +215,48 @@ export class GizmoManager {
}
}
getModelInfo(): ModelTransform | null {
const object = this.targetObject
if (!object) return null
object.updateMatrix()
return {
uuid: object.uuid,
name: object.name,
type: object.type,
position: {
x: object.position.x,
y: object.position.y,
z: object.position.z
},
rotation: {
x: object.rotation.x,
y: object.rotation.y,
z: object.rotation.z,
order: object.rotation.order
},
quaternion: {
x: object.quaternion.x,
y: object.quaternion.y,
z: object.quaternion.z,
w: object.quaternion.w
},
scale: {
x: object.scale.x,
y: object.scale.y,
z: object.scale.z
},
up: {
x: object.up.x,
y: object.up.y,
z: object.up.z
},
visible: object.visible,
matrix: object.matrix.toArray()
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()

View File

@@ -25,6 +25,7 @@ import type {
Load3DOptions,
LoadModelOptions,
MaterialMode,
ModelTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
@@ -914,6 +915,10 @@ class Load3d {
return this.gizmoManager.getTransform()
}
public getModelInfo(): ModelTransform | null {
return this.gizmoManager.getModelInfo()
}
public fitToViewer(): void {
this.modelManager.fitToViewer()
this.forceRender()

View File

@@ -15,7 +15,7 @@ export interface ModelLoadContext {
readonly materialMode: MaterialMode
}
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**

View File

@@ -22,6 +22,21 @@ export interface CameraState {
cameraType: CameraType
}
export interface ModelTransform {
uuid: string
name: string
type: string
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number; order: string }
quaternion: { x: number; y: number; z: number; w: number }
scale: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
visible: boolean
matrix: number[]
}
export type ModelInfo = ModelTransform[]
export interface SceneConfig {
showGrid: boolean
backgroundColor: string

View File

@@ -30,7 +30,7 @@ type FirebaseRuntimeConfig = {
* be tweaked without a frontend release. Field types map 1:1 to a component
* in our internal UI library — see `DynamicSurveyField.vue`.
*/
export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
/**
* A translatable string. Either:

View File

@@ -7,30 +7,32 @@
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
ref="gridEl"
data-testid="image-grid"
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
class="relative grid w-full flex-1 gap-1 rounded-sm p-1 contain-size"
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
>
<button
<Button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
size="unset"
class="ring-ring overflow-hidden rounded-none p-0 hover:ring-1 focus-visible:ring-2"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@pointerdown="trackPointerStart"
@click="handleGridThumbnailClick($event, index)"
@click="openImageInGallery(index)"
>
<img
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
@load="updateAspectRatio($event, index)"
/>
</button>
</Button>
</div>
<!-- Gallery View (Image Wrapper) -->
@@ -167,11 +169,12 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useElementSize, useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -202,12 +205,17 @@ function defaultViewMode(urls: readonly string[]): ViewMode {
return urls.length > 1 ? 'grid' : 'gallery'
}
const { width: gridWidth, height: gridHeight } = useElementSize(
useTemplateRef('gridEl')
)
const currentIndex = ref(0)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageAspectRatio = ref(1)
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
@@ -227,10 +235,8 @@ const imageAltText = computed(() =>
})
)
const gridCols = computed(() => {
const count = imageUrls.length
if (count <= 4) return 2
if (count <= 9) return 3
return 4
const bias = gridWidth.value / gridHeight.value / imageAspectRatio.value
return Math.max(Math.round(Math.sqrt(imageUrls.length * bias)), 1)
})
watch(
@@ -274,6 +280,14 @@ function handleImageLoad(event: Event) {
}
}
function updateAspectRatio(event: Event, index: number) {
if (!(event.target instanceof HTMLImageElement) || index !== 0) return
const { naturalWidth, naturalHeight } = event.target
if (naturalWidth && naturalHeight) {
imageAspectRatio.value = naturalWidth / naturalHeight
}
}
function handleImageError() {
stopDelayedLoader()
showLoader.value = false
@@ -310,20 +324,6 @@ function setCurrentIndex(index: number) {
}
}
const CLICK_THRESHOLD = 3
let pointerStartPos = { x: 0, y: 0 }
function trackPointerStart(event: PointerEvent) {
pointerStartPos = { x: event.clientX, y: event.clientY }
}
function handleGridThumbnailClick(event: MouseEvent, index: number) {
const dx = event.clientX - pointerStartPos.x
const dy = event.clientY - pointerStartPos.y
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
openImageInGallery(index)
}
async function openImageInGallery(index: number) {
setCurrentIndex(index)
viewMode.value = 'gallery'

View File

@@ -212,6 +212,7 @@
v-for="handle in RESIZE_HANDLES"
:key="handle.corner"
role="button"
:data-corner="handle.corner"
:aria-label="t(handle.i18nKey)"
:class="
cn(

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { RESIZE_HANDLES, hasNorthEdge, hasWestEdge } from './resizeHandleConfig'
describe('hasWestEdge', () => {
it.for<[CompassCorners, boolean]>([
['NW', true],
['SW', true],
['NE', false],
['SE', false]
])('corner %s -> %s', ([corner, expected]) => {
expect(hasWestEdge(corner)).toBe(expected)
})
})
describe('hasNorthEdge', () => {
it.for<[CompassCorners, boolean]>([
['NW', true],
['NE', true],
['SW', false],
['SE', false]
])('corner %s -> %s', ([corner, expected]) => {
expect(hasNorthEdge(corner)).toBe(expected)
})
})
describe('RESIZE_HANDLES', () => {
it('defines exactly one entry per CompassCorners member', () => {
const expected = new Set<CompassCorners>(['NE', 'NW', 'SE', 'SW'])
const actual = new Set(RESIZE_HANDLES.map((handle) => handle.corner))
expect(actual).toEqual(expected)
expect(RESIZE_HANDLES).toHaveLength(expected.size)
})
})

View File

@@ -43,3 +43,11 @@ export const RESIZE_HANDLES: ResizeHandle[] = [
svgTransform: 'scale(-1, -1)'
}
] as const
/** True for corners on the left edge of a node (SW, NW) — these move the x-origin when dragged. */
export const hasWestEdge = (corner: CompassCorners): boolean =>
corner === 'SW' || corner === 'NW'
/** True for corners on the top edge of a node (NE, NW) — these move the y-origin when dragged. */
export const hasNorthEdge = (corner: CompassCorners): boolean =>
corner === 'NE' || corner === 'NW'

View File

@@ -8,6 +8,10 @@ import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTran
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import {
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
export interface ResizeCallbackPayload {
size: Size
@@ -135,20 +139,23 @@ export function useNodeResize(
break
}
const isWestCorner = hasWestEdge(activeCorner)
const isNorthCorner = hasNorthEdge(activeCorner)
// Apply snap-to-grid
if (shouldSnap(moveEvent)) {
// Snap position first for N/W corners, then compensate size
if (activeCorner.includes('N') || activeCorner.includes('W')) {
if (isNorthCorner || isWestCorner) {
const originalX = newX
const originalY = newY
const snapped = applySnapToPosition({ x: newX, y: newY })
newX = snapped.x
newY = snapped.y
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newHeight += originalY - newY
}
if (activeCorner.includes('W')) {
if (isWestCorner) {
newWidth += originalX - newX
}
}
@@ -166,7 +173,7 @@ export function useNodeResize(
parseFloat(nodeElement.style.getPropertyValue('min-width') || '0') ||
MIN_NODE_WIDTH
if (newWidth < minWidth) {
if (activeCorner.includes('W')) {
if (isWestCorner) {
newX =
resizeStartPosition.value.x + resizeStartSize.value.width - minWidth
}
@@ -179,7 +186,7 @@ export function useNodeResize(
// a responsive breakpoint.
const minContentHeight = measureMinContentHeight(newWidth)
if (newHeight < minContentHeight) {
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newY =
resizeStartPosition.value.y +
resizeStartSize.value.height -

View File

@@ -812,8 +812,8 @@ export class ComfyApi extends EventTarget {
locale && locale !== 'en' ? `index.${locale}.json` : 'index.json'
try {
const res = await axios.get(this.fileURL(`/templates/${fileName}`))
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
const contentType = String(res.headers['content-type'] ?? '')
return contentType.includes('application/json') ? res.data : []
} catch (error) {
// Fallback to default English version if localized version doesn't exist
if (locale && locale !== 'en') {
@@ -1411,8 +1411,8 @@ export class ComfyApi extends EventTarget {
}
}
)
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : null
const contentType = String(res.headers['content-type'] ?? '')
return contentType.includes('application/json') ? res.data : null
} catch (error) {
console.error('Error loading fuse options:', error)
return null

View File

@@ -4,9 +4,8 @@ import { merge } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { markRaw, ref } from 'vue'
import type { Component, HTMLAttributes } from 'vue'
import type { Component, HTMLAttributes, Ref } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { DialogContentSize } from '@/components/ui/dialog/dialog.variants'
import type { ComponentAttrs } from 'vue-component-type-helpers'
@@ -50,23 +49,19 @@ interface CustomDialogComponentProps {
contentClass?: HTMLAttributes['class']
}
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
export type DialogComponentProps = Record<string, unknown> &
CustomDialogComponentProps
export interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
export interface DialogInstance {
key: string
visible: boolean
title?: string
headerComponent?: H
headerProps?: ComponentAttrs<H>
component: B
contentProps: ComponentAttrs<B>
footerComponent?: F
footerProps?: ComponentAttrs<F>
headerComponent?: Component
headerProps?: Record<string, unknown>
component: Component
contentProps: Record<string, unknown>
footerComponent?: Component
footerProps?: Record<string, unknown>
dialogComponentProps: DialogComponentProps
priority: number
}
@@ -100,7 +95,7 @@ interface UpdateDialogOptions {
}
export const useDialogStore = defineStore('dialog', () => {
const dialogStack = ref<DialogInstance[]>([])
const dialogStack: Ref<DialogInstance[]> = ref([])
/**
* The key of the currently active (top-most) dialog.
@@ -118,7 +113,6 @@ export const useDialogStore = defineStore('dialog', () => {
const insertIndex = dialogStack.value.findIndex(
(d) => d.priority <= dialog.priority
)
dialogStack.value.splice(
insertIndex === -1 ? dialogStack.value.length : insertIndex,
0,
@@ -145,8 +139,8 @@ export const useDialogStore = defineStore('dialog', () => {
if (!targetDialog) return
targetDialog.dialogComponentProps?.onClose?.()
const index = dialogStack.value.indexOf(targetDialog)
dialogStack.value.splice(index, 1)
const index = dialogStack.value.findIndex((d) => d.key === targetDialog.key)
if (index !== -1) dialogStack.value.splice(index, 1)
activeKey.value =
dialogStack.value.length > 0