feat(image-list)!: replace fixed Start extras with growable value_N slots
Distributed Image List Start no longer has reference_image, text, strength, seed or per-slot mode dropdowns. It now takes images, mask and up to 10 generic value_N inputs (IMAGE, MASK, STRING, INT, FLOAT, BOOLEAN) that grow via native V3 Autogrow, each with a matching output. - One value is shared by every image; one per image pairs in order; any other count fails before dispatch. Only images is split into frames. - Outputs are declared up front (image, mask, item_index, context, value_1..value_10). web/imageListStart.js hides unused value outputs, mirrors input types, and follows Autogrow compaction so downstream links keep their value. - Image-list protocol bumped to v2; older workers fail preflight. Breaking: saved workflows using the old Start inputs must recreate Start.
This commit is contained in:
@@ -63,6 +63,17 @@ class TestImageSource:
|
||||
('image', 'reference_image', 'mask', 'text', 'strength', 'seed'))
|
||||
|
||||
|
||||
class TestSharedFlag:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {'required': {}}
|
||||
RETURN_TYPES = ('BOOLEAN',)
|
||||
FUNCTION = 'run'
|
||||
def run(self):
|
||||
assert not WORKER, 'shared value source leaked into worker prompt'
|
||||
return (True,)
|
||||
|
||||
|
||||
class TestCachedModel:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -81,10 +92,10 @@ class TestImageOperation:
|
||||
'text': ('STRING', {'forceInput': True}),
|
||||
'strength': ('FLOAT', {'forceInput': True}),
|
||||
'seed': ('INT', {'forceInput': True}), 'index': ('INT', {'forceInput': True}),
|
||||
'model': ('MODEL',)}}
|
||||
'flag': ('BOOLEAN', {'forceInput': True}), 'model': ('MODEL',)}}
|
||||
RETURN_TYPES = ('IMAGE', 'MASK', 'STRING')
|
||||
FUNCTION = 'run'
|
||||
def run(self, image, reference_image, mask, text, strength, seed, index, model):
|
||||
def run(self, image, reference_image, mask, text, strength, seed, index, flag, model):
|
||||
assert WORKER or ALLOW_MASTER, 'per-image branch executed in orchestrator-only mode'
|
||||
BRANCH_CALLS.append(index)
|
||||
# Sleep in short chunks so the real Comfy interrupt signal can be tested.
|
||||
@@ -93,6 +104,7 @@ class TestImageOperation:
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
time.sleep(0.01)
|
||||
assert seed == 100 + index
|
||||
assert flag is True
|
||||
assert text == f'item-{index}'
|
||||
assert strength == (index + 1) / 16
|
||||
assert torch.all(reference_image == strength)
|
||||
@@ -119,7 +131,7 @@ async def setup():
|
||||
module = sys.modules[str(ROOT).replace('.', '_x_')]
|
||||
nodes.NODE_CLASS_MAPPINGS.update(TestImageSource=TestImageSource,
|
||||
TestImageOperation=TestImageOperation, TestCapture=TestCapture,
|
||||
TestCachedModel=TestCachedModel)
|
||||
TestCachedModel=TestCachedModel, TestSharedFlag=TestSharedFlag)
|
||||
# Match the CORS-enabled direct-worker launch contract for canvas probes.
|
||||
app = web.Application(middlewares=[server.create_cors_middleware('*')])
|
||||
# Register the actual feature routes, not test replacements.
|
||||
@@ -206,19 +218,22 @@ async def worker_main():
|
||||
def graph(callback, worker_ids):
|
||||
return {
|
||||
'1': {'class_type': 'TestImageSource', 'inputs': {}},
|
||||
# Generic Autogrow value slots carry per-item reference images, text,
|
||||
# strength and seed (one value per image), plus a shared boolean.
|
||||
'2': {'class_type': 'DistributedImageListStart', 'inputs': {
|
||||
'images': ['1', 0], 'reference_image': ['1', 1], 'mask': ['1', 2], 'text': ['1', 3],
|
||||
'strength': ['1', 4], 'seed': ['1', 5],
|
||||
'reference_image_mode': 'per_item', 'mask_mode': 'per_item', 'text_mode': 'per_item',
|
||||
'strength_mode': 'per_item', 'seed_mode': 'per_item',
|
||||
'images': ['1', 0], 'mask': ['1', 2],
|
||||
'values.value_1': ['1', 1], 'values.value_2': ['1', 3],
|
||||
'values.value_3': ['1', 4], 'values.value_4': ['1', 5], 'values.value_5': ['7', 0],
|
||||
'image_list_enabled_worker_ids': json.dumps(worker_ids), 'image_list_master_callback': callback}},
|
||||
'3': {'class_type': 'TestImageOperation', 'inputs': {
|
||||
key: ['2', index] for key, index in [('image', 0), ('reference_image', 1), ('mask', 2),
|
||||
('text', 3), ('strength', 4), ('seed', 5), ('index', 6)]} | {'model': ['6', 0]}},
|
||||
key: ['2', index] for key, index in [('image', 0), ('mask', 1), ('index', 2),
|
||||
('reference_image', 4), ('text', 5), ('strength', 6), ('seed', 7), ('flag', 8)]}
|
||||
| {'model': ['6', 0]}},
|
||||
'4': {'class_type': 'DistributedImageListEnd', 'inputs': {
|
||||
'images': ['3', 0], 'masks': ['3', 1], 'texts': ['3', 2], 'context': ['2', 7]}},
|
||||
'images': ['3', 0], 'masks': ['3', 1], 'texts': ['3', 2], 'context': ['2', 3]}},
|
||||
'5': {'class_type': 'TestCapture', 'inputs': {'images': ['4', 0], 'masks': ['4', 1], 'texts': ['4', 2]}},
|
||||
'6': {'class_type': 'TestCachedModel', 'inputs': {}},
|
||||
'7': {'class_type': 'TestSharedFlag', 'inputs': {}},
|
||||
}
|
||||
|
||||
|
||||
@@ -325,10 +340,9 @@ async def coordinator():
|
||||
previous = '1'
|
||||
for start, end in [('2', '3'), ('4', '5'), ('6', '7')]:
|
||||
chained[start] = {'class_type': 'DistributedImageListStart', 'inputs': {
|
||||
'images': [previous, 0], 'reference_image_mode': 'per_item', 'mask_mode': 'per_item',
|
||||
'text_mode': 'shared', 'strength_mode': 'shared', 'seed_mode': 'shared'}}
|
||||
'images': [previous, 0]}}
|
||||
chained[end] = {'class_type': 'DistributedImageListEnd', 'inputs': {
|
||||
'images': [start, 0], 'context': [start, 7]}}
|
||||
'images': [start, 0], 'context': [start, 3]}}
|
||||
previous = end
|
||||
chained['8'] = {'class_type': 'TestCapture', 'inputs': {
|
||||
'images': ['7', 0], 'masks': ['7', 1], 'texts': ['7', 2]}}
|
||||
|
||||
@@ -228,15 +228,13 @@ async def check_image_list_lazy_executor(mapping, module, prompt_server, asset_m
|
||||
graph = {
|
||||
'1': {'class_type': 'EmptyImage', 'inputs': {'height': 8, 'width': 8, 'batch_size': 3, 'color': 0}},
|
||||
'2': {'class_type': 'DistributedImageListStart', 'inputs': {
|
||||
'images': ['1', 0], 'reference_image_mode': 'per_item', 'mask_mode': 'per_item',
|
||||
'text_mode': 'shared', 'strength': 1.0, 'strength_mode': 'shared',
|
||||
'seed': 17, 'seed_mode': 'shared', 'image_list_enabled_worker_ids': '["test-worker"]',
|
||||
'images': ['1', 0], 'image_list_enabled_worker_ids': '["test-worker"]',
|
||||
'image_list_master_callback': 'http://127.0.0.1:1',
|
||||
'image_list_workflow_meta': workflow_meta,
|
||||
}},
|
||||
'3': {'class_type': 'ImageListTestProbe', 'inputs': {'image': ['2', 0]}},
|
||||
'4': {'class_type': 'DistributedImageListEnd', 'inputs': {
|
||||
'images': ['3', 0], 'context': ['2', 7],
|
||||
'images': ['3', 0], 'context': ['2', 3],
|
||||
}},
|
||||
'5': {'class_type': 'PreviewImage', 'inputs': {'images': ['4', 0]}},
|
||||
}
|
||||
@@ -247,7 +245,7 @@ async def check_image_list_lazy_executor(mapping, module, prompt_server, asset_m
|
||||
assert len(validator(graph)) == 1
|
||||
connected_text_graph = json.loads(json.dumps(graph))
|
||||
connected_text_graph['6'] = {
|
||||
'class_type': 'ImageListTextProbe', 'inputs': {'text': ['2', 3]},
|
||||
'class_type': 'ImageListTextProbe', 'inputs': {'text': ['2', 4]},
|
||||
}
|
||||
connected_text_graph['4']['inputs']['texts'] = ['6', 0]
|
||||
assert len(validator(connected_text_graph)) == 1
|
||||
@@ -359,9 +357,44 @@ async def main():
|
||||
assert texts_schema.get('forceInput') is True, texts_schema
|
||||
assert 'default' not in texts_schema, texts_schema
|
||||
assert [out.io_type for out in start_schema.outputs] == [
|
||||
'IMAGE', 'IMAGE', 'MASK', 'STRING', 'FLOAT', 'INT', 'INT', 'DISTRIBUTED_IMAGE_LIST_CONTEXT']
|
||||
'IMAGE', 'MASK', 'INT', 'DISTRIBUTED_IMAGE_LIST_CONTEXT'] + ['*'] * 10
|
||||
assert [out.display_name for out in start_schema.outputs][4:] == [f'value_{i}' for i in range(1, 11)]
|
||||
assert all(out.is_output_list for out in start_schema.outputs)
|
||||
assert start_schema.is_input_list and start_schema.accept_all_inputs
|
||||
start_info = json.loads(json.dumps(mapping['DistributedImageListStart'].GET_NODE_INFO_V1()))
|
||||
assert list(start_info['input']['optional']) == ['images', 'mask', 'values'], start_info['input']
|
||||
values_spec = start_info['input']['optional']['values']
|
||||
assert values_spec[0] == 'COMFY_AUTOGROW_V3', values_spec
|
||||
template = values_spec[1]['template']
|
||||
assert template['names'] == [f'value_{i}' for i in range(1, 11)] and template['min'] == 0, template
|
||||
assert template['input']['required']['value'][0] == 'IMAGE,MASK,STRING,INT,FLOAT,BOOLEAN', template
|
||||
# Autogrow + INPUT_IS_LIST through the real parser/list mapper, values
|
||||
# still split 1-or-N, unconnected slots ignored.
|
||||
# get_input_data must accept Autogrow names; constants are wrapped once.
|
||||
parsed, missing, hidden = execution.get_input_data(
|
||||
{'values.value_1': 'shared', 'values.value_3': 4},
|
||||
mapping['DistributedImageListStart'], unique_id='autogrow-probe', extra_data={})
|
||||
assert not missing and parsed['values.value_1'] == ['shared'], parsed
|
||||
# Linked inputs arrive as the upstream output list (one element per value).
|
||||
prepared = {'images': [torch.zeros((3, 2, 2, 3))], 'values.value_1': ['shared'],
|
||||
'values.value_3': [4, 5, 6]}
|
||||
seen = {}
|
||||
runtime_mod = sys.modules[module.__name__ + '.image_list.runtime']
|
||||
def capture(_images, _mask, values):
|
||||
seen['values'] = values
|
||||
raise RuntimeError('captured')
|
||||
nodes_mod = sys.modules[module.__name__ + '.nodes.image_list']
|
||||
with patch.object(nodes_mod, 'normalize_image_list_inputs', capture):
|
||||
try:
|
||||
await asyncio.to_thread(lambda: asyncio.run(execution._async_map_node_over_list(
|
||||
'autogrow-probe', 'autogrow-probe', mapping['DistributedImageListStart'], prepared,
|
||||
'execute', v3_data=hidden)))
|
||||
except RuntimeError as exc:
|
||||
assert 'captured' in str(exc), exc
|
||||
assert seen['values'] == {'value_1': ['shared'], 'value_3': [4, 5, 6]}, seen
|
||||
rows = runtime_mod.normalize_image_list_inputs(prepared['images'], None, seen['values'])
|
||||
assert [row['values'] for row in rows] == [{'value_1': 'shared', 'value_3': n} for n in (4, 5, 6)]
|
||||
print('IMAGE_LIST_AUTOGROW_OK real parser; value_N slots shared/per-item')
|
||||
assert [out.io_type for out in end_schema.outputs] == ['IMAGE', 'MASK', 'STRING']
|
||||
assert all(out.is_output_list for out in end_schema.outputs)
|
||||
assert end_schema.is_input_list and end_schema.is_output_node
|
||||
|
||||
@@ -168,7 +168,7 @@ class _FakeSession:
|
||||
def _valid_capability():
|
||||
return {
|
||||
"supported": True,
|
||||
"protocol_version": 1,
|
||||
"protocol_version": 2,
|
||||
"node_ids": ["DistributedImageListStart", "DistributedImageListEnd"],
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ class DispatchSelectionTests(unittest.IsolatedAsyncioTestCase):
|
||||
await dispatch.preflight_image_list_workers(workers, require_idle=True)
|
||||
|
||||
async def test_image_list_preflight_fails_closed_for_malformed_capabilities(self):
|
||||
malformed = {"supported": True, "protocol_version": 1, "node_ids": None}
|
||||
malformed = {"supported": True, "protocol_version": 2, "node_ids": None}
|
||||
session = _FakeSession({("worker", "capabilities"): (200, malformed)})
|
||||
|
||||
async def fake_session():
|
||||
@@ -432,7 +432,7 @@ class DispatchSelectionTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_image_list_preflight_fails_closed_for_incompatible_protocol(self):
|
||||
incompatible = _valid_capability()
|
||||
incompatible["protocol_version"] = 2
|
||||
incompatible["protocol_version"] = 1
|
||||
session = _FakeSession({("worker", "capabilities"): (200, incompatible)})
|
||||
|
||||
async def fake_session():
|
||||
|
||||
+76
-34
@@ -9,6 +9,7 @@ from image_list.runtime import (
|
||||
IMAGE_OUT,
|
||||
CONTEXT_OUT,
|
||||
START_CLASS,
|
||||
VALUE_OUT_BASE,
|
||||
ImageListCancelledError,
|
||||
build_worker_prompt,
|
||||
create_master_job,
|
||||
@@ -26,11 +27,7 @@ from image_list.runtime import (
|
||||
|
||||
|
||||
def start_node(image_link):
|
||||
return {"class_type": START_CLASS, "inputs": {
|
||||
"images": image_link,
|
||||
"reference_image_mode": "per_item", "mask_mode": "per_item",
|
||||
"text_mode": "shared", "strength_mode": "shared", "seed_mode": "shared",
|
||||
}}
|
||||
return {"class_type": START_CLASS, "inputs": {"images": image_link}}
|
||||
|
||||
|
||||
def graph_one_region():
|
||||
@@ -39,7 +36,7 @@ def graph_one_region():
|
||||
"2": {"class_type": "EmptyImage", "inputs": {"height": 8, "width": 8, "batch_size": 2}},
|
||||
"3": start_node(["2", 0]),
|
||||
"4": {"class_type": "GenericFrameOperation", "inputs": {
|
||||
"image": ["3", IMAGE_OUT], "model": ["1", 0], "seed": ["3", 5],
|
||||
"image": ["3", IMAGE_OUT], "model": ["1", 0], "seed": ["3", VALUE_OUT_BASE],
|
||||
}},
|
||||
"5": {"class_type": END_CLASS, "inputs": {
|
||||
"images": ["4", 0], "context": ["3", CONTEXT_OUT],
|
||||
@@ -70,53 +67,85 @@ class ImageListNormalizationTests(unittest.TestCase):
|
||||
refs = torch.ones((2, 5, 7, 3), dtype=torch.float16) * 0.333
|
||||
masks = [torch.full((1, 2, 3), 0.125, dtype=torch.float64),
|
||||
torch.full((1, 8, 9), 0.875, dtype=torch.float32)]
|
||||
rows = normalize_image_list_inputs(
|
||||
images, refs, masks, ["first", "second"], [0.2], [37],
|
||||
"per_item", "per_item", "per_item", "shared", "shared",
|
||||
)
|
||||
rows = normalize_image_list_inputs(images, masks, {
|
||||
"value_1": [refs[:1], refs[1:]], "value_2": ["first", "second"],
|
||||
"value_3": [0.2], "value_4": [37],
|
||||
})
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual([tuple(row["image"].shape) for row in rows], [(1, 1, 1, 3), (1, 2, 3, 4)])
|
||||
self.assertEqual([row["image"].dtype for row in rows], [torch.float64, torch.float32])
|
||||
self.assertTrue(torch.equal(rows[0]["reference_image"], refs[:1]))
|
||||
self.assertTrue(torch.equal(rows[0]["values"]["value_1"], refs[:1]))
|
||||
self.assertEqual(rows[1]["mask"].dtype, torch.float32)
|
||||
self.assertEqual([row["index"] for row in rows], [0, 1])
|
||||
self.assertEqual([row["seed"] for row in rows], [37, 37])
|
||||
self.assertEqual([row["strength"] for row in rows], [0.2, 0.2])
|
||||
self.assertEqual([row["values"]["value_2"] for row in rows], ["first", "second"])
|
||||
self.assertEqual([row["values"]["value_3"] for row in rows], [0.2, 0.2])
|
||||
self.assertEqual([row["values"]["value_4"] for row in rows], [37, 37])
|
||||
|
||||
def test_explicit_per_item_and_shared_scalar_contracts(self):
|
||||
def test_one_value_is_shared_and_n_values_pair_in_order(self):
|
||||
image = torch.zeros((3, 2, 4, 3), dtype=torch.float32)
|
||||
rows = normalize_image_list_inputs(image, text="same", strength=0.4, seed=9,
|
||||
text_mode="shared", strength_mode="shared", seed_mode="shared")
|
||||
self.assertEqual([row["text"] for row in rows], ["same"] * 3)
|
||||
self.assertEqual([row["strength"] for row in rows], [0.4] * 3)
|
||||
self.assertEqual([row["seed"] for row in rows], [9] * 3)
|
||||
with self.assertRaisesRegex(ValueError, "expected exactly 3"):
|
||||
normalize_image_list_inputs(image, text=["one", "two"], text_mode="per_item")
|
||||
with self.assertRaisesRegex(ValueError, "shared inputs require exactly one"):
|
||||
normalize_image_list_inputs(image, seed=[1, 2], seed_mode="shared")
|
||||
with self.assertRaisesRegex(ValueError, "expected exactly 3"):
|
||||
normalize_image_list_inputs(image, mask=torch.zeros((2, 2, 4)), mask_mode="per_item")
|
||||
rows = normalize_image_list_inputs(image, values={
|
||||
"value_1": ["same"], "value_2": [0.4], "value_3": [9], "value_4": [True]})
|
||||
self.assertEqual([row["values"]["value_1"] for row in rows], ["same"] * 3)
|
||||
self.assertEqual([row["values"]["value_2"] for row in rows], [0.4] * 3)
|
||||
self.assertEqual([row["values"]["value_3"] for row in rows], [9] * 3)
|
||||
self.assertEqual([row["values"]["value_4"] for row in rows], [True] * 3)
|
||||
rows = normalize_image_list_inputs(image, values={"value_2": [1, 2, 3]})
|
||||
self.assertEqual([row["values"] for row in rows], [{"value_2": 1}, {"value_2": 2}, {"value_2": 3}])
|
||||
with self.assertRaisesRegex(ValueError, "value_1 has 2 values; connect exactly 1.*or 3"):
|
||||
normalize_image_list_inputs(image, values={"value_1": ["one", "two"]})
|
||||
with self.assertRaisesRegex(ValueError, "mask has 2 values"):
|
||||
normalize_image_list_inputs(image, mask=torch.zeros((2, 2, 4)))
|
||||
shared_mask = normalize_image_list_inputs(image, mask=torch.ones((1, 2, 4)))
|
||||
self.assertTrue(all(torch.equal(row["mask"], torch.ones((1, 2, 4))) for row in shared_mask))
|
||||
|
||||
def test_no_implicit_tail_repetition_and_no_empty_primary_batch(self):
|
||||
def test_value_batches_stay_whole_and_only_images_split(self):
|
||||
images = torch.zeros((2, 2, 2, 3), dtype=torch.float32)
|
||||
with self.assertRaisesRegex(ValueError, "expected exactly 2"):
|
||||
normalize_image_list_inputs(images, reference_image=torch.zeros((1, 2, 2, 3)),
|
||||
reference_image_mode="per_item")
|
||||
pair = torch.rand((2, 4, 4, 3)) # e.g. two references for one item
|
||||
rows = normalize_image_list_inputs(images, values={"value_1": [pair]})
|
||||
self.assertTrue(all(torch.equal(row["values"]["value_1"], pair) for row in rows))
|
||||
per_item = [torch.rand((2, 4, 4, 3)), torch.rand((2, 4, 4, 3))]
|
||||
rows = normalize_image_list_inputs(images, values={"value_1": per_item})
|
||||
self.assertTrue(torch.equal(rows[1]["values"]["value_1"], per_item[1]))
|
||||
|
||||
def test_rejects_unsupported_values_and_counts(self):
|
||||
images = torch.zeros((2, 2, 2, 3), dtype=torch.float32)
|
||||
with self.assertRaisesRegex(ValueError, "value_1 has 3 values"):
|
||||
normalize_image_list_inputs(images, values={"value_1": [1, 2, 3]})
|
||||
with self.assertRaisesRegex(TypeError, "unsupported type dict"):
|
||||
normalize_image_list_inputs(images, values={"value_1": [{"samples": 1}]})
|
||||
with self.assertRaisesRegex(TypeError, "floating-point"):
|
||||
normalize_image_list_inputs(images, values={"value_1": [torch.zeros(2, dtype=torch.int64)]})
|
||||
with self.assertRaisesRegex(TypeError, "finite"):
|
||||
normalize_image_list_inputs(images, values={"value_1": [float("nan")]})
|
||||
with self.assertRaisesRegex(ValueError, "Unknown Start value slot"):
|
||||
normalize_image_list_inputs(images, values={"value_11": [1]})
|
||||
with self.assertRaisesRegex(ValueError, "at least one image"):
|
||||
normalize_image_list_inputs(None)
|
||||
# Unconnected Autogrow slots arrive as None and are ignored.
|
||||
rows = normalize_image_list_inputs(images, values={"value_1": [None], "value_2": None})
|
||||
self.assertEqual([row["values"] for row in rows], [{}, {}])
|
||||
|
||||
def test_typed_transport_round_trips_float_images_masks_and_scalar_values(self):
|
||||
item = {"image": torch.arange(24, dtype=torch.float64).reshape(1, 2, 4, 3) / 7,
|
||||
"reference_image": torch.ones((1, 1, 3, 4), dtype=torch.float16),
|
||||
"mask": torch.full((1, 5, 6), 0.375, dtype=torch.float32),
|
||||
"text": "safe text", "strength": 0.625, "seed": 42, "index": 3}
|
||||
"values": {"value_1": torch.ones((2, 1, 3, 4), dtype=torch.float16),
|
||||
"value_2": "safe text", "value_3": 0.625, "value_4": 42,
|
||||
"value_5": False},
|
||||
"index": 3}
|
||||
restored = decode_item(encode_item(item))
|
||||
self.assertTrue(torch.equal(restored["image"], item["image"]))
|
||||
self.assertEqual(restored["image"].dtype, torch.float64)
|
||||
self.assertTrue(torch.equal(restored["reference_image"], item["reference_image"]))
|
||||
self.assertTrue(torch.equal(restored["values"]["value_1"], item["values"]["value_1"]))
|
||||
self.assertEqual(restored["values"]["value_1"].dtype, torch.float16)
|
||||
self.assertTrue(torch.equal(restored["mask"], item["mask"]))
|
||||
self.assertEqual(restored["text"], "safe text")
|
||||
self.assertEqual(restored["seed"], 42)
|
||||
self.assertEqual(restored["values"]["value_2"], "safe text")
|
||||
self.assertEqual(restored["values"]["value_3"], 0.625)
|
||||
self.assertIs(restored["values"]["value_4"], 42)
|
||||
self.assertIs(restored["values"]["value_5"], False)
|
||||
with self.assertRaisesRegex(ValueError, "unknown slot"):
|
||||
decode_item({**encode_item(item), "values": {"strength": {"type": "FLOAT", "value": 1.0}}})
|
||||
with self.assertRaisesRegex(ValueError, "unsupported type"):
|
||||
decode_item({**encode_item(item), "values": {"value_1": {"type": "PATH", "value": "/x"}}})
|
||||
with self.assertRaisesRegex(TypeError, "floating-point tensor"):
|
||||
encode_item({**item, "image": torch.ones((1, 2, 4, 3), dtype=torch.int32)})
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported image-list transport type"):
|
||||
@@ -148,6 +177,19 @@ class ImageListGraphBoundaryTests(unittest.TestCase):
|
||||
self.assertEqual(set(worker), {"1", "3", "4", "5"})
|
||||
self.assertEqual(worker["3"]["inputs"]["worker_item_token"], "token_12345678901234567890123456789012")
|
||||
self.assertNotIn("images", worker["3"]["inputs"])
|
||||
|
||||
def test_worker_prompt_drops_per_item_value_links(self):
|
||||
prompt = graph_one_region()
|
||||
prompt["8"] = {"class_type": "StringList", "inputs": {}}
|
||||
prompt["3"]["inputs"].update({"values.value_1": ["8", 0], "values.value_2": ["8", 0]})
|
||||
import sys, types
|
||||
from unittest.mock import patch
|
||||
registry = {"CheckpointLoaderSimple": type("Loader", (), {"RETURN_TYPES": ("MODEL",)})}
|
||||
with patch.dict(sys.modules, {"nodes": types.SimpleNamespace(NODE_CLASS_MAPPINGS=registry)}):
|
||||
region = validate_image_list_regions(prompt)[0]
|
||||
worker = build_worker_prompt(prompt, region, "token_12345678901234567890123456789012", "ctx", 0, "http://m")
|
||||
self.assertFalse([name for name in worker["3"]["inputs"] if name.startswith("values.")])
|
||||
self.assertNotIn("8", worker)
|
||||
self.assertEqual(worker["4"]["inputs"]["model"], ["1", 0])
|
||||
self.assertNotIn("6", worker)
|
||||
|
||||
|
||||
@@ -154,11 +154,9 @@ class SchedulingBoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
runtime.validate_image_list_regions(prompt)
|
||||
|
||||
def test_shared_scalars_are_validated_before_dispatch(self):
|
||||
for kwargs in ({'text': 4, 'text_mode': 'shared'},
|
||||
{'strength': float('nan'), 'strength_mode': 'shared'},
|
||||
{'seed': True, 'seed_mode': 'shared'}):
|
||||
for value in (float('nan'), object(), torch.zeros(2, dtype=torch.int32)):
|
||||
with self.assertRaises(TypeError):
|
||||
runtime.normalize_image_list_inputs(torch.zeros((1, 2, 2, 3)), **kwargs)
|
||||
runtime.normalize_image_list_inputs(torch.zeros((1, 2, 2, 3)), values={'value_1': [value]})
|
||||
|
||||
def test_three_sequential_regions_can_share_worker_loader(self):
|
||||
prompt = graph_two_sequential_regions()
|
||||
|
||||
@@ -21,7 +21,7 @@ def graph():
|
||||
'start': {'class_type': 'DistributedImageListStart', 'inputs': {'images': ['source', 0]}},
|
||||
'op': {'class_type': 'Operation', 'inputs': {'image': ['start', 0]}},
|
||||
'end': {'class_type': 'DistributedImageListEnd', 'inputs': {
|
||||
'images': ['op', 0], 'context': ['start', 7]}},
|
||||
'images': ['op', 0], 'context': ['start', 3]}},
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ class MasterRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
raise fixture.local_error
|
||||
item = fixture.rt.consume_worker_item(token)
|
||||
fixture.rt.set_master_result(fixture.context, index, token, {
|
||||
'image': item['image'], 'mask': item.get('mask'), 'text': item.get('text')})
|
||||
'image': item['image'], 'mask': item.get('mask'),
|
||||
'text': item['values'].get('value_1')})
|
||||
finally:
|
||||
fixture.local_finished.set()
|
||||
task = asyncio.create_task(execute())
|
||||
@@ -134,7 +135,8 @@ class MasterRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
def create(self, participates=True, count=5):
|
||||
self.items = [{'image': torch.full((1, i + 1, 2, 3), float(i)),
|
||||
'mask': torch.full((1, i + 1, 2), float(i)), 'text': str(i), 'index': i}
|
||||
'mask': torch.full((1, i + 1, 2), float(i)), 'values': {'value_1': str(i)},
|
||||
'index': i}
|
||||
for i in range(count)]
|
||||
self.context = self.rt.create_master_job('start', 'end', graph(), self.items,
|
||||
[w['id'] for w in self.workers], '', master_participates=participates)
|
||||
@@ -162,7 +164,7 @@ class MasterRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
# A remote callback is ready immediately; the local slot is slower.
|
||||
item = self.rt.consume_worker_item(token)
|
||||
self.rt.set_master_result(self.context, index, token, {
|
||||
'image': item['image'], 'mask': item['mask'], 'text': item['text']})
|
||||
'image': item['image'], 'mask': item['mask'], 'text': item['values']['value_1']})
|
||||
return f'remote-{index}'
|
||||
|
||||
async def test_local_only_preserves_order_and_uses_no_http(self):
|
||||
@@ -328,10 +330,12 @@ def real_framework_probe(comfy_root):
|
||||
nodes.NODE_CLASS_MAPPINGS.update(Loader=Loader, Operation=Operation, Expanded=Expanded)
|
||||
prompt = graph()
|
||||
prompt['loader'] = {'class_type': 'Loader', 'inputs': {}}
|
||||
prompt['op']['inputs'].update(model=['loader', 0], mask=['start', 2], text=['start', 3])
|
||||
prompt['op']['inputs'].update(model=['loader', 0], mask=['start', rt.MASK_OUT],
|
||||
text=['start', rt.VALUE_OUT_BASE])
|
||||
prompt['end']['inputs'].update(masks=['op', 1], texts=['op', 2])
|
||||
items = [{'image': torch.full((1, i + 1, 2, 3), float(i)),
|
||||
'mask': torch.full((1, i + 1, 2), float(i)), 'text': str(i), 'index': i} for i in range(3)]
|
||||
'mask': torch.full((1, i + 1, 2), float(i)), 'values': {'value_1': str(i)}, 'index': i}
|
||||
for i in range(3)]
|
||||
region = rt.validate_image_list_regions(prompt)[0]
|
||||
parent_progress = progress.ProgressRegistry('outer', execution.DynamicPrompt({}))
|
||||
parent_progress.nodes['outer'] = {'state': progress.NodeState.Running, 'value': 3, 'max': 7}
|
||||
@@ -364,7 +368,7 @@ def real_framework_probe(comfy_root):
|
||||
data['images'] = [torch.zeros((3, 2, 2, 3))]
|
||||
result = await execution._async_map_node_over_list('outer', 'start', cls, data, cls.FUNCTION, v3_data=v3)
|
||||
result = await execution.resolve_map_node_over_list_results(result)
|
||||
context = result[0].result[7][0]
|
||||
context = result[0].result[rt.CONTEXT_OUT][0]
|
||||
assert rt.get_master_job(context).master_participates is expected
|
||||
rt.remove_master_job(context)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class MasterSelectionTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.prompt = {
|
||||
"1": {"class_type": "EmptyImage", "inputs": {}},
|
||||
"2": {"class_type": "DistributedImageListStart", "inputs": {"images": ["1", 0]}},
|
||||
"3": {"class_type": "DistributedImageListEnd", "inputs": {"images": ["2", 0], "context": ["2", 7]}},
|
||||
"3": {"class_type": "DistributedImageListEnd", "inputs": {"images": ["2", 0], "context": ["2", 3]}},
|
||||
}
|
||||
|
||||
async def queue(self, **kwargs):
|
||||
|
||||
@@ -237,7 +237,7 @@ class RuntimeBoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(json.loads(single)['item']['image']['dtype'], 'float32')
|
||||
self.rt.MAX_REQUEST_BYTES = len(single)
|
||||
self.assertEqual(self.rt.encode_request_payload(payload), single)
|
||||
item['reference_image'] = item['image']
|
||||
item['values'] = {'value_1': item['image']}
|
||||
item['mask'] = torch.zeros((1, 4, 4))
|
||||
payload['item'] = self.rt.encode_item(item)
|
||||
with self.assertRaisesRegex(ValueError, 'complete.*request'):
|
||||
|
||||
Reference in New Issue
Block a user