Compare commits

..

8 Commits

Author SHA1 Message Date
Rizumu Ayaka
b165b3f999 fix: focus keybindings search when opening Manage Shortcuts (FE-845) (#12709)
## Summary

Opening the Keybinding panel from the **Manage Shortcuts** button now
focuses the **Search Keybindings** field instead of the **Search
Settings** field.

## Changes

- **What**: The Settings dialog's "Search Settings" input had an
unconditional `autofocus`, so opening directly to the keybinding panel
always stole focus to the wrong field. Made it conditional
(`:autofocus="activeCategoryKey !== 'keybinding'"`) and added
`autofocus` to the keybinding panel's own search input.

## Review Focus

- `autofocus` maps to the native attribute, which only fires on DOM
insertion — flipping the reactive `:autofocus` while navigating between
categories inside the dialog will not re-steal focus, so there is no
regression for in-dialog navigation.
- Added an E2E test verified in both directions: it fails on the
original code (Search Settings focused) and passes with the fix (Search
Keybindings focused).

Fixes FE-845

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 11:05:01 +00:00
Terry Jia
d7f9754393 feat: add bounding boxes and colors widgets (CORE-292) (#12960)
## Summary
Add two reusable node widgets backed by native (non-string) values:
- Bounding boxes editor (BOUNDING_BOXES): draw, select, resize, and
label regions over an optional background image. Value is a native list
of `{ x, y, width, height, metadata }` pixel boxes; the editor works in
normalized space internally and converts at the value boundary,
rescaling when the node's width/height change.
- Colors palette (COLORS): native `string[]` of hex colors, sharing the
PaletteSwatchRow component (usePaletteSwatchRow composable).

Both reactively hide the width/height widgets while a background image
is connected by writing through the widget value store so the Vue node
re-renders.

Some design refer to KJ's node

BE: https://github.com/Comfy-Org/ComfyUI/pull/14537

Screenshot
<img width="3019" height="1470" alt="image"
src="https://github.com/user-attachments/assets/06795772-97e6-4084-9205-e370f955fb28"
/>

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 11:00:18 +00:00
Terry Jia
48a3ea0e92 feat: add HDR/EXR image viewer for SaveImageAdvanced outputs (#13049)
## Summary
Browsers cannot render EXR/HDR in <img>, so these outputs showed as
broken images. Add a full-screen three.js viewer holding a single WebGL
context created on open and released on close, opened via an 'Open in
HDR Viewer' action on EXR/HDR outputs in ImagePreview. The layout
mirrors the 3D viewer: canvas on the left, grouped controls in a
right-hand sidebar.

The display pipeline (gamut -> exposure -> linear-to-sRGB -> dither ->
clamp, plus clip warnings) is adapted from
[HDRView](https://github.com/wkjarosz/hdrview). Source gamut is
auto-detected from the EXR chromaticities attribute (Rec.709/Rec.2020)
with a manual override.

Inspection tools operate on the EXR float data kept on the CPU by
EXRLoader:
- pixel inspector: hover to read raw RGBA values and coordinates
- statistics: min/max/mean/std-dev plus NaN and Inf counts
- auto-exposure: set exposure so the max value maps to 1
- channel isolation: view R/G/B/A or luminance individually

## Screenshots (if applicable)



https://github.com/user-attachments/assets/22b80718-4b15-41ee-86b5-8fe38a6a82e2
2026-06-24 06:43:30 -04:00
Rizumu Ayaka
a8f8ba7580 fix: clamp sidebar tab labels to two lines with tooltip fallback (#12755)
## Summary

Sidebar tab labels no longer overflow the rail: they wrap up to 2 lines
max, then truncate with an ellipsis, with the full name always
recoverable via the hover tooltip (per design spec from Alex Tov in
FE-698).

## Changes

- **What**:
- Labels in `SidebarIcon.vue` now use `line-clamp-2` + `overflow-wrap:
break-word` + `whitespace-normal`, contained within the rail width minus
`--sidebar-padding` so text keeps breathing room from the rail border
(the base Button's `whitespace-nowrap` previously prevented any
wrapping, causing labels like "Input & Output" to be clipped on both
sides)
- Near-fit built-in labels ("Workflows", "Templates", "Shortcuts" —
wider than the floating-mode line) get soft hyphens (`­`) in their en
label strings, so they break cleanly as "Work-/flows" in floating mode
and render as a single unhyphenated line in connected mode (56px).
`hyphens: auto` can't do this because Chromium skips hyphenation for
capitalized words. Title/tooltip strings are untouched
- Tooltip falls back to the label when an extension registers a sidebar
tab without a tooltip, so clamped text is always recoverable on hover

## Review Focus

- Labels never bleed past the rail or get clipped by the rail's
`overflow-hidden`; long unbroken extension names (e.g.
`WASNodeSuitePreprocessors`) break mid-token across 2 lines + ellipsis,
matching the design mockup
- Soft hyphens live only in `sideToolbar.labels.*`, not in the
title/tooltip keys, so command palette / tooltip text stays clean
- No E2E regression test: the fix is pure CSS layout (line
wrapping/clamping), and per `AGENTS.md` testing guidelines we don't
write tests that depend on non-behavioral styling. The one behavioral
change (tooltip falls back to label) is covered by a unit test in
`SidebarIcon.test.ts`

Fixes
[FE-698](https://linear.app/comfyorg/issue/FE-698/bug-input-and-outputs-text-not-wrapping-in-left-sidebar)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 10:19:23 +00:00
jaeone94
966659b303 fix: bind promoted asset modals (Legacy) to host widgets (#13075)
## Summary

Bind asset-browser modal selections to the widget that actually opened
the modal, so promoted subgraph asset widgets commit through the host
promoted widget instead of the internal source widget closure.

## Changes

- **What**: Makes the asset-browser modal commit path widget-owned:
after a valid selection, `openModal` writes to the widget passed into
the modal and notifies that widget's callback.
- **What**: Captures workflow state after a successful value-changing
asset selection, because the async modal `Use` action can run after the
global mouseup-based change capture has already fired.
- **What**: Preserves existing asset-browser filtering by keeping
`nodeTypeForBrowser` and `inputNameForBrowser` captured in the asset
widget's existing modal options closure.
- **What**: Avoids adding promoted-widget-specific rebinding code to
`litegraphService` and avoids changing LiteGraph core widget option
types.
- **What**: Only runs the source widget's `onValueChange` callback when
the selected widget is the original owner widget created by
`createAssetWidget`.
- **What**: For cloned/transient host widgets, such as promoted subgraph
asset widgets, dispatches `onWidgetChanged` through the widget's owning
node instead of the internal source node.
- **What**: Removes the duplicate PrimitiveNode callback dispatch
because the asset modal commit path now centrally notifies the selected
widget callback.
- **What**: Adds stable asset-browser `data-testid`s and a cloud E2E
regression for legacy promoted subgraph asset selection.
- **What**: Adds unit coverage for both regular asset widget commits and
cloned promoted-host asset modal commits, including workflow change
capture.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This PR supersedes #13074. The earlier direction treated the bug as a
missing callback bridge in the async asset-browser commit path, but the
ownership issue is more specific: promoted subgraph asset widgets reuse
modal options that were created from the deepest concrete source widget.
Those options still need to carry source metadata for filtering the
asset browser, but the modal's `Use` action must commit to the widget
that actually opened the modal.

This matters after the History ADR 0009 subgraph widget changes shipped
through #12197. In the 1.46 subgraph model, promoted widget values live
on the subgraph host node and are not synchronized back into the
internal widget. The internal source widget remains useful as the
provider of asset-browser metadata, because `SubgraphNode` already
resolves nested promotions down to the final concrete widget, but it
should not own the edit commit.

The final patch keeps that boundary narrow:

- no `IWidgetOptions` or LiteGraph core type changes;
- no asset-specific promoted-widget rebinding in `litegraphService`;
- no new promoted-widget traversal logic, because the existing subgraph
promotion path already resolves the final concrete source widget;
- the modal commit path uses the widget passed to `openModal` as the
value owner;
- successful async modal commits explicitly capture workflow state when
the selected value changes.

Please focus review on whether `createAssetWidget` now preserves regular
asset widget behavior while correctly handling cloned/transient host
widgets. The key distinction is that the source `onValueChange` path
only runs for the original owner widget; promoted host wrappers instead
rely on their callback bridge and owning node's `onWidgetChanged` hook.

A review pass also found that this PR makes an existing async modal
weakness more visible: asset-browser selection happens from the modal
button's `click` handler, while the global change tracker also captures
on `mouseup`. Depending on event ordering, the automatic capture can
occur before the selection mutates the widget. This PR now captures
workflow state immediately after a successful value-changing asset
selection so undo/modified tracking follows the same user-visible edit.

Local verification:

- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --reporter=dot`
- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --coverage
--reporter=dot --coverage.reporter=text
--coverage.include=src/platform/assets/utils/createAssetWidget.ts`
- `pnpm exec eslint src/platform/assets/utils/createAssetWidget.ts
src/platform/assets/utils/createAssetWidget.test.ts`
- `pnpm typecheck`
- `pnpm format:check`
- `pnpm build:cloud`

---------

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 10:16:43 +00:00
Alexis Rolland
a95dab2f59 Update allowlist in CLA workflow (#13091)
Update `allowlist` in CLA workflow to add
[actions-user](https://github.com/actions-user)
2026-06-24 10:14:16 +00:00
Alexis Rolland
5f90bacb73 ci: add CLA Assistant workflow (#13058)
Adds the CLA Assistant GitHub Actions workflow at
`.github/workflows/cla.yml`, copied from
https://github.com/Comfy-Org/comfy-cla/blob/main/.github/workflows/cla.yml

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-24 06:53:53 +00:00
ShihChi Huang
84319bea13 refactor: drop redundant isCloud guards around telemetry calls (#13082)
## Summary

Remove redundant `if (isCloud)` guards around `useTelemetry()?.x()`
calls. `useTelemetry()` already returns `null` in OSS builds, so the
optional-chain calls no-op there — the guards only duplicated that
central contract.

## Changes

- **What**: Drop the `isCloud` guard wrapping telemetry calls across 9
files and remove the 5 now-unused `isCloud` imports (pure dedent —
implementations unchanged). Add two-path (cloud + OSS) characterization
tests for the two previously-uncovered composables
(`useTemplateWorkflows`, `useSubscriptionActions`).

## e2e
In local/OSS mode, useTelemetry() returns null, so no telemetry-related
behavior occurs, and the workflow loads as expected. There are no
local/OSS flow regressions for the exact template workflow paths touched
by the branch.

| before | after |
| -- | -- |
| <img width="1280" height="800" alt="before-01-templates-open"
src="https://github.com/user-attachments/assets/1cccc686-4e3a-4cf0-a578-a653a1383e3c"
/> | <img width="1280" height="800" alt="after-01-templates-open"
src="https://github.com/user-attachments/assets/ff834a58-4375-432a-8cc1-6e04ceeece77"
/> |
| <img width="1280" height="800" alt="before-02-template-loaded"
src="https://github.com/user-attachments/assets/1abd301b-d66d-4819-a0f3-9dff1a1e23b5"
/> | <img width="1280" height="800" alt="after-02-template-loaded"
src="https://github.com/user-attachments/assets/9fbb6903-c085-4744-b683-39b01680c654"
/> |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Behavior is intended to be unchanged: OSS still no-ops via null
telemetry. Router page-view tracking may run in more build contexts but
remains guarded by optional chaining.
> 
> **Overview**
> Removes duplicate **`if (isCloud)`** wrappers around
**`useTelemetry()?.…()`** across onboarding, auth, templates,
subscription UI, and routing. Call sites now rely on
**`useTelemetry()`** returning **`null`** in OSS (optional chaining
stays a no-op there), and several unused **`isCloud`** imports are
dropped.
> 
> **`trackPageView`** in the router no longer bails early on cloud-only
or **`window`** checks; it always invokes
**`useTelemetry()?.trackPageView(...)`** on navigation.
> 
> Adds characterization tests for **`useTemplateWorkflows`** and
**`useSubscriptionActions`** that assert telemetry fires when the mock
dispatcher is registered and does not when the mock simulates OSS
(**`useTelemetry()` → null**).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fd6c9a56bd. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: ShihChi Huang <shh@theonlyperson.com>
2026-06-24 06:06:19 +00:00
77 changed files with 4999 additions and 337 deletions

62
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
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

@@ -112,6 +112,10 @@ 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

@@ -223,4 +223,23 @@ 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

@@ -0,0 +1,99 @@
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

@@ -0,0 +1,224 @@
/* 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

@@ -0,0 +1,181 @@
<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,7 +427,6 @@ 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'
@@ -453,16 +452,14 @@ onMounted(() => {
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
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,6 +8,7 @@
v-model="filters['global'].value"
class="max-w-96"
size="lg"
autofocus
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"

View File

@@ -0,0 +1,126 @@
/* 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

@@ -0,0 +1,258 @@
<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

@@ -0,0 +1,70 @@
/* 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

@@ -0,0 +1,48 @@
<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

@@ -0,0 +1,54 @@
/* 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

@@ -0,0 +1,29 @@
<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

@@ -3,17 +3,12 @@ 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 = {
icon: string
selected: boolean
tooltip?: string
class?: string
iconBadge?: string | (() => string | null)
}
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
const i18n = createI18n({
legacy: false,
@@ -84,4 +79,20 @@ 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,9 +40,11 @@
</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 text-center text-2xs"
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"
>{{ st(label, label) }}</span
>
</div>
@@ -83,7 +85,14 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
/**
* 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
})
</script>
<style>

View File

@@ -0,0 +1,237 @@
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

@@ -0,0 +1,246 @@
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

@@ -0,0 +1,249 @@
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

@@ -0,0 +1,614 @@
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

@@ -0,0 +1,114 @@
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

@@ -0,0 +1,114 @@
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

@@ -0,0 +1,443 @@
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

@@ -0,0 +1,89 @@
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

@@ -0,0 +1,38 @@
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,6 +2,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './createBoundingBoxes'
import './customWidgets'
import './dynamicPrompts'
import './editAttention'

View File

@@ -1,5 +1,7 @@
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'
@@ -27,8 +29,7 @@ export type Viewport3dDeps = {
viewHelperManager: ViewHelperManager
}
export class Viewport3d {
renderer: THREE.WebGLRenderer
export class Viewport3d extends WebGLViewport {
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
@@ -52,7 +53,6 @@ export class Viewport3d {
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,6 +63,7 @@ export class Viewport3d {
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
super(deps.renderer)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -73,7 +74,6 @@ export class Viewport3d {
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 {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.observeResize(container, () => this.handleResize())
}
start(): void {
@@ -118,16 +118,6 @@ export class Viewport3d {
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,
@@ -400,29 +390,15 @@ export class Viewport3d {
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.renderer.dispose()
this.renderer.domElement.remove()
this.disposeRenderer()
}
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

@@ -7,7 +7,6 @@ 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
@@ -273,17 +272,7 @@ export class PrimitiveNode extends LGraphNode {
widgetName: 'value',
nodeTypeForBrowser: targetNode.comfyClass ?? '',
inputNameForBrowser: targetInputName,
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)
}
defaultValue
})
}

View File

@@ -1,5 +1,6 @@
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 {
@@ -141,6 +142,8 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IBoundingBoxesWidget
| IColorsWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -343,6 +346,19 @@ 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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,16 @@
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,6 +21,8 @@ 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'
@@ -62,6 +64,8 @@ export type WidgetTypeMap = {
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
boundingboxes: BoundingBoxesWidget
colors: ColorsWidget
[key: string]: BaseWidget
}
@@ -144,6 +148,10 @@ 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

@@ -387,6 +387,35 @@
"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",
"nodePackInfo": "Node Pack Info",
@@ -866,8 +895,8 @@
"nodes": "Nodes",
"models": "Models",
"assets": "Assets",
"workflows": "Workflows",
"templates": "Templates",
"workflows": "Work\u00adflows",
"templates": "Tem\u00adplates",
"console": "Console",
"menu": "Menu",
"imported": "Imported",
@@ -2113,6 +2142,21 @@
"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",
@@ -2946,7 +2990,7 @@
"share": "Share"
},
"shortcuts": {
"shortcuts": "Shortcuts",
"shortcuts": "Short\u00adcuts",
"essentials": "Essential",
"viewControls": "View Controls",
"manageShortcuts": "Manage Shortcuts",

View File

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

View File

@@ -0,0 +1,169 @@
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,8 +12,11 @@ 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
@@ -25,34 +28,26 @@ interface CreateAssetWidgetParams {
inputNameForBrowser?: string
/** Default value for the widget */
defaultValue?: string
/** Callback when widget value changes */
onValueChange?: (
widget: IBaseWidget,
newValue: string,
oldValue: unknown
) => void
}
/**
* 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
interface CreateAssetWidgetOptionsParams {
widgetName: string
nodeTypeForBrowser: string
inputNameForBrowser?: string
}
const displayLabel = defaultValue ?? t('widgets.selectModel')
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 assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
@@ -60,8 +55,8 @@ export function createAssetWidget(
await assetBrowserDialog.show({
nodeType: nodeTypeForBrowser,
inputName: inputNameForBrowser ?? widgetName,
currentValue: widget.value as string,
inputName,
currentValue: String(widget.value ?? ''),
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
@@ -98,15 +93,44 @@ export function createAssetWidget(
const oldValue = widget.value
widget.value = validatedFilename.data
onValueChange?.(widget, validatedFilename.data, oldValue)
widget.callback?.(widget.value)
if (hasOwnerNode(widget)) {
widget.node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
if (oldValue !== validatedFilename.data) {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
}
})
}
const options: IWidgetAssetOptions = {
return {
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,7 +135,6 @@ 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'
@@ -188,9 +187,7 @@ const signUpWithEmail = async (values: SignUpData) => {
}
onMounted(async () => {
if (isCloud) {
telemetry?.trackSignupOpened()
}
telemetry?.trackSignupOpened()
userIsInChina.value = await isInChina()
})

View File

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

View File

@@ -53,11 +53,9 @@ watch(
)
const handleSubscribe = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
}
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
}

View File

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

View File

@@ -38,6 +38,19 @@ 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', {
@@ -48,6 +61,7 @@ Object.defineProperty(window, 'open', {
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
})
describe('handleAddApiCredits', () => {
@@ -73,6 +87,27 @@ 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 } =

View File

@@ -2,7 +2,6 @@ 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'
@@ -30,13 +29,11 @@ export function useSubscriptionActions() {
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
if (isCloud) {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
}
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)

View File

@@ -12,7 +12,7 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
:autofocus="activeCategoryKey !== 'keybinding'"
@search="handleSearch"
/>
</div>

View File

@@ -49,6 +49,17 @@ vi.mock('@/stores/dialogStore', () => ({
}))
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackTemplate } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackTemplate: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value ? { trackTemplate: mockTrackTemplate } : null
}))
// Mock fetch
global.fetch = vi.fn()
@@ -58,6 +69,9 @@ describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
beforeEach(() => {
mockIsCloud.value = true
mockTrackTemplate.mockClear()
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
@@ -285,6 +299,30 @@ describe('useTemplateWorkflows', () => {
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})
it('tracks template telemetry on load in cloud builds', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
mockWorkflowTemplatesStore.isLoaded = true
await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(mockTrackTemplate).toHaveBeenCalledWith({
workflow_name: 'template1',
template_source: 'default'
})
})
it('does not fire template telemetry in OSS builds', async () => {
mockIsCloud.value = false
const { loadWorkflowTemplate } = useTemplateWorkflows()
mockWorkflowTemplatesStore.isLoaded = true
await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(mockTrackTemplate).not.toHaveBeenCalled()
})
it('should handle errors when loading templates', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()

View File

@@ -1,7 +1,6 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
@@ -132,12 +131,10 @@ export function useTemplateWorkflows() {
? t(`templateWorkflows.template.${id}`, id)
: id
if (isCloud) {
useTelemetry()?.trackTemplate({
workflow_name: id,
template_source: sourceModule
})
}
useTelemetry()?.trackTemplate({
workflow_name: id,
template_source: sourceModule
})
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName, {

View File

@@ -15,6 +15,10 @@ vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
vi.mock('@/services/hdrViewerService', () => ({
openHdrViewer: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -36,6 +40,10 @@ const i18n = createI18n({
loading: 'Loading',
viewGrid: 'Grid view',
galleryThumbnail: 'Gallery thumbnail'
},
hdrViewer: {
hdrImage: 'HDR image',
openInHdrViewer: 'Open in HDR Viewer'
}
}
}
@@ -86,6 +94,15 @@ describe('ImagePreview', () => {
expect(container.querySelector('.image-preview')).not.toBeInTheDocument()
})
it('offers the HDR viewer instead of an <img> for exr outputs', () => {
renderImagePreview({
imageUrls: ['/api/view?filename=out.exr&type=output']
})
expect(screen.getByTestId('hdr-open-button')).toBeInTheDocument()
expect(screen.queryByTestId('main-image')).not.toBeInTheDocument()
})
it('displays calculating dimensions text in gallery mode', async () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]

View File

@@ -23,15 +23,23 @@
total: imageUrls.length
})
"
@click="openImageInGallery(index)"
@click="handleGridClick(index)"
>
<img
v-if="!isHdrImageUrl(imageUrls[index])"
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
@load="updateAspectRatio($event, index)"
/>
<div
v-else
class="flex size-full flex-col items-center justify-center gap-1 text-base-foreground"
>
<i class="icon-[lucide--sun] size-6" />
<span class="text-xs">{{ $t('hdrViewer.hdrImage') }}</span>
</div>
</Button>
</div>
@@ -61,12 +69,30 @@
</p>
</div>
<!-- Loading State -->
<div v-if="showLoader && !imageError" class="size-full">
<div
v-if="showLoader && !imageError && !currentImageIsHdr"
class="size-full"
>
<Skeleton class="size-full rounded-sm" />
</div>
<button
v-if="!imageError && currentImageIsHdr"
type="button"
data-testid="hdr-open-button"
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-3 border-0 bg-transparent text-base-foreground"
@click="openHdrViewer(currentImageUrl)"
>
<i class="icon-[lucide--sun] size-12" />
<span class="text-sm">{{ $t('hdrViewer.hdrImage') }}</span>
<span
class="rounded-md bg-base-foreground px-3 py-1.5 text-sm text-base-background"
>
{{ $t('hdrViewer.openInHdrViewer') }}
</span>
</button>
<!-- Main Image -->
<img
v-if="!imageError"
v-if="!imageError && !currentImageIsHdr"
data-testid="main-image"
:src="currentImageUrl"
:alt="imageAltText"
@@ -82,7 +108,7 @@
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages && !imageError"
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
:class="actionButtonClass"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@@ -117,7 +143,7 @@
<!-- Image Dimensions (gallery mode only) -->
<div
v-if="viewMode === 'gallery'"
v-if="viewMode === 'gallery' && !currentImageIsHdr"
class="pt-2 text-center text-xs text-base-foreground"
>
<span
@@ -178,7 +204,9 @@ 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'
import { openHdrViewer } from '@/services/hdrViewerService'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isHdrImageUrl } from '@/utils/hdrFormatUtil'
import { getGridThumbnailUrl } from '@/utils/imageUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -228,6 +256,7 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
)
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
const currentImageIsHdr = computed(() => isHdrImageUrl(currentImageUrl.value))
const gridImageUrls = computed(() => imageUrls.map(getGridThumbnailUrl))
const hasMultipleImages = computed(() => imageUrls.length > 1)
const imageAltText = computed(() =>
@@ -333,6 +362,15 @@ async function openImageInGallery(index: number) {
galleryPanelEl.value?.focus()
}
function handleGridClick(index: number) {
const url = imageUrls[index]
if (isHdrImageUrl(url)) {
openHdrViewer(url)
return
}
void openImageInGallery(index)
}
function getNavigationDotClass(index: number) {
return cn(
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useBoundingBoxesWidget } from './useBoundingBoxesWidget'
const widgetOptions = { serialize: true, canvasOnly: false }
function mockNode() {
return { addWidget: vi.fn(() => ({})) } as unknown as LGraphNode & {
addWidget: ReturnType<typeof vi.fn>
}
}
describe('useBoundingBoxesWidget', () => {
it('adds a boundingboxes widget seeded with the spec default', () => {
const node = mockNode()
const boxes = [
{
x: 0,
y: 0,
width: 1,
height: 1,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
useBoundingBoxesWidget()(node, {
type: 'BOUNDING_BOXES',
name: 'editor_state',
default: boxes
} as InputSpec)
expect(node.addWidget).toHaveBeenCalledWith(
'boundingboxes',
'editor_state',
boxes,
null,
widgetOptions
)
})
it('defaults to an empty box list', () => {
const node = mockNode()
useBoundingBoxesWidget()(node, {
type: 'BOUNDING_BOXES',
name: 'editor_state'
} as InputSpec)
expect(node.addWidget).toHaveBeenCalledWith(
'boundingboxes',
'editor_state',
[],
null,
widgetOptions
)
})
it('deep-clones the spec default so edits never leak into shared state', () => {
const node = mockNode()
const shared = [
{
x: 0,
y: 0,
width: 1,
height: 1,
metadata: { type: 'obj', text: '', desc: '', palette: ['#fff'] }
}
]
useBoundingBoxesWidget()(node, {
type: 'BOUNDING_BOXES',
name: 'editor_state',
default: shared
} as InputSpec)
const passed = node.addWidget.mock.calls[0][2] as typeof shared
expect(passed).not.toBe(shared)
expect(passed[0]).not.toBe(shared[0])
expect(passed[0].metadata.palette).not.toBe(shared[0].metadata.palette)
expect(passed).toEqual(shared)
})
})

View File

@@ -0,0 +1,23 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
BoundingBoxesInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { BoundingBox } from '@/types/boundingBoxes'
export const useBoundingBoxesWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IBaseWidget => {
const spec = inputSpec as BoundingBoxesInputSpec
const defaultValue: BoundingBox[] =
spec.default?.map((box) => ({
...box,
metadata: { ...box.metadata, palette: [...box.metadata.palette] }
})) ?? []
return node.addWidget('boundingboxes', spec.name, defaultValue, null, {
serialize: true,
canvasOnly: false
}) as IBaseWidget
}
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useColorsWidget } from './useColorsWidget'
const widgetOptions = { serialize: true, canvasOnly: false }
function mockNode() {
return { addWidget: vi.fn(() => ({})) } as unknown as LGraphNode & {
addWidget: ReturnType<typeof vi.fn>
}
}
describe('useColorsWidget', () => {
it('adds a colors widget seeded with the spec default', () => {
const node = mockNode()
useColorsWidget()(node, {
type: 'COLORS',
name: 'palette',
default: ['#fff']
} as InputSpec)
expect(node.addWidget).toHaveBeenCalledWith(
'colors',
'palette',
['#fff'],
null,
widgetOptions
)
})
it('defaults to an empty palette', () => {
const node = mockNode()
useColorsWidget()(node, { type: 'COLORS', name: 'palette' } as InputSpec)
expect(node.addWidget).toHaveBeenCalledWith(
'colors',
'palette',
[],
null,
widgetOptions
)
})
it('copies the spec default so widgets never share its reference', () => {
const node = mockNode()
const shared = ['#fff']
useColorsWidget()(node, {
type: 'COLORS',
name: 'palette',
default: shared
} as InputSpec)
expect(node.addWidget.mock.calls[0][2]).not.toBe(shared)
})
})

View File

@@ -0,0 +1,18 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
ColorsInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useColorsWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IBaseWidget => {
const spec = inputSpec as ColorsInputSpec
const defaultValue: string[] = spec.default ? [...spec.default] : []
return node.addWidget('colors', spec.name, defaultValue, null, {
serialize: true,
canvasOnly: false
}) as IBaseWidget
}
}

View File

@@ -171,10 +171,7 @@ function createAssetBrowserWidget(
widgetName: inputSpec.name,
nodeTypeForBrowser: node.comfyClass ?? '',
inputNameForBrowser: inputSpec.name,
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
node.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
}
defaultValue
})
}

View File

@@ -69,6 +69,12 @@ const WidgetPainter = defineAsyncComponent(
const WidgetRange = defineAsyncComponent(
() => import('@/components/range/WidgetRange.vue')
)
const WidgetBoundingBoxes = defineAsyncComponent(
() => import('@/components/boundingBoxes/WidgetBoundingBoxes.vue')
)
const WidgetColors = defineAsyncComponent(
() => import('@/components/palette/WidgetColors.vue')
)
export const FOR_TESTING = {
WidgetButton,
@@ -219,6 +225,22 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
aliases: ['RANGE'],
essential: false
}
],
[
'boundingboxes',
{
component: WidgetBoundingBoxes,
aliases: ['BOUNDING_BOXES'],
essential: false
}
],
[
'colors',
{
component: WidgetColors,
aliases: ['COLORS'],
essential: false
}
]
]
@@ -258,7 +280,8 @@ const EXPANDING_TYPES = [
'curve',
'painter',
'imagecompare',
'range'
'range',
'boundingboxes'
] as const
export function shouldExpand(type: string): boolean {

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import {
GAMUT_NAMES,
detectGamutFromChromaticities,
gamutToSrgbMatrix
} from './colorGamut'
const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1]
describe('gamutToSrgbMatrix', () => {
it('returns identity for sRGB source', () => {
expect(gamutToSrgbMatrix('sRGB')).toEqual(IDENTITY)
})
it('matches the published linear Rec.2020 to sRGB matrix', () => {
const expected = [
1.6605, -0.5876, -0.0728, -0.1246, 1.1329, -0.0083, -0.0182, -0.1006,
1.1187
]
const actual = gamutToSrgbMatrix('Rec.2020')
for (let i = 0; i < 9; i++) {
expect(actual[i]).toBeCloseTo(expected[i], 3)
}
})
it('maps the Rec.2020 white point to equal-energy sRGB (rows sum to ~1)', () => {
const m = gamutToSrgbMatrix('Rec.2020')
for (let row = 0; row < 3; row++) {
const sum = m[row * 3] + m[row * 3 + 1] + m[row * 3 + 2]
expect(sum).toBeCloseTo(1, 3)
}
})
it('exposes the supported gamut names', () => {
expect(GAMUT_NAMES).toContain('sRGB')
expect(GAMUT_NAMES).toContain('Rec.2020')
})
})
describe('detectGamutFromChromaticities', () => {
it('falls back to sRGB when the attribute is absent', () => {
expect(detectGamutFromChromaticities(undefined)).toBe('sRGB')
})
it('detects Rec.2020 primaries from the EXR header', () => {
expect(
detectGamutFromChromaticities({
redX: 0.708,
redY: 0.292,
greenX: 0.17,
greenY: 0.797,
blueX: 0.131,
blueY: 0.046,
whiteX: 0.3127,
whiteY: 0.329
})
).toBe('Rec.2020')
})
it('does not match Rec.2020 when the white point differs', () => {
expect(
detectGamutFromChromaticities({
redX: 0.708,
redY: 0.292,
greenX: 0.17,
greenY: 0.797,
blueX: 0.131,
blueY: 0.046,
whiteX: 0.314,
whiteY: 0.351
})
).toBe('sRGB')
})
it('detects Rec.709/sRGB primaries from the EXR header', () => {
expect(
detectGamutFromChromaticities({
redX: 0.64,
redY: 0.33,
greenX: 0.3,
greenY: 0.6,
blueX: 0.15,
blueY: 0.06,
whiteX: 0.3127,
whiteY: 0.329
})
).toBe('sRGB')
})
})

View File

@@ -0,0 +1,144 @@
interface Chromaticities {
red: readonly [number, number]
green: readonly [number, number]
blue: readonly [number, number]
white: readonly [number, number]
}
const D65: readonly [number, number] = [0.3127, 0.329]
const CHROMATICITIES = {
sRGB: {
red: [0.64, 0.33],
green: [0.3, 0.6],
blue: [0.15, 0.06],
white: D65
},
'Rec.2020': {
red: [0.708, 0.292],
green: [0.17, 0.797],
blue: [0.131, 0.046],
white: D65
}
} satisfies Record<string, Chromaticities>
export type GamutName = keyof typeof CHROMATICITIES
export const GAMUT_NAMES = Object.keys(CHROMATICITIES) as GamutName[]
type Mat3 = readonly number[]
const IDENTITY: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]
function rgbToXyz(c: Chromaticities): Mat3 {
const [rx, ry] = c.red
const [gx, gy] = c.green
const [bx, by] = c.blue
const [wx, wy] = c.white
const xWhite = wx / wy
const zWhite = (1 - wx - wy) / wy
const d = rx * (by - gy) + bx * (gy - ry) + gx * (ry - by)
const srN =
xWhite * (by - gy) -
gx * (by - 1 + by * (xWhite + zWhite)) +
bx * (gy - 1 + gy * (xWhite + zWhite))
const sgN =
xWhite * (ry - by) +
rx * (by - 1 + by * (xWhite + zWhite)) -
bx * (ry - 1 + ry * (xWhite + zWhite))
const sbN =
xWhite * (gy - ry) -
rx * (gy - 1 + gy * (xWhite + zWhite)) +
gx * (ry - 1 + ry * (xWhite + zWhite))
const sr = srN / d
const sg = sgN / d
const sb = sbN / d
return [
sr * rx,
sg * gx,
sb * bx,
sr * ry,
sg * gy,
sb * by,
sr * (1 - rx - ry),
sg * (1 - gx - gy),
sb * (1 - bx - by)
]
}
function multiply(a: Mat3, b: Mat3): Mat3 {
const result = new Array<number>(9).fill(0)
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
let sum = 0
for (let k = 0; k < 3; k++) sum += a[row * 3 + k] * b[k * 3 + col]
result[row * 3 + col] = sum
}
}
return result
}
function invert(m: Mat3): Mat3 {
const [a, b, c, d, e, f, g, h, i] = m
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
if (det === 0) return IDENTITY
const invDet = 1 / det
return [
(e * i - f * h) * invDet,
(c * h - b * i) * invDet,
(b * f - c * e) * invDet,
(f * g - d * i) * invDet,
(a * i - c * g) * invDet,
(c * d - a * f) * invDet,
(d * h - e * g) * invDet,
(b * g - a * h) * invDet,
(a * e - b * d) * invDet
]
}
const SRGB_TO_XYZ = rgbToXyz(CHROMATICITIES.sRGB)
const XYZ_TO_SRGB = invert(SRGB_TO_XYZ)
export function gamutToSrgbMatrix(gamut: GamutName): Mat3 {
if (gamut === 'sRGB') return IDENTITY
return multiply(XYZ_TO_SRGB, rgbToXyz(CHROMATICITIES[gamut]))
}
export interface ChromaticityCoords {
redX: number
redY: number
greenX: number
greenY: number
blueX: number
blueY: number
whiteX: number
whiteY: number
}
function matchesGamut(c: ChromaticityCoords, gamut: GamutName): boolean {
const ref = CHROMATICITIES[gamut]
const tol = 0.01
return (
Math.abs(c.redX - ref.red[0]) < tol &&
Math.abs(c.redY - ref.red[1]) < tol &&
Math.abs(c.greenX - ref.green[0]) < tol &&
Math.abs(c.greenY - ref.green[1]) < tol &&
Math.abs(c.blueX - ref.blue[0]) < tol &&
Math.abs(c.blueY - ref.blue[1]) < tol &&
Math.abs(c.whiteX - ref.white[0]) < tol &&
Math.abs(c.whiteY - ref.white[1]) < tol
)
}
export function detectGamutFromChromaticities(
c: ChromaticityCoords | undefined
): GamutName {
if (!c) return 'sRGB'
return GAMUT_NAMES.find((name) => matchesGamut(c, name)) ?? 'sRGB'
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { computeChannelHistograms, computeImageStats } from './hdrStats'
function reader(values: number[]) {
return (i: number) => values[i]
}
describe('computeImageStats', () => {
it('computes min/max/mean over RGB, skipping alpha', () => {
const data = [0, 0.5, 1, 1, 0.2, 0.4, 0.6, 1]
const stats = computeImageStats(reader(data), data.length, 4)
expect(stats.min).toBe(0)
expect(stats.max).toBe(1)
expect(stats.mean).toBeCloseTo((0 + 0.5 + 1 + 0.2 + 0.4 + 0.6) / 6, 6)
})
it('counts NaN and Inf and excludes them from min/max/mean', () => {
const data = [0.5, NaN, Infinity, -Infinity, 0.25]
const stats = computeImageStats(reader(data), data.length, 3)
expect(stats.nanCount).toBe(1)
expect(stats.infCount).toBe(2)
expect(stats.min).toBe(0.25)
expect(stats.max).toBe(0.5)
expect(stats.mean).toBeCloseTo(0.375, 6)
})
it('counts NaN/Inf in alpha but keeps alpha out of min/max/mean', () => {
const data = [0.1, 0.2, 0.3, NaN, 0.4, 0.5, 0.6, Infinity]
const stats = computeImageStats(reader(data), data.length, 4)
expect(stats.nanCount).toBe(1)
expect(stats.infCount).toBe(1)
expect(stats.min).toBe(0.1)
expect(stats.max).toBe(0.6)
expect(stats.mean).toBeCloseTo((0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6) / 6, 6)
})
it('reports HDR values above one', () => {
const data = [2, 4, 8]
const stats = computeImageStats(reader(data), data.length, 3)
expect(stats.max).toBe(8)
expect(stats.mean).toBeCloseTo(14 / 3, 6)
})
it('returns zeros when there are no finite samples', () => {
const data = [NaN, Infinity]
const stats = computeImageStats(reader(data), data.length, 3)
expect(stats).toEqual({
min: 0,
max: 0,
mean: 0,
stdDev: 0,
nanCount: 1,
infCount: 1
})
})
})
describe('computeChannelHistograms', () => {
it('bins each channel independently', () => {
const data = [0, 0.5, 1, 0.5, 0.5, 0.5]
const hist = computeChannelHistograms(reader(data), data.length, 3, 4)
expect(hist.r[0]).toBe(1)
expect(hist.r[2]).toBe(1)
expect(hist.g[2]).toBe(2)
expect(hist.b[3]).toBe(1)
expect(hist.a).toBeNull()
})
it('builds an alpha histogram for RGBA data', () => {
const data = [0, 0, 0, 1, 1, 1, 1, 0]
const hist = computeChannelHistograms(reader(data), data.length, 4, 4)
expect(hist.a).not.toBeNull()
expect(hist.a![3]).toBe(1)
expect(hist.a![0]).toBe(1)
})
it('clamps HDR values above one into the last bin', () => {
const data = [8, 8, 8]
const hist = computeChannelHistograms(reader(data), data.length, 3, 4)
expect(hist.luminance[3]).toBe(1)
expect(hist.r[3]).toBe(1)
})
it('skips NaN samples per channel', () => {
const data = [NaN, 0.5, 0.5]
const hist = computeChannelHistograms(reader(data), data.length, 3, 4)
expect(hist.r.reduce((a, b) => a + b, 0)).toBe(0)
expect(hist.g.reduce((a, b) => a + b, 0)).toBe(1)
})
})

View File

@@ -0,0 +1,92 @@
export interface ImageStats {
min: number
max: number
mean: number
stdDev: number
nanCount: number
infCount: number
}
export function computeImageStats(
read: (index: number) => number,
length: number,
channels: number
): ImageStats {
let min = Infinity
let max = -Infinity
let sum = 0
let sumSq = 0
let count = 0
let nanCount = 0
let infCount = 0
for (let i = 0; i < length; i++) {
const value = read(i)
if (Number.isNaN(value)) {
nanCount++
continue
}
if (!Number.isFinite(value)) {
infCount++
continue
}
if (channels === 4 && i % channels === 3) continue
if (value < min) min = value
if (value > max) max = value
sum += value
sumSq += value * value
count++
}
if (count === 0) {
return { min: 0, max: 0, mean: 0, stdDev: 0, nanCount, infCount }
}
const mean = sum / count
const variance = Math.max(0, sumSq / count - mean * mean)
return { min, max, mean, stdDev: Math.sqrt(variance), nanCount, infCount }
}
export interface ChannelHistograms {
r: Uint32Array
g: Uint32Array
b: Uint32Array
a: Uint32Array | null
luminance: Uint32Array
}
export function computeChannelHistograms(
read: (index: number) => number,
length: number,
channels: number,
bins = 256
): ChannelHistograms {
const last = bins - 1
const r = new Uint32Array(bins)
const g = new Uint32Array(bins)
const b = new Uint32Array(bins)
const luminance = new Uint32Array(bins)
const a = channels === 4 ? new Uint32Array(bins) : null
const accumulate = (target: Uint32Array, value: number) => {
if (Number.isNaN(value)) return
const bin = Math.floor(Math.max(0, value) * bins)
target[bin > last ? last : bin]++
}
for (let i = 0; i + channels - 1 < length; i += channels) {
const rv = read(i)
const gv = channels >= 3 ? read(i + 1) : rv
const bv = channels >= 3 ? read(i + 2) : rv
accumulate(r, rv)
accumulate(g, gv)
accumulate(b, bv)
if (a) accumulate(a, read(i + 3))
accumulate(
luminance,
channels >= 3 ? 0.2126 * rv + 0.7152 * gv + 0.0722 * bv : rv
)
}
return { r, g, b, a, luminance }
}

View File

@@ -0,0 +1,79 @@
export const HDR_VIEWER_VERTEX_SHADER = `
out vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
export const HDR_VIEWER_FRAGMENT_SHADER = `
in vec2 vUv;
out vec4 frag_color;
uniform sampler2D uImage;
uniform mat3 uGamutToSRGB;
uniform float uGain;
uniform int uChannel;
uniform bool uDither;
uniform bool uClipWarnings;
uniform vec2 uClipRange;
float linearToS(float a) {
float s = sign(a);
a = abs(a);
return s * (a < 0.0031308 ? 12.92 * a : 1.055 * pow(a, 1.0 / 2.4) - 0.055);
}
vec3 linearToSRGB(vec3 c) {
return vec3(linearToS(c.r), linearToS(c.g), linearToS(c.b));
}
float ign(vec2 p) {
return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y));
}
float tent(float r) {
float rp = sqrt(2.0 * r);
float rn = sqrt(2.0 * r + 1.0) - 1.0;
return (r < 0.0) ? 0.5 * rn : 0.5 * rp;
}
void main() {
vec4 texel = texture(uImage, vUv);
vec3 mapped = uGamutToSRGB * texel.rgb;
vec3 selected;
if (uChannel == 1) selected = vec3(mapped.r);
else if (uChannel == 2) selected = vec3(mapped.g);
else if (uChannel == 3) selected = vec3(mapped.b);
else if (uChannel == 4) selected = vec3(texel.a);
else if (uChannel == 5)
selected = vec3(dot(mapped, vec3(0.2126, 0.7152, 0.0722)));
else selected = mapped;
vec3 exposed = selected * uGain;
vec3 display = linearToSRGB(exposed);
if (uDither) {
float r = ign(gl_FragCoord.xy) - 0.5;
display += vec3(tent(r) / 255.0);
}
display = clamp(display, 0.0, 1.0);
if (uClipWarnings) {
float zebra1 =
mod(floor((gl_FragCoord.x + gl_FragCoord.y) / 8.0), 2.0) == 0.0 ? 0.0 : 1.0;
float zebra2 =
mod(floor((gl_FragCoord.x - gl_FragCoord.y) / 8.0), 2.0) == 0.0 ? 0.0 : 1.0;
bvec3 over = greaterThan(exposed, vec3(uClipRange.y));
bvec3 under = lessThan(exposed, vec3(uClipRange.x));
display = mix(display, vec3(zebra1), vec3(over));
display = mix(display, vec3(zebra2), vec3(under));
}
frag_color = vec4(display, 1.0);
}
`

View File

@@ -0,0 +1,52 @@
import type * as THREE from 'three'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WebGLViewport } from './WebGLViewport'
function fakeRenderer() {
const domElement = document.createElement('canvas')
return {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement
} as unknown as THREE.WebGLRenderer
}
describe('WebGLViewport', () => {
afterEach(() => vi.unstubAllGlobals())
it('releases the context via forceContextLoss before disposing', () => {
const renderer = fakeRenderer()
const dispatchSpy = vi.spyOn(renderer.domElement, 'dispatchEvent')
const removeSpy = vi.spyOn(renderer.domElement, 'remove')
new WebGLViewport(renderer).disposeRenderer()
expect(renderer.forceContextLoss).toHaveBeenCalledOnce()
expect(renderer.dispose).toHaveBeenCalledOnce()
expect(removeSpy).toHaveBeenCalledOnce()
expect(dispatchSpy.mock.calls[0][0].type).toBe('webglcontextlost')
})
it('observes the resize target and disconnects on dispose', () => {
const observe = vi.fn()
const disconnect = vi.fn()
vi.stubGlobal(
'ResizeObserver',
class {
observe = observe
disconnect = disconnect
unobserve = vi.fn()
}
)
const target = document.createElement('div')
const onResize = vi.fn()
const viewport = new WebGLViewport(fakeRenderer())
viewport.observeResize(target, onResize)
expect(observe).toHaveBeenCalledWith(target)
viewport.disposeRenderer()
expect(disconnect).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,29 @@
import type * as THREE from 'three'
export class WebGLViewport {
renderer: THREE.WebGLRenderer
private resizeObserver: ResizeObserver | null = null
constructor(renderer: THREE.WebGLRenderer) {
this.renderer = renderer
}
observeResize(target: Element, onResize: () => void): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => onResize())
this.resizeObserver.observe(target)
}
disposeRenderer(): void {
this.resizeObserver?.disconnect()
this.resizeObserver = null
this.renderer.forceContextLoss()
this.renderer.domElement.dispatchEvent(
new Event('webglcontextlost', { bubbles: true, cancelable: true })
)
this.renderer.dispose()
this.renderer.domElement.remove()
}
}

View File

@@ -43,8 +43,6 @@ function getBasePath(): string {
const basePath = getBasePath()
function trackPageView(): void {
if (!isCloud || typeof window === 'undefined') return
useTelemetry()?.trackPageView(document.title, {
path: window.location.href
})

View File

@@ -114,6 +114,35 @@ const zGalleriaInputSpec = zBaseInputOptions.extend({
.optional()
})
const zColorsInputSpec = zBaseInputOptions.extend({
type: z.literal('COLORS'),
name: z.string(),
isOptional: z.boolean().optional(),
default: z.array(z.string()).optional()
})
const zBoundingBoxesInputSpec = zBaseInputOptions.extend({
type: z.literal('BOUNDING_BOXES'),
name: z.string(),
isOptional: z.boolean().optional(),
default: z
.array(
z.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
metadata: z.object({
type: z.enum(['obj', 'text']),
text: z.string(),
desc: z.string(),
palette: z.array(z.string())
})
})
)
.optional()
})
const zTextareaInputSpec = zBaseInputOptions.extend({
type: z.literal('TEXTAREA'),
name: z.string(),
@@ -179,6 +208,8 @@ const zInputSpec = z.union([
zMarkdownInputSpec,
zChartInputSpec,
zGalleriaInputSpec,
zColorsInputSpec,
zBoundingBoxesInputSpec,
zTextareaInputSpec,
zCurveInputSpec,
zRangeInputSpec,
@@ -225,6 +256,8 @@ export type ImageCompareInputSpec = z.infer<typeof zImageCompareInputSpec>
export type BoundingBoxInputSpec = z.infer<typeof zBoundingBoxInputSpec>
export type ChartInputSpec = z.infer<typeof zChartInputSpec>
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type ColorsInputSpec = z.infer<typeof zColorsInputSpec>
export type BoundingBoxesInputSpec = z.infer<typeof zBoundingBoxesInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CurveInputSpec = z.infer<typeof zCurveInputSpec>
export type RangeInputSpec = z.infer<typeof zRangeInputSpec>

View File

@@ -16,6 +16,8 @@ import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composabl
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import { useFloatWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
import { useGalleriaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget'
import { useBoundingBoxesWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxesWidget'
import { useColorsWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorsWidget'
import { useImageCompareWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget'
import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget'
import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
@@ -234,6 +236,8 @@ export const ComfyWidgets = {
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()),
RANGE: transformWidgetConstructorV2ToV1(useRangeWidget()),
BOUNDING_BOXES: transformWidgetConstructorV2ToV1(useBoundingBoxesWidget()),
COLORS: transformWidgetConstructorV2ToV1(useColorsWidget()),
...dynamicWidgets
} as const

View File

@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
const { showDialog } = vi.hoisted(() => ({ showDialog: vi.fn() }))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog })
}))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
import { openHdrViewer } from './hdrViewerService'
describe('openHdrViewer', () => {
it('opens a full-screen dialog with the full-resolution url and filename title', () => {
openHdrViewer('/api/view?filename=out.exr&preview=webp;75&rand=1')
expect(showDialog).toHaveBeenCalledOnce()
const options = showDialog.mock.calls[0][0]
expect(options.key).toBe('hdr-viewer')
expect(options.title).toBe('out.exr')
expect(options.props.imageUrl).toBe('/api/view?filename=out.exr&rand=1')
expect(options.dialogComponentProps.size).toBe('full')
})
})

View File

@@ -0,0 +1,28 @@
import { defineAsyncComponent } from 'vue'
import { t } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'
import {
getImageFilenameFromUrl,
toFullResolutionUrl
} from '@/utils/hdrFormatUtil'
const HdrViewerContent = defineAsyncComponent(
() => import('@/components/hdr/HdrViewerContent.vue')
)
export function openHdrViewer(url: string) {
const fullResUrl = toFullResolutionUrl(url)
useDialogStore().showDialog({
key: 'hdr-viewer',
title: getImageFilenameFromUrl(fullResUrl) ?? t('hdrViewer.title'),
component: HdrViewerContent,
props: { imageUrl: fullResUrl },
dialogComponentProps: {
renderer: 'reka',
size: 'full',
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
maximizable: true
}
})
}

View File

@@ -361,15 +361,13 @@ export const useAuthStore = defineStore('auth', () => {
{ createCustomer: true }
)
if (isCloud) {
useTelemetry()?.trackAuth({
method: 'email',
is_new_user: false,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}
useTelemetry()?.trackAuth({
method: 'email',
is_new_user: false,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
return result
}
@@ -384,15 +382,13 @@ export const useAuthStore = defineStore('auth', () => {
{ createCustomer: true }
)
if (isCloud) {
useTelemetry()?.trackAuth({
method: 'email',
is_new_user: true,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}
useTelemetry()?.trackAuth({
method: 'email',
is_new_user: true,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
return result
}
@@ -405,17 +401,14 @@ export const useAuthStore = defineStore('auth', () => {
{ createCustomer: true }
)
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
useTelemetry()?.trackAuth({
method: 'google',
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}
const additionalUserInfo = getAdditionalUserInfo(result)
useTelemetry()?.trackAuth({
method: 'google',
is_new_user: options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
return result
}
@@ -428,17 +421,14 @@ export const useAuthStore = defineStore('auth', () => {
{ createCustomer: true }
)
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
useTelemetry()?.trackAuth({
method: 'github',
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
}
const additionalUserInfo = getAdditionalUserInfo(result)
useTelemetry()?.trackAuth({
method: 'github',
is_new_user: options?.isNewUser || additionalUserInfo?.isNewUser || false,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
return result
}

View File

@@ -0,0 +1,16 @@
type RegionType = 'obj' | 'text'
export interface BoundingBoxMetadata {
type: RegionType
text: string
desc: string
palette: string[]
}
export interface BoundingBox {
x: number
y: number
width: number
height: number
metadata: BoundingBoxMetadata
}

View File

@@ -9,8 +9,11 @@ import {
hsbToRgb,
hsvaToHex,
isTransparent,
luminance,
parseToRgb,
readableTextColor,
rgbToHex,
textOnColor,
toHexFromFormat
} from '@/utils/colorUtil'
@@ -284,6 +287,28 @@ describe('colorUtil conversions', () => {
expect(toHexFromFormat('abcdef', 'hex')).toBe('#abcdef')
})
})
describe('luminance', () => {
it('computes perceptual luminance', () => {
expect(luminance({ r: 255, g: 0, b: 0 })).toBeCloseTo(76.245, 2)
expect(luminance({ r: 255, g: 255, b: 255 })).toBeCloseTo(255, 2)
})
})
describe('readableTextColor / textOnColor', () => {
it('lightens dark colors', () => {
expect(readableTextColor('#000000')).not.toBe('rgb(0,0,0)')
})
it('leaves already-light colors unchanged', () => {
expect(readableTextColor('#ffffff')).toBe('rgb(255,255,255)')
})
it('flips text color for contrast', () => {
expect(textOnColor('#ffffff')).toBe('#000')
expect(textOnColor('#000000')).toBe('#fff')
})
})
})
describe('colorUtil - adjustColor', () => {
const runAdjustColorTests = (

View File

@@ -95,6 +95,28 @@ export function rgbToHex({ r, g, b }: RGB): string {
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
export function luminance({ r, g, b }: RGB): number {
return 0.299 * r + 0.587 * g + 0.114 * b
}
export function readableTextColor(hex: string): string {
const rgb = hexToRgb(hex)
let { r, g, b } = rgb
const lum = luminance(rgb)
const MIN = 130
if (lum < MIN) {
const t = (MIN - lum) / (255 - lum)
r = Math.round(r + (255 - r) * t)
g = Math.round(g + (255 - g) * t)
b = Math.round(b + (255 - b) * t)
}
return `rgb(${r},${g},${b})`
}
export function textOnColor(hex: string): string {
return luminance(hexToRgb(hex)) > 140 ? '#000' : '#fff'
}
export function hsbToRgb({ h, s, b }: HSB): RGB {
// Normalize
const hh = ((h % 360) + 360) % 360

View File

@@ -1,147 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { FuseSearchable } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
interface SearchItem extends Partial<FuseSearchable> {
name: string
}
interface FilterItem {
options: string[]
}
const makeSearch = <T>(data: T[] = []) =>
new FuseSearch<T>(data, {
fuseOptions: {
keys: ['name'],
includeScore: true,
threshold: 0.6,
shouldSort: false
},
advancedScoring: true
})
describe('FuseSearch', () => {
it('assigns stable ranking tiers for exact, prefix, word, substring, and multi-part matches', () => {
const search = new FuseSearch<string>([], {})
const cases = [
{ query: 'load image', item: 'load image', tier: 0 },
{ query: 'load', item: 'Load Image', tier: 1 },
{ query: 'image', item: 'LoadImage', tier: 2 },
{ query: 'cast', item: 'broadcast', tier: 3 },
{ query: 'batch latent', item: 'LatentBatch', tier: 4 },
{ query: 'ten bat', item: 'LatentBatch', tier: 5 },
{ query: 'vae', item: 'KSampler', tier: 9 }
]
for (const { query, item, tier } of cases) {
expect(search.calcAuxSingle(query, item, 0)[0]).toBe(tier)
}
})
it('penalizes deprecated non-exact matches without penalizing exact matches', () => {
const search = makeSearch<SearchItem>()
expect(
search.calcAuxScores('image', { name: 'Image Deprecated' }, 0)[0]
).toBe(6)
expect(
search.calcAuxScores('deprecated node', { name: 'Deprecated Node' }, 0)[0]
).toBe(0)
})
it('lets searchable entries post-process their auxiliary scores', () => {
const search = makeSearch<SearchItem>()
const entry: SearchItem = {
name: 'Image Loader',
postProcessSearchScores: (scores) => [scores[0] + 2, ...scores.slice(1)]
}
expect(search.calcAuxScores('image', entry, 0)[0]).toBe(3)
})
it('sorts advanced search results by auxiliary ranking instead of Fuse order', () => {
const exact = { name: 'Image' }
const prefix = { name: 'Image Loader' }
const camelCaseWord = { name: 'LoadImage' }
const substring = { name: 'PreimageNode' }
const deprecated = { name: 'Image Deprecated' }
const search = makeSearch([
substring,
deprecated,
camelCaseWord,
prefix,
exact
])
expect(search.search('image')).toEqual([
exact,
prefix,
camelCaseWord,
substring,
deprecated
])
})
it('returns data in original order for an empty query without calling Fuse', () => {
const data = [{ name: 'B' }, { name: 'A' }]
const search = makeSearch(data)
const fuseSearchSpy = vi.spyOn(search.fuse, 'search')
expect(search.search('')).toEqual(data)
expect(fuseSearchSpy).not.toHaveBeenCalled()
})
it('compares auxiliary scores by the first differing value and then length', () => {
const search = new FuseSearch<string>([], {})
expect(
[
[1, 4],
[1, 2],
[0, 99]
].sort(search.compareAux)
).toEqual([
[0, 99],
[1, 2],
[1, 4]
])
expect(
[
[1, 2, 0],
[1, 2]
].sort(search.compareAux)
).toEqual([
[1, 2],
[1, 2, 0]
])
})
})
describe('FuseFilter', () => {
it('matches single values, comma-separated values, and wildcard fallbacks', () => {
const imageItem = { options: ['IMAGE', 'LATENT'] }
const modelItem = { options: ['MODEL'] }
const filter = new FuseFilter<FilterItem, string>([imageItem, modelItem], {
id: 'type',
name: 'Type',
invokeSequence: 't',
getItemOptions: (item) => item.options
})
expect(filter.getAllNodeOptions([imageItem, modelItem, imageItem])).toEqual(
['IMAGE', 'LATENT', 'MODEL']
)
expect(filter.matches(imageItem, 'IMAGE')).toBe(true)
expect(filter.matches(imageItem, 'MODEL')).toBe(false)
expect(filter.matches(imageItem, 'MODEL,IMAGE')).toBe(true)
expect(filter.matches(modelItem, '*', { wildcard: '*' })).toBe(true)
expect(filter.matches(imageItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(true)
expect(filter.matches(modelItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(
false
)
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
getImageFilenameFromUrl,
isHdrImageFilename,
isHdrImageUrl,
toFullResolutionUrl
} from './hdrFormatUtil'
describe('isHdrImageFilename', () => {
it('detects exr and hdr regardless of case', () => {
expect(isHdrImageFilename('render.exr')).toBe(true)
expect(isHdrImageFilename('RENDER.EXR')).toBe(true)
expect(isHdrImageFilename('env.hdr')).toBe(true)
})
it('rejects non-hdr formats and empty input', () => {
expect(isHdrImageFilename('image.png')).toBe(false)
expect(isHdrImageFilename('image.webp')).toBe(false)
expect(isHdrImageFilename(undefined)).toBe(false)
expect(isHdrImageFilename('')).toBe(false)
})
})
describe('getImageFilenameFromUrl', () => {
it('reads the filename query parameter from a view url', () => {
expect(
getImageFilenameFromUrl('/api/view?filename=out.exr&type=output')
).toBe('out.exr')
})
it('falls back to the last path segment', () => {
expect(getImageFilenameFromUrl('https://x.test/files/out.hdr')).toBe(
'out.hdr'
)
})
it('returns undefined for empty input', () => {
expect(getImageFilenameFromUrl('')).toBeUndefined()
})
})
describe('isHdrImageUrl edge cases', () => {
it('returns false for undefined', () => {
expect(isHdrImageUrl(undefined)).toBe(false)
})
})
describe('isHdrImageUrl', () => {
it('detects hdr outputs from view urls', () => {
expect(isHdrImageUrl('/api/view?filename=scene.exr&type=output')).toBe(true)
expect(isHdrImageUrl('/api/view?filename=scene.png&type=output')).toBe(
false
)
})
})
describe('toFullResolutionUrl', () => {
it('strips the preview parameter', () => {
expect(
toFullResolutionUrl('/api/view?filename=out.exr&preview=webp;75&rand=1')
).toBe('/api/view?filename=out.exr&rand=1')
})
it('leaves urls without a preview parameter untouched', () => {
expect(toFullResolutionUrl('/api/view?filename=out.exr')).toBe(
'/api/view?filename=out.exr'
)
})
it('preserves absolute http urls while stripping preview', () => {
expect(
toFullResolutionUrl('https://x.test/api/view?filename=out.exr&preview=w')
).toBe('https://x.test/api/view?filename=out.exr')
})
})

View File

@@ -0,0 +1,38 @@
const HDR_EXTENSIONS = ['.exr', '.hdr'] as const
export function isHdrImageFilename(filename: string | undefined): boolean {
if (!filename) return false
const lower = filename.toLowerCase()
return HDR_EXTENSIONS.some((ext) => lower.endsWith(ext))
}
export function getImageFilenameFromUrl(url: string): string | undefined {
if (!url) return undefined
try {
const parsed = new URL(url, window.location.origin)
return (
parsed.searchParams.get('filename') ??
parsed.pathname.split('/').pop() ??
undefined
)
} catch {
return url.split('/').pop()
}
}
export function isHdrImageUrl(url: string | undefined): boolean {
if (!url) return false
return isHdrImageFilename(getImageFilenameFromUrl(url))
}
export function toFullResolutionUrl(url: string): string {
try {
const parsed = new URL(url, window.location.origin)
parsed.searchParams.delete('preview')
return url.startsWith('http')
? parsed.toString()
: `${parsed.pathname}${parsed.search}`
} catch {
return url
}
}