mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
[backport cloud/1.41] feat: add linear interpolation type to CURVE widget (#10166)
Backport of #10118 to cloud/1.41.
Cherry-pick of 46d8567f10.
## Original PR
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10118
## Summary
Change the CURVE widget value from CurvePoint[] to CurveData ({ points,
interpolation }) to support multiple interpolation types. Add a Select
dropdown in the widget UI for switching between Smooth (monotone cubic)
and Linear interpolation, with the SVG preview updating accordingly.
## Conflict Resolution
- CurveEditor.vue, WidgetCurve.vue, curveUtils.ts: Took main version
which merges both #9896 (disabled/upstream) and #10118 (interpolation)
features
- locales/en/main.json: Auto-merged cleanly
- package.json: Kept target branch version
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10166-backport-cloud-1-41-feat-add-linear-interpolation-type-to-CURVE-widget-3266d73d365081e8ba22c73c1590eb09)
by [Unito](https://www.unito.io)
This commit is contained in:
@@ -74,17 +74,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { computed, toRef, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
import type { CurveInterpolation, CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
const {
|
||||
curveColor = 'white',
|
||||
histogram,
|
||||
interpolation = 'monotone_cubic'
|
||||
} = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
interpolation?: CurveInterpolation
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
@@ -95,7 +100,8 @@ const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
|
||||
|
||||
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
svgRef,
|
||||
modelValue
|
||||
modelValue,
|
||||
interpolation: toRef(() => interpolation)
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<Select
|
||||
:model-value="modelValue.interpolation"
|
||||
@update:model-value="onInterpolationChange"
|
||||
>
|
||||
<SelectTrigger size="md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="interp in CURVE_INTERPOLATIONS"
|
||||
:key="interp"
|
||||
:value="interp"
|
||||
>
|
||||
{{ $t(`curveWidget.${interp}`) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CurveEditor
|
||||
:model-value="modelValue.points"
|
||||
:interpolation="modelValue.interpolation"
|
||||
@update:model-value="onPointsChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from './types'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
import { CURVE_INTERPOLATIONS } from './types'
|
||||
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
]
|
||||
const modelValue = defineModel<CurveData>({
|
||||
default: () => ({
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
})
|
||||
})
|
||||
|
||||
function onPointsChange(points: CurvePoint[]) {
|
||||
modelValue.value = { ...modelValue.value, points }
|
||||
}
|
||||
|
||||
function onInterpolationChange(value: unknown) {
|
||||
if (typeof value !== 'string') return
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
interpolation: value as CurveInterpolation
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import {
|
||||
createLinearInterpolator,
|
||||
createMonotoneInterpolator,
|
||||
curvesToLUT,
|
||||
histogramToPath
|
||||
@@ -73,6 +74,64 @@ describe('createMonotoneInterpolator', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLinearInterpolator', () => {
|
||||
it('returns 0 for empty points', () => {
|
||||
const interpolate = createLinearInterpolator([])
|
||||
expect(interpolate(0.5)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns constant for single point', () => {
|
||||
const interpolate = createLinearInterpolator([[0.5, 0.7]])
|
||||
expect(interpolate(0)).toBe(0.7)
|
||||
expect(interpolate(1)).toBe(0.7)
|
||||
})
|
||||
|
||||
it('passes through control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createLinearInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.8, 10)
|
||||
expect(interpolate(1)).toBe(1)
|
||||
})
|
||||
|
||||
it('linearly interpolates between points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createLinearInterpolator(points)
|
||||
expect(interpolate(0.25)).toBeCloseTo(0.25, 10)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
|
||||
expect(interpolate(0.75)).toBeCloseTo(0.75, 10)
|
||||
})
|
||||
|
||||
it('clamps to endpoint values outside range', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
]
|
||||
const interpolate = createLinearInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0.3)
|
||||
expect(interpolate(1)).toBe(0.9)
|
||||
})
|
||||
|
||||
it('handles unsorted input points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
[0.5, 0.5]
|
||||
]
|
||||
const interpolate = createLinearInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
|
||||
expect(interpolate(1)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('curvesToLUT', () => {
|
||||
it('returns a 256-entry Uint8Array', () => {
|
||||
const lut = curvesToLUT([
|
||||
|
||||
@@ -1,4 +1,50 @@
|
||||
import type { CurvePoint } from './types'
|
||||
import type { CurveInterpolation, CurvePoint } from './types'
|
||||
|
||||
/**
|
||||
* Piecewise linear interpolation through sorted control points.
|
||||
* Returns a function that evaluates y for any x in [0, 1].
|
||||
*/
|
||||
export function createLinearInterpolator(
|
||||
points: CurvePoint[]
|
||||
): (x: number) => number {
|
||||
if (points.length === 0) return () => 0
|
||||
if (points.length === 1) return () => points[0][1]
|
||||
|
||||
const sorted = [...points].sort((a, b) => a[0] - b[0])
|
||||
const n = sorted.length
|
||||
const xs = sorted.map((p) => p[0])
|
||||
const ys = sorted.map((p) => p[1])
|
||||
|
||||
return (x: number): number => {
|
||||
if (x <= xs[0]) return ys[0]
|
||||
if (x >= xs[n - 1]) return ys[n - 1]
|
||||
|
||||
let lo = 0
|
||||
let hi = n - 1
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (xs[mid] <= x) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const dx = xs[hi] - xs[lo]
|
||||
if (dx === 0) return ys[lo]
|
||||
const t = (x - xs[lo]) / dx
|
||||
return ys[lo] + t * (ys[hi] - ys[lo])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that dispatches to the correct interpolator based on type.
|
||||
*/
|
||||
export function createInterpolator(
|
||||
points: CurvePoint[],
|
||||
interpolation: CurveInterpolation
|
||||
): (x: number) => number {
|
||||
return interpolation === 'linear'
|
||||
? createLinearInterpolator(points)
|
||||
: createMonotoneInterpolator(points)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
@@ -106,9 +152,12 @@ export function histogramToPath(histogram: Uint32Array): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
|
||||
export function curvesToLUT(
|
||||
points: CurvePoint[],
|
||||
interpolation: CurveInterpolation = 'monotone_cubic'
|
||||
): Uint8Array {
|
||||
const lut = new Uint8Array(256)
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
const interpolate = createInterpolator(points, interpolation)
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i / 255
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
|
||||
export const CURVE_INTERPOLATIONS = ['monotone_cubic', 'linear'] as const
|
||||
|
||||
export type CurveInterpolation = (typeof CURVE_INTERPOLATIONS)[number]
|
||||
|
||||
export interface CurveData {
|
||||
points: CurvePoint[]
|
||||
interpolation: CurveInterpolation
|
||||
}
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
|
||||
import type { CurvePoint } from '@/components/curve/types'
|
||||
import { createInterpolator } from '@/components/curve/curveUtils'
|
||||
import type { CurveInterpolation, CurvePoint } from '@/components/curve/types'
|
||||
|
||||
interface UseCurveEditorOptions {
|
||||
svgRef: Ref<SVGSVGElement | null>
|
||||
modelValue: Ref<CurvePoint[]>
|
||||
interpolation: Ref<CurveInterpolation>
|
||||
}
|
||||
|
||||
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
|
||||
export function useCurveEditor({
|
||||
svgRef,
|
||||
modelValue,
|
||||
interpolation
|
||||
}: UseCurveEditorOptions) {
|
||||
const dragIndex = ref(-1)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
|
||||
const curvePath = computed(() => {
|
||||
const points = modelValue.value
|
||||
if (points.length < 2) return ''
|
||||
const sorted = [...points].sort((a, b) => a[0] - b[0])
|
||||
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
const xMin = points[0][0]
|
||||
const xMax = points[points.length - 1][0]
|
||||
if (interpolation.value === 'linear') {
|
||||
return sorted
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${1 - p[1]}`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const interpolate = createInterpolator(sorted, interpolation.value)
|
||||
const xMin = sorted[0][0]
|
||||
const xMax = sorted[sorted.length - 1][0]
|
||||
const segments = 128
|
||||
const range = xMax - xMin
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -692,6 +692,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
nodeId: this.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
this._invalidatePromotedViewsCache()
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurvePoint } from '@/components/curve/types'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -331,9 +331,9 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
|
||||
value: Bounds
|
||||
}
|
||||
|
||||
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
|
||||
export interface ICurveWidget extends IBaseWidget<CurveData, 'curve'> {
|
||||
type: 'curve'
|
||||
value: CurvePoint[]
|
||||
value: CurveData
|
||||
}
|
||||
|
||||
export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
|
||||
@@ -1944,6 +1944,10 @@
|
||||
"width": "Width",
|
||||
"height": "Height"
|
||||
},
|
||||
"curveWidget": {
|
||||
"monotone_cubic": "Smooth",
|
||||
"linear": "Linear"
|
||||
},
|
||||
"toastMessages": {
|
||||
"nothingToQueue": "Nothing to queue",
|
||||
"pleaseSelectOutputNodes": "Please select output nodes",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ICurveWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
@@ -6,20 +7,22 @@ import type {
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
const DEFAULT_CURVE_DATA: CurveData = {
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
}
|
||||
|
||||
export const useCurveWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): ICurveWidget => {
|
||||
const spec = inputSpec as CurveInputSpec
|
||||
const defaultValue = spec.default ?? [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
]
|
||||
const defaultValue: CurveData = spec.default
|
||||
? { ...spec.default, points: [...spec.default.points] }
|
||||
: { ...DEFAULT_CURVE_DATA, points: [...DEFAULT_CURVE_DATA.points] }
|
||||
|
||||
const rawWidget = node.addWidget(
|
||||
'curve',
|
||||
spec.name,
|
||||
[...defaultValue],
|
||||
() => {}
|
||||
)
|
||||
const rawWidget = node.addWidget('curve', spec.name, defaultValue, () => {})
|
||||
|
||||
if (rawWidget.type !== 'curve') {
|
||||
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
|
||||
|
||||
@@ -128,11 +128,16 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
|
||||
|
||||
const zCurvePoint = z.tuple([z.number(), z.number()])
|
||||
|
||||
const zCurveData = z.object({
|
||||
points: z.array(zCurvePoint),
|
||||
interpolation: z.enum(['monotone_cubic', 'linear'])
|
||||
})
|
||||
|
||||
const zCurveInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('CURVE'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
default: z.array(zCurvePoint).optional()
|
||||
default: zCurveData.optional()
|
||||
})
|
||||
|
||||
const zCustomInputSpec = zBaseInputOptions.extend({
|
||||
|
||||
Reference in New Issue
Block a user