mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-16 10:27:32 +00:00
Compare commits
6 Commits
ExposeExec
...
range-edit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904acd0f5c | ||
|
|
45ca1beea2 | ||
|
|
aef299caf8 | ||
|
|
188fafa89a | ||
|
|
3984408d05 | ||
|
|
6034be9a6f |
@@ -22,7 +22,9 @@ const extraFileExtensions = ['.vue']
|
||||
|
||||
const commonGlobals = {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
} as const
|
||||
|
||||
const settings = {
|
||||
|
||||
@@ -51,7 +51,6 @@ onMounted(() => {
|
||||
// See: https://vite.dev/guide/build#load-error-handling
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import {
|
||||
createMonotoneInterpolator,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
@@ -95,15 +95,15 @@ export function histogramToPath(histogram: Uint32Array): string {
|
||||
const max = sorted[Math.floor(255 * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const step = 1 / 255
|
||||
let d = 'M0,1'
|
||||
const invMax = 1 / max
|
||||
const parts: string[] = ['M0,1']
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i * step
|
||||
const y = 1 - Math.min(1, histogram[i] / max)
|
||||
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
|
||||
const x = i / 255
|
||||
const y = 1 - Math.min(1, histogram[i] * invMax)
|
||||
parts.push(`L${x},${y}`)
|
||||
}
|
||||
d += ' L1,1 Z'
|
||||
return d
|
||||
parts.push('L1,1 Z')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
|
||||
|
||||
1
src/components/curve/types.ts
Normal file
1
src/components/curve/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
@@ -438,7 +438,6 @@ onMounted(() => {
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
|
||||
@@ -131,11 +131,11 @@ export const Queued: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-1'
|
||||
const queueIndex = 104
|
||||
const priority = 104
|
||||
|
||||
// Current job in pending
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
|
||||
makePendingTask(jobId, priority, Date.now() - 90_000)
|
||||
]
|
||||
// Add some other pending jobs to give context
|
||||
queue.pendingTasks.push(
|
||||
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-parallel'
|
||||
const queueIndex = 210
|
||||
const priority = 210
|
||||
|
||||
// Current job in pending with some ahead
|
||||
queue.pendingTasks = [
|
||||
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
|
||||
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
|
||||
makePendingTask(jobId, priority, Date.now() - 120_000)
|
||||
]
|
||||
|
||||
// Seen 2 minutes ago - set via prompt metadata above
|
||||
@@ -238,9 +238,9 @@ export const Running: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-running-1'
|
||||
const queueIndex = 300
|
||||
const priority = 300
|
||||
queue.runningTasks = [
|
||||
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
|
||||
makeRunningTask(jobId, priority, Date.now() - 65_000)
|
||||
]
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask('hist-r1', 250, 30, true),
|
||||
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-zero-ahead-single'
|
||||
const queueIndex = 510
|
||||
const priority = 510
|
||||
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
|
||||
makePendingTask(jobId, priority, Date.now() - 45_000)
|
||||
]
|
||||
|
||||
queue.historyTasks = [
|
||||
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
|
||||
const exec = useExecutionStore()
|
||||
|
||||
const jobId = 'job-queued-zero-ahead-multi'
|
||||
const queueIndex = 520
|
||||
const priority = 520
|
||||
|
||||
queue.pendingTasks = [
|
||||
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
|
||||
makePendingTask(jobId, priority, Date.now() - 20_000)
|
||||
]
|
||||
|
||||
queue.historyTasks = [
|
||||
@@ -380,8 +380,8 @@ export const Completed: Story = {
|
||||
const queue = useQueueStore()
|
||||
|
||||
const jobId = 'job-completed-1'
|
||||
const queueIndex = 400
|
||||
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
|
||||
const priority = 400
|
||||
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
@@ -401,11 +401,11 @@ export const Failed: Story = {
|
||||
const queue = useQueueStore()
|
||||
|
||||
const jobId = 'job-failed-1'
|
||||
const queueIndex = 410
|
||||
const priority = 410
|
||||
queue.historyTasks = [
|
||||
makeHistoryTask(
|
||||
jobId,
|
||||
queueIndex,
|
||||
priority,
|
||||
12,
|
||||
false,
|
||||
'Example error: invalid inputs for node X'
|
||||
|
||||
@@ -168,14 +168,14 @@ const queuedAtValue = computed(() =>
|
||||
|
||||
const currentQueueIndex = computed<number | null>(() => {
|
||||
const task = taskForJob.value
|
||||
return task ? Number(task.queueIndex) : null
|
||||
return task ? Number(task.job.priority) : null
|
||||
})
|
||||
|
||||
const jobsAhead = computed<number | null>(() => {
|
||||
const idx = currentQueueIndex.value
|
||||
if (idx == null) return null
|
||||
const ahead = queueStore.pendingTasks.filter(
|
||||
(t: TaskItemImpl) => Number(t.queueIndex) < idx
|
||||
(t: TaskItemImpl) => Number(t.job.priority) < idx
|
||||
)
|
||||
return ahead.length
|
||||
})
|
||||
|
||||
125
src/components/range/RangeEditor.test.ts
Normal file
125
src/components/range/RangeEditor.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RangeEditor from './RangeEditor.vue'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
|
||||
return mount(RangeEditor, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('RangeEditor', () => {
|
||||
it('renders with min and max handles', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('highlights selected range in plain mode', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const highlight = wrapper.find('[data-testid="range-highlight"]')
|
||||
expect(highlight.attributes('x')).toBe('0.2')
|
||||
expect(highlight.attributes('width')).toBe('0.6000000000000001')
|
||||
})
|
||||
|
||||
it('dims area outside the range in histogram mode', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0.2, max: 0.8 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
const left = wrapper.find('[data-testid="range-dim-left"]')
|
||||
const right = wrapper.find('[data-testid="range-dim-right"]')
|
||||
expect(left.attributes('width')).toBe('0.2')
|
||||
expect(right.attributes('x')).toBe('0.8')
|
||||
})
|
||||
|
||||
it('hides midpoint handle by default', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 }
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows midpoint handle when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders gradient background when display is gradient', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'gradient',
|
||||
gradientStops: [
|
||||
{ offset: 0, color: [0, 0, 0] as const },
|
||||
{ offset: 1, color: [255, 255, 255] as const }
|
||||
]
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
|
||||
expect(wrapper.find('linearGradient').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders histogram path when display is histogram with data', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders inputs for min and max', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
expect(inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders midpoint input when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
expect(inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('normalizes handle positions with custom value range', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 64, max: 192 },
|
||||
valueMin: 0,
|
||||
valueMax: 255
|
||||
})
|
||||
|
||||
const minHandle = wrapper.find('[data-testid="handle-min"]')
|
||||
const maxHandle = wrapper.find('[data-testid="handle-max"]')
|
||||
// 64/255 ≈ 25.1%, 192/255 ≈ 75.3%
|
||||
expect(minHandle.attributes('style')).toContain('left: 25.0')
|
||||
expect(maxHandle.attributes('style')).toContain('left: 75.')
|
||||
})
|
||||
})
|
||||
283
src/components/range/RangeEditor.vue
Normal file
283
src/components/range/RangeEditor.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="relative select-none"
|
||||
@pointerdown.stop="handleTrackPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<!-- Track -->
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
:class="
|
||||
cn(
|
||||
'block w-full rounded-sm bg-node-component-surface',
|
||||
display === 'histogram' ? 'aspect-[3/2]' : 'h-8'
|
||||
)
|
||||
"
|
||||
>
|
||||
<defs v-if="display === 'gradient'">
|
||||
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop
|
||||
v-for="(stop, i) in computedStops"
|
||||
:key="i"
|
||||
:offset="stop.offset"
|
||||
:stop-color="`rgb(${stop.color[0]},${stop.color[1]},${stop.color[2]})`"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect
|
||||
v-if="display === 'gradient'"
|
||||
data-testid="gradient-bg"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"
|
||||
:fill="`url(#${gradientId})`"
|
||||
/>
|
||||
|
||||
<path
|
||||
v-if="display === 'histogram' && histogramPath"
|
||||
data-testid="histogram-path"
|
||||
:d="histogramPath"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.3"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="display === 'plain'"
|
||||
data-testid="range-highlight"
|
||||
:x="minNorm"
|
||||
y="0"
|
||||
:width="Math.max(0, maxNorm - minNorm)"
|
||||
height="1"
|
||||
fill="white"
|
||||
fill-opacity="0.15"
|
||||
/>
|
||||
<template v-if="display === 'histogram'">
|
||||
<rect
|
||||
v-if="minNorm > 0"
|
||||
data-testid="range-dim-left"
|
||||
x="0"
|
||||
y="0"
|
||||
:width="minNorm"
|
||||
height="1"
|
||||
fill="black"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<rect
|
||||
v-if="maxNorm < 1"
|
||||
data-testid="range-dim-right"
|
||||
:x="maxNorm"
|
||||
y="0"
|
||||
:width="1 - maxNorm"
|
||||
height="1"
|
||||
fill="black"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
|
||||
<!-- Min handle -->
|
||||
<div
|
||||
data-testid="handle-min"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${minNorm * 100}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('min', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="#333"
|
||||
stroke="#aaa"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Midpoint handle -->
|
||||
<div
|
||||
v-if="showMidpoint && modelValue.midpoint !== undefined"
|
||||
data-testid="handle-midpoint"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${midpointPercent}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('midpoint', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="#888"
|
||||
stroke="#ccc"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Max handle -->
|
||||
<div
|
||||
data-testid="handle-max"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${maxNorm * 100}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('max', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="white"
|
||||
stroke="#555"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Value inputs -->
|
||||
<div class="mt-3 flex items-center justify-between" @pointerdown.stop>
|
||||
<ScrubableNumberInput
|
||||
v-model="minValue"
|
||||
:display-value="formatValue(minValue)"
|
||||
:min="valueMin"
|
||||
:max="valueMax"
|
||||
:step="step"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-if="showMidpoint && modelValue.midpoint !== undefined"
|
||||
v-model="midpointValue"
|
||||
:display-value="midpointValue.toFixed(2)"
|
||||
:min="midpointScale === 'gamma' ? 0.01 : 0"
|
||||
:max="midpointScale === 'gamma' ? 9.99 : 1"
|
||||
:step="0.01"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="maxValue"
|
||||
:display-value="formatValue(maxValue)"
|
||||
:min="valueMin"
|
||||
:max="valueMax"
|
||||
:step="step"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, useId, useTemplateRef } from 'vue'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { histogramToPath } from '@/components/curve/curveUtils'
|
||||
import { useRangeEditor } from '@/composables/useRangeEditor'
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
clamp,
|
||||
clamp01,
|
||||
gammaToPosition,
|
||||
normalize,
|
||||
positionToGamma
|
||||
} from './rangeUtils'
|
||||
|
||||
const {
|
||||
display = 'plain',
|
||||
gradientStops,
|
||||
showMidpoint = false,
|
||||
midpointScale = 'linear',
|
||||
histogram,
|
||||
valueMin = 0,
|
||||
valueMax = 1
|
||||
} = defineProps<{
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradientStops?: ColorStop[]
|
||||
showMidpoint?: boolean
|
||||
midpointScale?: 'linear' | 'gamma'
|
||||
histogram?: Uint32Array | null
|
||||
valueMin?: number
|
||||
valueMax?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<RangeValue>({ required: true })
|
||||
|
||||
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
|
||||
const gradientId = useId()
|
||||
|
||||
const { handleTrackPointerDown, startDrag } = useRangeEditor({
|
||||
trackRef,
|
||||
modelValue,
|
||||
valueMin: toRef(() => valueMin),
|
||||
valueMax: toRef(() => valueMax)
|
||||
})
|
||||
|
||||
const isIntegerRange = computed(() => valueMax - valueMin >= 2)
|
||||
const step = computed(() => (isIntegerRange.value ? 1 : 0.01))
|
||||
|
||||
function formatValue(v: number): string {
|
||||
return isIntegerRange.value ? Math.round(v).toString() : v.toFixed(2)
|
||||
}
|
||||
|
||||
/** Normalize an actual value to 0-1 for SVG positioning */
|
||||
const minNorm = computed(() =>
|
||||
normalize(modelValue.value.min, valueMin, valueMax)
|
||||
)
|
||||
const maxNorm = computed(() =>
|
||||
normalize(modelValue.value.max, valueMin, valueMax)
|
||||
)
|
||||
|
||||
const computedStops = computed(
|
||||
() =>
|
||||
gradientStops ?? [
|
||||
{ offset: 0, color: [0, 0, 0] as const },
|
||||
{ offset: 1, color: [255, 255, 255] as const }
|
||||
]
|
||||
)
|
||||
|
||||
const midpointPercent = computed(() => {
|
||||
const { min, max, midpoint } = modelValue.value
|
||||
if (midpoint === undefined) return 0
|
||||
const midAbs = min + midpoint * (max - min)
|
||||
return normalize(midAbs, valueMin, valueMax) * 100
|
||||
})
|
||||
|
||||
const minValue = computed({
|
||||
get: () => modelValue.value.min,
|
||||
set: (min) => {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
min: Math.min(clamp(min, valueMin, valueMax), modelValue.value.max)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const maxValue = computed({
|
||||
get: () => modelValue.value.max,
|
||||
set: (max) => {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
max: Math.max(clamp(max, valueMin, valueMax), modelValue.value.min)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const midpointValue = computed({
|
||||
get: () => {
|
||||
const pos = modelValue.value.midpoint ?? 0.5
|
||||
return midpointScale === 'gamma' ? positionToGamma(pos) : pos
|
||||
},
|
||||
set: (val) => {
|
||||
const position =
|
||||
midpointScale === 'gamma' ? clamp01(gammaToPosition(val)) : clamp01(val)
|
||||
modelValue.value = { ...modelValue.value, midpoint: position }
|
||||
}
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
30
src/components/range/WidgetRange.vue
Normal file
30
src/components/range/WidgetRange.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<RangeEditor
|
||||
v-model="modelValue"
|
||||
:display="widget?.options?.display"
|
||||
:gradient-stops="widget?.options?.gradient_stops"
|
||||
:show-midpoint="widget?.options?.show_midpoint"
|
||||
:midpoint-scale="widget?.options?.midpoint_scale"
|
||||
:histogram="widget?.options?.histogram"
|
||||
:value-min="widget?.options?.value_min"
|
||||
:value-max="widget?.options?.value_max"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
IWidgetRangeOptions,
|
||||
RangeValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import RangeEditor from './RangeEditor.vue'
|
||||
|
||||
defineProps<{
|
||||
widget?: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<RangeValue>({
|
||||
default: () => ({ min: 0, max: 1 })
|
||||
})
|
||||
</script>
|
||||
131
src/components/range/rangeUtils.test.ts
Normal file
131
src/components/range/rangeUtils.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clamp,
|
||||
clamp01,
|
||||
constrainRange,
|
||||
denormalize,
|
||||
formatMidpointLabel,
|
||||
gammaToPosition,
|
||||
normalize,
|
||||
positionToGamma
|
||||
} from './rangeUtils'
|
||||
|
||||
describe('clamp', () => {
|
||||
it('clamps to arbitrary range', () => {
|
||||
expect(clamp(128, 0, 255)).toBe(128)
|
||||
expect(clamp(-10, 0, 255)).toBe(0)
|
||||
expect(clamp(300, 0, 255)).toBe(255)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalize', () => {
|
||||
it('normalizes value to 0-1', () => {
|
||||
expect(normalize(128, 0, 256)).toBe(0.5)
|
||||
expect(normalize(0, 0, 255)).toBe(0)
|
||||
expect(normalize(255, 0, 255)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 when min equals max', () => {
|
||||
expect(normalize(5, 5, 5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('denormalize', () => {
|
||||
it('converts normalized value back to range', () => {
|
||||
expect(denormalize(0.5, 0, 256)).toBe(128)
|
||||
expect(denormalize(0, 0, 255)).toBe(0)
|
||||
expect(denormalize(1, 0, 255)).toBe(255)
|
||||
})
|
||||
|
||||
it('round-trips with normalize', () => {
|
||||
expect(denormalize(normalize(100, 0, 255), 0, 255)).toBeCloseTo(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clamp01', () => {
|
||||
it('returns value within bounds unchanged', () => {
|
||||
expect(clamp01(0.5)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('clamps values below 0', () => {
|
||||
expect(clamp01(-0.5)).toBe(0)
|
||||
})
|
||||
|
||||
it('clamps values above 1', () => {
|
||||
expect(clamp01(1.5)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 for 0', () => {
|
||||
expect(clamp01(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 1 for 1', () => {
|
||||
expect(clamp01(1)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('positionToGamma', () => {
|
||||
it('converts 0.5 to gamma 1.0', () => {
|
||||
expect(positionToGamma(0.5)).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('converts 0.25 to gamma 2.0', () => {
|
||||
expect(positionToGamma(0.25)).toBeCloseTo(2.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gammaToPosition', () => {
|
||||
it('converts gamma 1.0 to position 0.5', () => {
|
||||
expect(gammaToPosition(1.0)).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('converts gamma 2.0 to position 0.25', () => {
|
||||
expect(gammaToPosition(2.0)).toBeCloseTo(0.25)
|
||||
})
|
||||
|
||||
it('round-trips with positionToGamma', () => {
|
||||
for (const pos of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
||||
expect(gammaToPosition(positionToGamma(pos))).toBeCloseTo(pos)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMidpointLabel', () => {
|
||||
it('formats linear scale as decimal', () => {
|
||||
expect(formatMidpointLabel(0.5, 'linear')).toBe('0.50')
|
||||
})
|
||||
|
||||
it('formats gamma scale as gamma value', () => {
|
||||
expect(formatMidpointLabel(0.5, 'gamma')).toBe('1.00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('constrainRange', () => {
|
||||
it('passes through valid range unchanged', () => {
|
||||
const result = constrainRange({ min: 0.2, max: 0.8 })
|
||||
expect(result).toEqual({ min: 0.2, max: 0.8, midpoint: undefined })
|
||||
})
|
||||
|
||||
it('clamps values to [0, 1]', () => {
|
||||
const result = constrainRange({ min: -0.5, max: 1.5 })
|
||||
expect(result.min).toBe(0)
|
||||
expect(result.max).toBe(1)
|
||||
})
|
||||
|
||||
it('enforces min <= max', () => {
|
||||
const result = constrainRange({ min: 0.8, max: 0.3 })
|
||||
expect(result.min).toBe(0.8)
|
||||
expect(result.max).toBe(0.8)
|
||||
})
|
||||
|
||||
it('preserves midpoint when present', () => {
|
||||
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 0.5 })
|
||||
expect(result.midpoint).toBe(0.5)
|
||||
})
|
||||
|
||||
it('clamps midpoint to [0, 1]', () => {
|
||||
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 1.5 })
|
||||
expect(result.midpoint).toBe(1)
|
||||
})
|
||||
})
|
||||
48
src/components/range/rangeUtils.ts
Normal file
48
src/components/range/rangeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
export function clamp01(value: number): number {
|
||||
return clamp(value, 0, 1)
|
||||
}
|
||||
|
||||
export function normalize(value: number, min: number, max: number): number {
|
||||
return max === min ? 0 : (value - min) / (max - min)
|
||||
}
|
||||
|
||||
export function denormalize(
|
||||
normalized: number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
return min + normalized * (max - min)
|
||||
}
|
||||
|
||||
export function positionToGamma(position: number): number {
|
||||
const clamped = Math.max(0.001, Math.min(0.999, position))
|
||||
return -Math.log2(clamped)
|
||||
}
|
||||
|
||||
export function gammaToPosition(gamma: number): number {
|
||||
return Math.pow(2, -gamma)
|
||||
}
|
||||
|
||||
export function formatMidpointLabel(
|
||||
position: number,
|
||||
scale: 'linear' | 'gamma'
|
||||
): string {
|
||||
if (scale === 'gamma') {
|
||||
return positionToGamma(position).toFixed(2)
|
||||
}
|
||||
return position.toFixed(2)
|
||||
}
|
||||
|
||||
export function constrainRange(value: RangeValue): RangeValue {
|
||||
const min = clamp01(value.min)
|
||||
const max = clamp01(Math.max(min, value.max))
|
||||
const midpoint =
|
||||
value.midpoint !== undefined ? clamp01(value.midpoint) : undefined
|
||||
return { min, max, midpoint }
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
|
||||
|
||||
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
|
||||
'PreviewImage',
|
||||
'SaveImage',
|
||||
'GLSLShader'
|
||||
])
|
||||
|
||||
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
|
||||
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type TestTask = {
|
||||
jobId: string
|
||||
queueIndex: number
|
||||
job: { priority: number }
|
||||
mockState: JobState
|
||||
executionTime?: number
|
||||
executionEndTimestamp?: number
|
||||
@@ -174,7 +174,7 @@ const createTask = (
|
||||
overrides: Partial<TestTask> & { mockState?: JobState } = {}
|
||||
): TestTask => ({
|
||||
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
|
||||
queueIndex: overrides.queueIndex ?? 0,
|
||||
job: overrides.job ?? { priority: 0 },
|
||||
mockState: overrides.mockState ?? 'pending',
|
||||
executionTime: overrides.executionTime,
|
||||
executionEndTimestamp: overrides.executionEndTimestamp,
|
||||
@@ -258,7 +258,7 @@ describe('useJobList', () => {
|
||||
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
|
||||
createTask({ jobId: '1', job: { priority: 1 }, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
@@ -287,7 +287,7 @@ describe('useJobList', () => {
|
||||
vi.useFakeTimers()
|
||||
const taskId = '2'
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
|
||||
createTask({ jobId: taskId, job: { priority: 1 }, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
@@ -300,7 +300,7 @@ describe('useJobList', () => {
|
||||
|
||||
vi.mocked(buildJobDisplay).mockClear()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
|
||||
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
|
||||
]
|
||||
await flush()
|
||||
jobItems.value
|
||||
@@ -314,7 +314,7 @@ describe('useJobList', () => {
|
||||
it('cleans up timeouts on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
|
||||
createTask({ jobId: '3', job: { priority: 1 }, mockState: 'pending' })
|
||||
]
|
||||
|
||||
initComposable()
|
||||
@@ -331,7 +331,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'p',
|
||||
queueIndex: 1,
|
||||
job: { priority: 1 },
|
||||
mockState: 'pending',
|
||||
createTime: 3000
|
||||
})
|
||||
@@ -339,7 +339,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'r',
|
||||
queueIndex: 5,
|
||||
job: { priority: 5 },
|
||||
mockState: 'running',
|
||||
createTime: 2000
|
||||
})
|
||||
@@ -347,7 +347,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'h',
|
||||
queueIndex: 3,
|
||||
job: { priority: 3 },
|
||||
mockState: 'completed',
|
||||
createTime: 1000,
|
||||
executionEndTimestamp: 5000
|
||||
@@ -366,9 +366,9 @@ describe('useJobList', () => {
|
||||
|
||||
it('filters by job tab and resets failed tab when failures disappear', async () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
|
||||
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
|
||||
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
|
||||
createTask({ jobId: 'p', job: { priority: 1 }, mockState: 'pending' })
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
@@ -384,7 +384,7 @@ describe('useJobList', () => {
|
||||
expect(instance.hasFailedJobs.value).toBe(true)
|
||||
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
|
||||
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' })
|
||||
]
|
||||
await flush()
|
||||
|
||||
@@ -396,13 +396,13 @@ describe('useJobList', () => {
|
||||
queueStoreMock.pendingTasks = [
|
||||
createTask({
|
||||
jobId: 'wf-1',
|
||||
queueIndex: 2,
|
||||
job: { priority: 2 },
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-1'
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'wf-2',
|
||||
queueIndex: 1,
|
||||
job: { priority: 1 },
|
||||
mockState: 'pending',
|
||||
workflowId: 'workflow-2'
|
||||
})
|
||||
@@ -426,14 +426,14 @@ describe('useJobList', () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'alpha',
|
||||
queueIndex: 2,
|
||||
job: { priority: 2 },
|
||||
mockState: 'completed',
|
||||
createTime: 2000,
|
||||
executionEndTimestamp: 2000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'beta',
|
||||
queueIndex: 1,
|
||||
job: { priority: 1 },
|
||||
mockState: 'failed',
|
||||
createTime: 1000,
|
||||
executionEndTimestamp: 1000
|
||||
@@ -471,13 +471,13 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'active',
|
||||
queueIndex: 3,
|
||||
job: { priority: 3 },
|
||||
mockState: 'running',
|
||||
executionTime: 7_200_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'other',
|
||||
queueIndex: 2,
|
||||
job: { priority: 2 },
|
||||
mockState: 'running',
|
||||
executionTime: 3_600_000
|
||||
})
|
||||
@@ -507,7 +507,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'live-preview',
|
||||
queueIndex: 1,
|
||||
job: { priority: 1 },
|
||||
mockState: 'running'
|
||||
})
|
||||
]
|
||||
@@ -526,7 +526,7 @@ describe('useJobList', () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
jobId: 'disabled-preview',
|
||||
queueIndex: 1,
|
||||
job: { priority: 1 },
|
||||
mockState: 'running'
|
||||
})
|
||||
]
|
||||
@@ -567,28 +567,28 @@ describe('useJobList', () => {
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'today-small',
|
||||
queueIndex: 4,
|
||||
job: { priority: 4 },
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 2_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'today-large',
|
||||
queueIndex: 3,
|
||||
job: { priority: 3 },
|
||||
mockState: 'completed',
|
||||
executionEndTimestamp: Date.now(),
|
||||
executionTime: 5_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'yesterday',
|
||||
queueIndex: 2,
|
||||
job: { priority: 2 },
|
||||
mockState: 'failed',
|
||||
executionEndTimestamp: Date.now() - 86_400_000,
|
||||
executionTime: 1_000
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'undated',
|
||||
queueIndex: 1,
|
||||
job: { priority: 1 },
|
||||
mockState: 'pending'
|
||||
})
|
||||
]
|
||||
|
||||
@@ -4,64 +4,32 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const { copy, copied } = useClipboard()
|
||||
const { copy, copied } = useClipboard({ legacy: true })
|
||||
const toast = useToast()
|
||||
const showSuccessToast = () => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
const showErrorToast = () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string) {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'absolute'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.setAttribute('aria-hidden', 'true')
|
||||
textarea.setAttribute('tabindex', '-1')
|
||||
textarea.style.width = '1px'
|
||||
textarea.style.height = '1px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
// using legacy document.execCommand for fallback for old and linux browsers
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
showSuccessToast()
|
||||
} else {
|
||||
showErrorToast()
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorToast()
|
||||
} finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await copy(text)
|
||||
if (copied.value) {
|
||||
showSuccessToast()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('clipboard.successMessage'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
// If VueUse copy failed, try fallback
|
||||
fallbackCopy(text)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
// VueUse copy failed, try fallback
|
||||
fallbackCopy(text)
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('clipboard.errorMessage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
|
||||
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { CurvePoint } from '@/components/curve/types'
|
||||
|
||||
interface UseCurveEditorOptions {
|
||||
svgRef: Ref<SVGSVGElement | null>
|
||||
@@ -21,11 +21,12 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
|
||||
const xMin = points[0][0]
|
||||
const xMax = points[points.length - 1][0]
|
||||
const segments = 128
|
||||
const range = xMax - xMin
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const x = xMin + (xMax - xMin) * (i / segments)
|
||||
const x = xMin + range * (i / segments)
|
||||
const y = 1 - interpolate(x)
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x},${y}`)
|
||||
}
|
||||
return parts.join('')
|
||||
})
|
||||
|
||||
114
src/composables/useRangeEditor.ts
Normal file
114
src/composables/useRangeEditor.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { clamp, denormalize, normalize } from '@/components/range/rangeUtils'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type HandleType = 'min' | 'max' | 'midpoint'
|
||||
|
||||
interface UseRangeEditorOptions {
|
||||
trackRef: Ref<HTMLElement | null>
|
||||
modelValue: Ref<RangeValue>
|
||||
valueMin: Ref<number>
|
||||
valueMax: Ref<number>
|
||||
}
|
||||
|
||||
export function useRangeEditor({
|
||||
trackRef,
|
||||
modelValue,
|
||||
valueMin,
|
||||
valueMax
|
||||
}: UseRangeEditorOptions) {
|
||||
const activeHandle = ref<HandleType | null>(null)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
|
||||
/** Convert pointer event to actual value in [valueMin, valueMax] */
|
||||
function pointerToValue(e: PointerEvent): number {
|
||||
const el = trackRef.value
|
||||
if (!el) return valueMin.value
|
||||
const rect = el.getBoundingClientRect()
|
||||
const normalized = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width)
|
||||
)
|
||||
return denormalize(normalized, valueMin.value, valueMax.value)
|
||||
}
|
||||
|
||||
function nearestHandle(value: number): HandleType {
|
||||
const { min, max, midpoint } = modelValue.value
|
||||
const dMin = Math.abs(value - min)
|
||||
const dMax = Math.abs(value - max)
|
||||
let best: HandleType = dMin <= dMax ? 'min' : 'max'
|
||||
const bestDist = Math.min(dMin, dMax)
|
||||
if (midpoint !== undefined) {
|
||||
const midAbs = min + midpoint * (max - min)
|
||||
if (Math.abs(value - midAbs) < bestDist) {
|
||||
best = 'midpoint'
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function updateValue(handle: HandleType, value: number) {
|
||||
const current = modelValue.value
|
||||
const clamped = clamp(value, valueMin.value, valueMax.value)
|
||||
|
||||
if (handle === 'min') {
|
||||
modelValue.value = { ...current, min: Math.min(clamped, current.max) }
|
||||
} else if (handle === 'max') {
|
||||
modelValue.value = { ...current, max: Math.max(clamped, current.min) }
|
||||
} else {
|
||||
const range = current.max - current.min
|
||||
const midNorm =
|
||||
range > 0 ? normalize(clamped, current.min, current.max) : 0
|
||||
const midpoint = Math.max(0, Math.min(1, midNorm))
|
||||
modelValue.value = { ...current, midpoint }
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
startDrag(nearestHandle(pointerToValue(e)), e)
|
||||
}
|
||||
|
||||
function startDrag(handle: HandleType, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
cleanupDrag?.()
|
||||
|
||||
activeHandle.value = handle
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
|
||||
el.setPointerCapture(e.pointerId)
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!activeHandle.value) return
|
||||
updateValue(activeHandle.value, pointerToValue(ev))
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (!activeHandle.value) return
|
||||
activeHandle.value = null
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', endDrag)
|
||||
el.removeEventListener('lostpointercapture', endDrag)
|
||||
cleanupDrag = null
|
||||
}
|
||||
|
||||
cleanupDrag = endDrag
|
||||
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', endDrag)
|
||||
el.addEventListener('lostpointercapture', endDrag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDrag?.()
|
||||
})
|
||||
|
||||
return {
|
||||
activeHandle,
|
||||
handleTrackPointerDown,
|
||||
startDrag
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
|
||||
// API Nodes
|
||||
'RecraftRemoveBackgroundNode',
|
||||
'RecraftVectorizeImageNode',
|
||||
'KlingOmniProEditVideoNode'
|
||||
'KlingOmniProEditVideoNode',
|
||||
|
||||
// Shader Nodes
|
||||
'GLSLShader'
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -201,7 +201,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true
|
||||
suppressPromotedOutline: true,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
|
||||
projected.y = originalY
|
||||
|
||||
@@ -184,6 +184,17 @@ describe('getPromotableWidgets', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
|
||||
const node = new LGraphNode('GLSLShader')
|
||||
node.type = 'GLSLShader'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for non-image nodes', () => {
|
||||
const node = new LGraphNode('TextNode')
|
||||
node.addOutput('TEXT', 'STRING')
|
||||
@@ -232,4 +243,25 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
glslNode.type = 'GLSLShader'
|
||||
subgraph.add(glslNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(glslNode.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
).toBe(true)
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -227,6 +227,29 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
|
||||
if (hasPreviewWidget()) continue
|
||||
|
||||
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
|
||||
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
|
||||
// includes this node and onDrawBackground can call updatePreviews on it
|
||||
// once execution outputs arrive.
|
||||
if (supportsVirtualCanvasImagePreview(node)) {
|
||||
if (
|
||||
!store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
) {
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Also schedule a deferred check: core $$ widgets are created lazily by
|
||||
// updatePreviews when node outputs are first loaded.
|
||||
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
|
||||
|
||||
@@ -20,6 +20,7 @@ if (!isCloud) {
|
||||
}
|
||||
import './noteNode'
|
||||
import './painter'
|
||||
import './rangeHistogram'
|
||||
import './previewAny'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
|
||||
37
src/extensions/core/rangeHistogram.ts
Normal file
37
src/extensions/core/rangeHistogram.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
const HISTOGRAM_KEY_PREFIX = 'range_histogram_'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.RangeHistogram',
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
const hasRangeWidget = node.widgets?.some((w) => w.type === 'range')
|
||||
if (!hasRangeWidget) return
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
node.onExecuted = function (output: Record<string, unknown>) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget.type !== 'range') continue
|
||||
|
||||
const data = output[HISTOGRAM_KEY_PREFIX + widget.name]
|
||||
if (!Array.isArray(data)) continue
|
||||
|
||||
if (widget.options) {
|
||||
;(widget.options as Record<string, unknown>).histogram =
|
||||
new Uint32Array(data as number[])
|
||||
// Force reactive update: widget.options is not reactive, but
|
||||
// widget.value is (via BaseWidget._state). Re-assigning value
|
||||
// triggers processedWidgets recomputation in NodeWidgets.vue,
|
||||
// which then reads the updated options from the store proxy.
|
||||
widget.value = { ...(widget.value as RangeValue) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurvePoint } from '@/components/curve/types'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -138,6 +139,7 @@ export type IWidget =
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -330,8 +332,6 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
|
||||
value: Bounds
|
||||
}
|
||||
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
|
||||
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
|
||||
type: 'curve'
|
||||
value: CurvePoint[]
|
||||
@@ -342,6 +342,31 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface RangeValue {
|
||||
min: number
|
||||
max: number
|
||||
midpoint?: number
|
||||
}
|
||||
|
||||
export interface IWidgetRangeOptions extends IWidgetOptions {
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradient_stops?: ColorStop[]
|
||||
show_midpoint?: boolean
|
||||
midpoint_scale?: 'linear' | 'gamma'
|
||||
value_min?: number
|
||||
value_max?: number
|
||||
histogram?: Uint32Array | null
|
||||
}
|
||||
|
||||
export interface IRangeWidget extends IBaseWidget<
|
||||
RangeValue,
|
||||
'range',
|
||||
IWidgetRangeOptions
|
||||
> {
|
||||
type: 'range'
|
||||
value: RangeValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface DrawWidgetOptions {
|
||||
showText?: boolean
|
||||
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
|
||||
suppressPromotedOutline?: boolean
|
||||
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
|
||||
previewImages?: HTMLImageElement[]
|
||||
}
|
||||
|
||||
interface DrawTruncatingTextOptions extends DrawWidgetOptions {
|
||||
@@ -140,6 +142,9 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
|
||||
this._state = useWidgetValueStore().registerWidget(graphId, {
|
||||
...this._state,
|
||||
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
|
||||
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
|
||||
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
|
||||
value: this.value,
|
||||
nodeId
|
||||
})
|
||||
|
||||
16
src/lib/litegraph/src/widgets/RangeWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/RangeWidget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IRangeWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class RangeWidget
|
||||
extends BaseWidget<IRangeWidget>
|
||||
implements IRangeWidget
|
||||
{
|
||||
override type = 'range' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Range')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
import { ImageCompareWidget } from './ImageCompareWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { RangeWidget } from './RangeWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
import { KnobWidget } from './KnobWidget'
|
||||
import { LegacyWidget } from './LegacyWidget'
|
||||
@@ -60,6 +61,7 @@ export type WidgetTypeMap = {
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -140,6 +142,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(CurveWidget, narrowedWidget, node)
|
||||
case 'painter':
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
case 'range':
|
||||
return toClass(RangeWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
@@ -282,7 +282,6 @@ const getCheckoutTier = (
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -189,25 +189,36 @@ export function useNodeResize(
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (!isResizing.value) return
|
||||
isResizing.value = false
|
||||
layoutStore.isResizingVueNodes.value = false
|
||||
resizeStartPointer.value = null
|
||||
resizeStartSize.value = null
|
||||
resizeStartPosition.value = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync()
|
||||
|
||||
stopMoveListen()
|
||||
stopUpListen()
|
||||
stopCancelListen()
|
||||
}
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false
|
||||
layoutStore.isResizingVueNodes.value = false
|
||||
resizeStartPointer.value = null
|
||||
resizeStartSize.value = null
|
||||
resizeStartPosition.value = null
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync()
|
||||
|
||||
target.releasePointerCapture(upEvent.pointerId)
|
||||
stopMoveListen()
|
||||
stopUpListen()
|
||||
try {
|
||||
target.releasePointerCapture(upEvent.pointerId)
|
||||
} catch {
|
||||
// Pointer capture may already be released
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
const stopMoveListen = useEventListener('pointermove', handlePointerMove)
|
||||
const stopUpListen = useEventListener('pointerup', handlePointerUp)
|
||||
const stopCancelListen = useEventListener('pointercancel', cleanup)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -92,9 +92,15 @@ watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
|
||||
}
|
||||
})
|
||||
|
||||
function isSingleImage(
|
||||
value: ImageCompareValue | string | undefined
|
||||
): value is string {
|
||||
return typeof value === 'string'
|
||||
}
|
||||
|
||||
const parsedValue = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? null : value
|
||||
return isSingleImage(value) ? null : value
|
||||
})
|
||||
|
||||
const beforeBatchCount = computed(
|
||||
@@ -126,26 +132,26 @@ watch(
|
||||
|
||||
const beforeImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
if (typeof value === 'string') return value
|
||||
if (isSingleImage(value)) return value
|
||||
return value?.beforeImages?.[beforeIndex.value] ?? ''
|
||||
})
|
||||
|
||||
const afterImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
if (typeof value === 'string') return ''
|
||||
if (isSingleImage(value)) return ''
|
||||
return value?.afterImages?.[afterIndex.value] ?? ''
|
||||
})
|
||||
|
||||
const beforeAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'object' && value?.beforeAlt
|
||||
return !isSingleImage(value) && value?.beforeAlt
|
||||
? value.beforeAlt
|
||||
: 'Before image'
|
||||
})
|
||||
|
||||
const afterAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'object' && value?.afterAlt
|
||||
return !isSingleImage(value) && value?.afterAlt
|
||||
? value.afterAlt
|
||||
: 'After image'
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -77,7 +78,9 @@ const renderPreview = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
shiftY: number,
|
||||
computedHeight: number | undefined
|
||||
computedHeight: number | undefined,
|
||||
imgs: HTMLImageElement[],
|
||||
width: number
|
||||
) => {
|
||||
if (!node.size) return
|
||||
|
||||
@@ -99,7 +102,6 @@ const renderPreview = (
|
||||
node.pointerDown = null
|
||||
}
|
||||
|
||||
const imgs = node.imgs ?? []
|
||||
if (imgs.length === 0) return
|
||||
|
||||
let { imageIndex } = node
|
||||
@@ -112,7 +114,7 @@ const renderPreview = (
|
||||
const settingStore = useSettingStore()
|
||||
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
|
||||
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
|
||||
const dw = node.size[0]
|
||||
const dw = width
|
||||
const dh = computedHeight ? computedHeight - IMAGE_TEXT_SIZE_TEXT_HEIGHT : 0
|
||||
|
||||
if (imageIndex == null) {
|
||||
@@ -358,8 +360,29 @@ class ImagePreviewWidget extends BaseWidget {
|
||||
this.serialize = false
|
||||
}
|
||||
|
||||
override drawWidget(ctx: CanvasRenderingContext2D): void {
|
||||
renderPreview(ctx, this.node, this.y, this.computedHeight)
|
||||
override drawWidget(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: DrawWidgetOptions
|
||||
): void {
|
||||
const imgs = options.previewImages ?? this.node.imgs ?? []
|
||||
renderPreview(
|
||||
ctx,
|
||||
this.node,
|
||||
this.y,
|
||||
this.computedHeight,
|
||||
imgs,
|
||||
options.width
|
||||
)
|
||||
}
|
||||
|
||||
override createCopyForNode(node: LGraphNode): this {
|
||||
const copy = new ImagePreviewWidget(
|
||||
node,
|
||||
this.name,
|
||||
this.options as IWidgetOptions<string | object>
|
||||
) as this
|
||||
copy.value = this.value
|
||||
return copy
|
||||
}
|
||||
|
||||
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IRangeWidget,
|
||||
IWidgetRangeOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { RangeInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useRangeWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec): IRangeWidget => {
|
||||
const spec = inputSpec as RangeInputSpec
|
||||
const defaultValue = spec.default ?? { min: 0.0, max: 1.0 }
|
||||
|
||||
const options: IWidgetRangeOptions = {
|
||||
display: spec.display,
|
||||
gradient_stops: spec.gradient_stops,
|
||||
show_midpoint: spec.show_midpoint,
|
||||
midpoint_scale: spec.midpoint_scale,
|
||||
value_min: spec.value_min,
|
||||
value_max: spec.value_max
|
||||
}
|
||||
|
||||
const rawWidget = node.addWidget(
|
||||
'range',
|
||||
spec.name,
|
||||
{ ...defaultValue },
|
||||
() => {},
|
||||
options
|
||||
)
|
||||
|
||||
if (rawWidget.type !== 'range') {
|
||||
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
|
||||
}
|
||||
|
||||
return rawWidget as IRangeWidget
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ const WidgetCurve = defineAsyncComponent(
|
||||
const WidgetPainter = defineAsyncComponent(
|
||||
() => import('@/components/painter/WidgetPainter.vue')
|
||||
)
|
||||
const WidgetRange = defineAsyncComponent(
|
||||
() => import('@/components/range/WidgetRange.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -197,6 +200,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['PAINTER'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'range',
|
||||
{
|
||||
component: WidgetRange,
|
||||
aliases: ['RANGE'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -233,7 +244,8 @@ const EXPANDING_TYPES = [
|
||||
'markdown',
|
||||
'load3D',
|
||||
'curve',
|
||||
'painter'
|
||||
'painter',
|
||||
'range'
|
||||
] as const
|
||||
|
||||
export function shouldExpand(type: string): boolean {
|
||||
|
||||
@@ -135,6 +135,30 @@ const zCurveInputSpec = zBaseInputOptions.extend({
|
||||
default: z.array(zCurvePoint).optional()
|
||||
})
|
||||
|
||||
const zRangeValue = z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
midpoint: z.number().optional()
|
||||
})
|
||||
|
||||
const zColorStop = z.object({
|
||||
offset: z.number(),
|
||||
color: z.tuple([z.number(), z.number(), z.number()])
|
||||
})
|
||||
|
||||
const zRangeInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('RANGE'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
default: zRangeValue.optional(),
|
||||
display: z.enum(['plain', 'gradient', 'histogram']).optional(),
|
||||
gradient_stops: z.array(zColorStop).optional(),
|
||||
show_midpoint: z.boolean().optional(),
|
||||
midpoint_scale: z.enum(['linear', 'gamma']).optional(),
|
||||
value_min: z.number().optional(),
|
||||
value_max: z.number().optional()
|
||||
})
|
||||
|
||||
const zCustomInputSpec = zBaseInputOptions.extend({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
@@ -156,6 +180,7 @@ const zInputSpec = z.union([
|
||||
zGalleriaInputSpec,
|
||||
zTextareaInputSpec,
|
||||
zCurveInputSpec,
|
||||
zRangeInputSpec,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
@@ -201,6 +226,7 @@ export type ChartInputSpec = z.infer<typeof zChartInputSpec>
|
||||
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
|
||||
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
|
||||
export type CurveInputSpec = z.infer<typeof zCurveInputSpec>
|
||||
export type RangeInputSpec = z.infer<typeof zRangeInputSpec>
|
||||
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
|
||||
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/com
|
||||
import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
|
||||
import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget'
|
||||
import { usePainterWidget } from '@/renderer/extensions/vueNodes/widgets/composables/usePainterWidget'
|
||||
import { useRangeWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRangeWidget'
|
||||
import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
|
||||
import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
@@ -310,6 +311,7 @@ export const ComfyWidgets = {
|
||||
PAINTER: transformWidgetConstructorV2ToV1(usePainterWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()),
|
||||
RANGE: transformWidgetConstructorV2ToV1(useRangeWidget()),
|
||||
...dynamicWidgets
|
||||
} as const
|
||||
|
||||
|
||||
@@ -190,78 +190,28 @@ describe('useModelToNodeStore', () => {
|
||||
expect(provider?.key).toBe('')
|
||||
})
|
||||
|
||||
it('should return provider for new extension model types', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
it.each([
|
||||
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
|
||||
['sams', 'SAMLoader', 'model_name'],
|
||||
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
|
||||
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
|
||||
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
['FlashVSR', 'FlashVSRNode', ''],
|
||||
['FlashVSR-v1.1', 'FlashVSRNode', ''],
|
||||
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
|
||||
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name']
|
||||
])(
|
||||
'should return correct provider for %s',
|
||||
(modelType, expectedNodeName, expectedKey) => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
// SAM2
|
||||
const sam2Provider = modelToNodeStore.getNodeProvider('sam2')
|
||||
expect(sam2Provider?.nodeDef?.name).toBe('DownloadAndLoadSAM2Model')
|
||||
expect(sam2Provider?.key).toBe('model')
|
||||
|
||||
// SAMLoader (original SAM)
|
||||
const samsProvider = modelToNodeStore.getNodeProvider('sams')
|
||||
expect(samsProvider?.nodeDef?.name).toBe('SAMLoader')
|
||||
expect(samsProvider?.key).toBe('model_name')
|
||||
|
||||
// IP-Adapter
|
||||
const ipadapterProvider = modelToNodeStore.getNodeProvider('ipadapter')
|
||||
expect(ipadapterProvider?.nodeDef?.name).toBe('IPAdapterModelLoader')
|
||||
expect(ipadapterProvider?.key).toBe('ipadapter_file')
|
||||
|
||||
// DepthAnything
|
||||
const depthProvider = modelToNodeStore.getNodeProvider('depthanything')
|
||||
expect(depthProvider?.nodeDef?.name).toBe(
|
||||
'DownloadAndLoadDepthAnythingV2Model'
|
||||
)
|
||||
expect(depthProvider?.key).toBe('model')
|
||||
})
|
||||
|
||||
it('should use hierarchical fallback for ultralytics subcategories', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
// ultralytics/bbox should fall back to ultralytics
|
||||
const bboxProvider = modelToNodeStore.getNodeProvider('ultralytics/bbox')
|
||||
expect(bboxProvider?.nodeDef?.name).toBe('UltralyticsDetectorProvider')
|
||||
expect(bboxProvider?.key).toBe('model_name')
|
||||
|
||||
// ultralytics/segm should also fall back to ultralytics
|
||||
const segmProvider = modelToNodeStore.getNodeProvider('ultralytics/segm')
|
||||
expect(segmProvider?.nodeDef?.name).toBe('UltralyticsDetectorProvider')
|
||||
})
|
||||
|
||||
it('should return provider for FlashVSR nodes with empty key (auto-load)', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
const flashVSRProvider = modelToNodeStore.getNodeProvider('FlashVSR')
|
||||
expect(flashVSRProvider?.nodeDef?.name).toBe('FlashVSRNode')
|
||||
expect(flashVSRProvider?.key).toBe('')
|
||||
|
||||
const flashVSR11Provider =
|
||||
modelToNodeStore.getNodeProvider('FlashVSR-v1.1')
|
||||
expect(flashVSR11Provider?.nodeDef?.name).toBe('FlashVSRNode')
|
||||
expect(flashVSR11Provider?.key).toBe('')
|
||||
})
|
||||
|
||||
it('should return provider for segformer models', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
const segformerB2Provider = modelToNodeStore.getNodeProvider(
|
||||
'segformer_b2_clothes'
|
||||
)
|
||||
expect(segformerB2Provider?.nodeDef?.name).toBe('LS_LoadSegformerModel')
|
||||
expect(segformerB2Provider?.key).toBe('model_name')
|
||||
|
||||
const segformerB3FashionProvider = modelToNodeStore.getNodeProvider(
|
||||
'segformer_b3_fashion'
|
||||
)
|
||||
expect(segformerB3FashionProvider?.nodeDef?.name).toBe(
|
||||
'LS_LoadSegformerModel'
|
||||
)
|
||||
})
|
||||
const provider = modelToNodeStore.getNodeProvider(modelType)
|
||||
expect(provider?.nodeDef?.name).toBe(expectedNodeName)
|
||||
expect(provider?.key).toBe(expectedKey)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('getAllNodeProviders', () => {
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('useQueueStore', () => {
|
||||
})
|
||||
|
||||
describe('update() - sorting', () => {
|
||||
it('should sort tasks by queueIndex descending', async () => {
|
||||
it('should sort tasks by job.priority descending', async () => {
|
||||
const job1 = createHistoryJob(1, 'hist-1')
|
||||
const job2 = createHistoryJob(5, 'hist-2')
|
||||
const job3 = createHistoryJob(3, 'hist-3')
|
||||
@@ -344,9 +344,9 @@ describe('useQueueStore', () => {
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(store.historyTasks[0].queueIndex).toBe(5)
|
||||
expect(store.historyTasks[1].queueIndex).toBe(3)
|
||||
expect(store.historyTasks[2].queueIndex).toBe(1)
|
||||
expect(store.historyTasks[0].job.priority).toBe(5)
|
||||
expect(store.historyTasks[1].job.priority).toBe(3)
|
||||
expect(store.historyTasks[2].job.priority).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve API sort order for pending tasks', async () => {
|
||||
@@ -363,14 +363,14 @@ describe('useQueueStore', () => {
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(store.pendingTasks[0].queueIndex).toBe(15)
|
||||
expect(store.pendingTasks[1].queueIndex).toBe(12)
|
||||
expect(store.pendingTasks[2].queueIndex).toBe(10)
|
||||
expect(store.pendingTasks[0].job.priority).toBe(15)
|
||||
expect(store.pendingTasks[1].job.priority).toBe(12)
|
||||
expect(store.pendingTasks[2].job.priority).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update() - queue index collision (THE BUG FIX)', () => {
|
||||
it('should NOT confuse different prompts with same queueIndex', async () => {
|
||||
it('should NOT confuse different prompts with same job.priority', async () => {
|
||||
const hist1 = createHistoryJob(50, 'prompt-uuid-aaa')
|
||||
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
@@ -387,10 +387,10 @@ describe('useQueueStore', () => {
|
||||
|
||||
expect(store.historyTasks).toHaveLength(1)
|
||||
expect(store.historyTasks[0].jobId).toBe('prompt-uuid-bbb')
|
||||
expect(store.historyTasks[0].queueIndex).toBe(51)
|
||||
expect(store.historyTasks[0].job.priority).toBe(51)
|
||||
})
|
||||
|
||||
it('should correctly reconcile when queueIndex is reused', async () => {
|
||||
it('should correctly reconcile when job.priority is reused', async () => {
|
||||
const hist1 = createHistoryJob(100, 'first-prompt-at-100')
|
||||
const hist2 = createHistoryJob(99, 'prompt-at-99')
|
||||
|
||||
@@ -412,7 +412,7 @@ describe('useQueueStore', () => {
|
||||
expect(jobIds).not.toContain('first-prompt-at-100')
|
||||
})
|
||||
|
||||
it('should handle multiple queueIndex collisions simultaneously', async () => {
|
||||
it('should handle multiple job.priority collisions simultaneously', async () => {
|
||||
const hist1 = createHistoryJob(10, 'old-at-10')
|
||||
const hist2 = createHistoryJob(20, 'old-at-20')
|
||||
const hist3 = createHistoryJob(30, 'keep-at-30')
|
||||
@@ -563,9 +563,9 @@ describe('useQueueStore', () => {
|
||||
await store.update()
|
||||
|
||||
expect(store.historyTasks).toHaveLength(3)
|
||||
expect(store.historyTasks[0].queueIndex).toBe(10)
|
||||
expect(store.historyTasks[1].queueIndex).toBe(9)
|
||||
expect(store.historyTasks[2].queueIndex).toBe(8)
|
||||
expect(store.historyTasks[0].job.priority).toBe(10)
|
||||
expect(store.historyTasks[1].job.priority).toBe(9)
|
||||
expect(store.historyTasks[2].job.priority).toBe(8)
|
||||
})
|
||||
|
||||
it('should respect maxHistoryItems when combining new and existing', async () => {
|
||||
@@ -589,7 +589,7 @@ describe('useQueueStore', () => {
|
||||
await store.update()
|
||||
|
||||
expect(store.historyTasks).toHaveLength(5)
|
||||
expect(store.historyTasks[0].queueIndex).toBe(23)
|
||||
expect(store.historyTasks[0].job.priority).toBe(23)
|
||||
})
|
||||
|
||||
it('should handle maxHistoryItems = 0', async () => {
|
||||
@@ -619,7 +619,7 @@ describe('useQueueStore', () => {
|
||||
await store.update()
|
||||
|
||||
expect(store.historyTasks).toHaveLength(1)
|
||||
expect(store.historyTasks[0].queueIndex).toBe(10)
|
||||
expect(store.historyTasks[0].job.priority).toBe(10)
|
||||
})
|
||||
|
||||
it('should dynamically adjust when maxHistoryItems changes', async () => {
|
||||
|
||||
@@ -312,10 +312,6 @@ export class TaskItemImpl {
|
||||
return this.jobId + this.displayStatus
|
||||
}
|
||||
|
||||
get queueIndex() {
|
||||
return this.job.priority
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.job.id
|
||||
}
|
||||
@@ -500,7 +496,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
)
|
||||
|
||||
const lastHistoryQueueIndex = computed<number>(() =>
|
||||
historyTasks.value.length ? historyTasks.value[0].queueIndex : -1
|
||||
historyTasks.value.length ? historyTasks.value[0].job.priority : -1
|
||||
)
|
||||
|
||||
const hasPendingTasks = computed<boolean>(() => pendingTasks.value.length > 0)
|
||||
|
||||
@@ -44,7 +44,7 @@ export const iconForJobState = (state: JobState): string => {
|
||||
const buildTitle = (task: TaskItemImpl, t: (k: string) => string): string => {
|
||||
const prefix = t('g.job')
|
||||
const shortId = String(task.jobId ?? '').split('-')[0]
|
||||
const idx = task.queueIndex
|
||||
const idx = task.job.priority
|
||||
if (typeof idx === 'number') return `${prefix} #${idx}`
|
||||
if (shortId) return `${prefix} ${shortId}`
|
||||
return prefix
|
||||
|
||||
Reference in New Issue
Block a user