Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added linked inputs group #1983

Merged
merged 7 commits into from
Jul 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/src/nodes/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ def optional_list_group(*inputs: BaseInput | NestedGroup):
can be used to create a similar effect.

See the Text Append node for an example.
```
"""
return group("optional-list")(*inputs)


def linked_inputs_group(*inputs: BaseInput):
"""
This group wraps around inputs of the same type. It ensures that all inputs have the same
value.

"The same type" here not only refers to the Navi type of those inputs. All possible values
from all inputs must also be valid values for all other inputs. This typically necessitates
that the inputs are of the same class and use the same parameters.
"""
return group("linked-inputs")(*inputs)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import cv2
import numpy as np

from nodes.groups import linked_inputs_group
from nodes.properties.inputs import ImageInput, SliderInput
from nodes.properties.outputs import ImageOutput

Expand Down Expand Up @@ -52,25 +53,27 @@ def get_kernel_2d(radius_x: float, radius_y) -> np.ndarray:
icon="MdBlurOn",
inputs=[
ImageInput(),
SliderInput(
"Radius X",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
),
SliderInput(
"Radius Y",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
linked_inputs_group(
SliderInput(
"Radius X",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
),
SliderInput(
"Radius Y",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
),
),
],
outputs=[ImageOutput(image_type="Input0")],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import numpy as np

from nodes.groups import linked_inputs_group
from nodes.impl.image_utils import fast_gaussian_blur
from nodes.properties.inputs import ImageInput, SliderInput
from nodes.properties.outputs import ImageOutput
Expand All @@ -16,25 +17,27 @@
icon="MdBlurOn",
inputs=[
ImageInput(),
SliderInput(
"Radius X",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
),
SliderInput(
"Radius Y",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
linked_inputs_group(
SliderInput(
"Radius X",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
),
SliderInput(
"Radius Y",
minimum=0,
maximum=1000,
default=1,
precision=1,
controls_step=1,
slider_step=0.1,
scale="log",
),
),
],
outputs=[ImageOutput(image_type="Input0")],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import cv2
import numpy as np

from nodes.groups import linked_inputs_group
from nodes.properties.inputs import ImageInput, SliderInput
from nodes.properties.outputs import ImageOutput

Expand All @@ -16,8 +17,10 @@
icon="MdOutlineAutoFixHigh",
inputs=[
ImageInput(),
SliderInput("Size X", minimum=1, maximum=1024, default=10, scale="log"),
SliderInput("Size Y", minimum=1, maximum=1024, default=10, scale="log"),
linked_inputs_group(
SliderInput("Size X", minimum=1, maximum=1024, default=10, scale="log"),
SliderInput("Size Y", minimum=1, maximum=1024, default=10, scale="log"),
),
],
outputs=[ImageOutput(image_type="Input0")],
)
Expand Down
7 changes: 6 additions & 1 deletion src/common/common-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,19 @@ interface SeedGroup extends GroupBase {
readonly kind: 'seed';
readonly options: Readonly<Record<string, never>>;
}
interface LinkedInputsGroup extends GroupBase {
readonly kind: 'linked-inputs';
readonly options: Readonly<Record<string, never>>;
}
export type GroupKind = Group['kind'];
export type Group =
| NcnnFileInputGroup
| FromToDropdownsGroup
| OptionalListGroup
| ConditionalGroup
| RequiredGroup
| SeedGroup;
| SeedGroup
| LinkedInputsGroup;

export type OfKind<T extends { readonly kind: string }, Kind extends T['kind']> = T extends {
readonly kind: Kind;
Expand Down
15 changes: 15 additions & 0 deletions src/common/group-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
NodeSchema,
NumberInput,
OfKind,
SliderInput,
} from './common-types';
import { getChainnerScope } from './types/chainner-scope';
import { fromJson } from './types/json';
Expand All @@ -34,6 +35,7 @@ type DeclaredGroupInputs = InputGuarantees<{
'ncnn-file-inputs': readonly [FileInput, FileInput];
'optional-list': readonly [InputItem, ...InputItem[]];
seed: readonly [NumberInput];
'linked-inputs': readonly [SliderInput, SliderInput, ...SliderInput[]];
}>;

// A bit hacky, but this ensures that GroupInputs covers exactly all group types, no more and no less
Expand Down Expand Up @@ -143,6 +145,19 @@ const groupInputsChecks: {

if (input.kind !== 'number') return 'Expected the input to be a number input';
},
'linked-inputs': (inputs) => {
if (inputs.length < 2) return 'Expected at least 2 inputs';
if (!allInputsOfKind(inputs, 'slider')) return `Expected all inputs to be slider inputs`;

const [ref] = inputs;
for (const i of inputs) {
if (i.min !== ref.min) return 'Expected all inputs to have the same min value';
if (i.max !== ref.max) return 'Expected all inputs to have the same max value';
if (i.precision !== ref.precision)
return 'Expected all inputs to have the same precision value';
if (i.def !== ref.def) return 'Expected all inputs to have the same default value';
}
},
};

export const checkGroupInputs = (
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/components/groups/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { InputItem } from '../../../common/group-inputs';
import { NodeState } from '../../helpers/nodeState';
import { ConditionalGroup } from './ConditionalGroup';
import { FromToDropdownsGroup } from './FromToDropdownsGroup';
import { LinkedInputsGroup } from './LinkedInputsGroup';
import { NcnnFileInputsGroup } from './NcnnFileInputsGroup';
import { OptionalInputsGroup } from './OptionalInputsGroup';
import { GroupProps, InputItemRenderer } from './props';
Expand All @@ -19,6 +20,7 @@ const GroupComponents: {
'ncnn-file-inputs': NcnnFileInputsGroup,
'optional-list': OptionalInputsGroup,
seed: SeedGroup,
'linked-inputs': LinkedInputsGroup,
};

interface GroupElementProps {
Expand Down
167 changes: 167 additions & 0 deletions src/renderer/components/groups/LinkedInputsGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Box, HStack, IconButton, Tooltip } from '@chakra-ui/react';
import { memo, useCallback, useState } from 'react';
import { IoMdLink } from 'react-icons/io';
import { IoUnlink } from 'react-icons/io5';
import { InputId, InputValue } from '../../../common/common-types';
import { joinEnglish } from '../../../common/util';
import { NodeState } from '../../helpers/nodeState';
import { useMemoObject } from '../../hooks/useMemo';
import { GroupProps } from './props';

export const LinkedInputsGroup = memo(
({ inputs, nodeState, ItemRenderer }: GroupProps<'linked-inputs'>) => {
const { inputData, setInputValue, isLocked } = nodeState;

const [linked, setLinked] = useState<boolean>(() => {
const allSameValue = inputs.every((input) => {
const value = inputData[input.id];
return value === inputData[inputs[0].id];
});
return allSameValue;
});

const [lastUsedInput, setLastUsedInput] = useState<InputId | null>(null);

const modifiedNodeState = useMemoObject<NodeState>({
...nodeState,
setInputValue: useCallback(
(inputId: InputId, value: InputValue): void => {
setInputValue(inputId, value);
setLastUsedInput(inputId);

if (linked && typeof value === 'number') {
for (const input of inputs) {
if (input.id !== inputId) {
setInputValue(input.id, value);
}
}
}
},
[linked, setInputValue, inputs]
),
});

const allConnected = inputs.every((input) => nodeState.connectedInputs.has(input.id));

const label = linked
? `The values of ${joinEnglish(
inputs.map((input) => input.label),
'and'
)} are currently linked to the same value. Click here to undo this link.`
: `Click here to link ${joinEnglish(
inputs.map((input) => input.label),
'and'
)} to the same value.`;

const linkButtonWidth = 1.4;
const linkButtonHeight = 1.6;

return (
<HStack
alignItems="normal"
pr={1}
spacing={0}
>
<Box w="full">
{inputs.map((item) => (
<ItemRenderer
item={item}
key={item.id}
nodeState={modifiedNodeState}
/>
))}
</Box>
<Box
alignItems="center"
display="flex"
position="relative"
>
<Box
borderColor="white white transparent transparent"
borderRadius="0 .5rem 0 0"
borderStyle="solid"
borderWidth="1px 2px 0 0"
bottom={`calc(50% + ${linkButtonHeight / 2}rem)`}
opacity={allConnected ? 0.25 : 0.5}
position="absolute"
right={`calc(${linkButtonWidth / 2}rem - 1px)`}
top=".5rem"
w={2}
/>
<Box
borderColor="transparent white white transparent"
borderRadius="0 0 .5rem 0"
borderStyle="solid"
borderWidth="0 2px 1px 0"
bottom=".5rem"
opacity={allConnected ? 0.25 : 0.5}
position="absolute"
right={`calc(${linkButtonWidth / 2}rem - 1px)`}
top={`calc(50% + ${linkButtonHeight / 2}rem)`}
w={2}
/>
<Tooltip
closeOnClick
closeOnPointerDown
hasArrow
borderRadius={8}
isDisabled={isLocked || allConnected}
label={label}
openDelay={2000}
>
<IconButton
aria-label={label}
className="nodrag"
h="2rem"
icon={
linked ? (
<IoMdLink style={{ transform: 'rotate(90deg)' }} />
) : (
<IoUnlink style={{ transform: 'rotate(90deg)' }} />
)
}
isDisabled={isLocked || allConnected}
minWidth={0}
ml="-0.25rem"
size="md"
variant="ghost"
w={`${linkButtonWidth}rem`}
onClick={() => {
if (linked) {
// just unlink
setLinked(false);
} else {
// link and set inputs to the same value
setLinked(true);

let value: InputValue;
if (
lastUsedInput !== null &&
!nodeState.connectedInputs.has(lastUsedInput)
) {
// use the value of the last used input (if not connected)
value = nodeState.inputData[lastUsedInput];
} else {
// use the value of the first unconnected input
const firstUnconnectedInput = inputs.find(
(input) => !nodeState.connectedInputs.has(input.id)
);
if (firstUnconnectedInput) {
value = nodeState.inputData[firstUnconnectedInput.id];
}
}

if (value !== undefined) {
for (const input of inputs) {
setInputValue(input.id, value);
}
}
}
}}
/>
</Tooltip>
</Box>
</HStack>
);
}
);