mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 16:54:51 +00:00
Compare commits
8 Commits
codex/cove
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b165b3f999 | ||
|
|
d7f9754393 | ||
|
|
48a3ea0e92 | ||
|
|
a8f8ba7580 | ||
|
|
966659b303 | ||
|
|
a95dab2f59 | ||
|
|
5f90bacb73 | ||
|
|
84319bea13 |
62
.github/workflows/cla.yml
vendored
Normal file
62
.github/workflows/cla.yml
vendored
Normal 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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
99
browser_tests/tests/cloud-asset-promoted-widget.spec.ts
Normal file
99
browser_tests/tests/cloud-asset-promoted-widget.spec.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
)
|
||||
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal file
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal file
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
v-model="filters['global'].value"
|
||||
class="max-w-96"
|
||||
size="lg"
|
||||
autofocus
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
|
||||
126
src/components/hdr/HdrViewerContent.test.ts
Normal file
126
src/components/hdr/HdrViewerContent.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
258
src/components/hdr/HdrViewerContent.vue
Normal file
258
src/components/hdr/HdrViewerContent.vue
Normal 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>
|
||||
70
src/components/palette/PaletteSwatchRow.test.ts
Normal file
70
src/components/palette/PaletteSwatchRow.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
48
src/components/palette/PaletteSwatchRow.vue
Normal file
48
src/components/palette/PaletteSwatchRow.vue
Normal 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>
|
||||
54
src/components/palette/WidgetColors.test.ts
Normal file
54
src/components/palette/WidgetColors.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
29
src/components/palette/WidgetColors.vue
Normal file
29
src/components/palette/WidgetColors.vue
Normal 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>
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
237
src/composables/boundingBoxes/boundingBoxesUtil.test.ts
Normal file
237
src/composables/boundingBoxes/boundingBoxesUtil.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
246
src/composables/boundingBoxes/boundingBoxesUtil.ts
Normal file
246
src/composables/boundingBoxes/boundingBoxesUtil.ts
Normal 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()
|
||||
}
|
||||
}))
|
||||
}
|
||||
249
src/composables/boundingBoxes/useBoundingBoxes.test.ts
Normal file
249
src/composables/boundingBoxes/useBoundingBoxes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
614
src/composables/boundingBoxes/useBoundingBoxes.ts
Normal file
614
src/composables/boundingBoxes/useBoundingBoxes.ts
Normal 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
|
||||
}
|
||||
}
|
||||
114
src/composables/palette/usePaletteSwatchRow.test.ts
Normal file
114
src/composables/palette/usePaletteSwatchRow.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
114
src/composables/palette/usePaletteSwatchRow.ts
Normal file
114
src/composables/palette/usePaletteSwatchRow.ts
Normal 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
|
||||
}
|
||||
}
|
||||
443
src/composables/useHdrViewer.ts
Normal file
443
src/composables/useHdrViewer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
89
src/extensions/core/createBoundingBoxes.test.ts
Normal file
89
src/extensions/core/createBoundingBoxes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
38
src/extensions/core/createBoundingBoxes.ts
Normal file
38
src/extensions/core/createBoundingBoxes.ts
Normal 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
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './createBoundingBoxes'
|
||||
import './customWidgets'
|
||||
import './dynamicPrompts'
|
||||
import './editAttention'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => ({}))
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
42
src/lib/litegraph/src/widgets/BoundingBoxesWidget.test.ts
Normal file
42
src/lib/litegraph/src/widgets/BoundingBoxesWidget.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
16
src/lib/litegraph/src/widgets/BoundingBoxesWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/BoundingBoxesWidget.ts
Normal 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 {}
|
||||
}
|
||||
36
src/lib/litegraph/src/widgets/ColorsWidget.test.ts
Normal file
36
src/lib/litegraph/src/widgets/ColorsWidget.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
16
src/lib/litegraph/src/widgets/ColorsWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/ColorsWidget.ts
Normal 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 {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="asset-card"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
:aria-labelledby="titleId"
|
||||
|
||||
169
src/platform/assets/utils/createAssetWidget.test.ts
Normal file
169
src/platform/assets/utils/createAssetWidget.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
size="md"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
autofocus
|
||||
:autofocus="activeCategoryKey !== 'keybinding'"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
90
src/renderer/hdr/colorGamut.test.ts
Normal file
90
src/renderer/hdr/colorGamut.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
144
src/renderer/hdr/colorGamut.ts
Normal file
144
src/renderer/hdr/colorGamut.ts
Normal 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'
|
||||
}
|
||||
91
src/renderer/hdr/hdrStats.test.ts
Normal file
91
src/renderer/hdr/hdrStats.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
92
src/renderer/hdr/hdrStats.ts
Normal file
92
src/renderer/hdr/hdrStats.ts
Normal 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 }
|
||||
}
|
||||
79
src/renderer/hdr/hdrViewerShader.ts
Normal file
79
src/renderer/hdr/hdrViewerShader.ts
Normal 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);
|
||||
}
|
||||
`
|
||||
52
src/renderer/three/WebGLViewport.test.ts
Normal file
52
src/renderer/three/WebGLViewport.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
29
src/renderer/three/WebGLViewport.ts
Normal file
29
src/renderer/three/WebGLViewport.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
23
src/services/hdrViewerService.test.ts
Normal file
23
src/services/hdrViewerService.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
28
src/services/hdrViewerService.ts
Normal file
28
src/services/hdrViewerService.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
16
src/types/boundingBoxes.ts
Normal file
16
src/types/boundingBoxes.ts
Normal 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
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
76
src/utils/hdrFormatUtil.test.ts
Normal file
76
src/utils/hdrFormatUtil.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
38
src/utils/hdrFormatUtil.ts
Normal file
38
src/utils/hdrFormatUtil.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user