Files
robertvoy-ComfyUI-Distributed/tests/test_media_bridge_nodes.py
T

199 lines
11 KiB
Python

"""Real ComfyUI V3 bridge mapping, file ownership and exact CPU round trips."""
import os
from pathlib import Path
import subprocess
import sys
import pytest
ROOT = Path(__file__).resolve().parents[1]
def test_real_media_bridge_nodes():
core = os.environ.get("COMFYUI_SOURCE_ROOT")
if core is None:
pytest.skip("Set COMFYUI_SOURCE_ROOT for CPU media bridge acceptance")
assert core and (Path(core) / "comfy_api/latest/_input_impl/video_types.py").is_file(), (
"Configured COMFYUI_SOURCE_ROOT must provide native video classes"
)
probe = r'''
import asyncio, importlib.util, io, json, os, sys, tempfile
from fractions import Fraction
import av
import numpy as np
from pathlib import Path
from unittest.mock import patch
import torch
sys.path.insert(0, sys.argv[1])
import comfy.cli_args
comfy.cli_args.args.cpu = True
comfy.cli_args.args.disable_assets = True
def forbidden(*args, **kwargs):
raise AssertionError('Bridge tests must not query or initialize a GPU')
torch.cuda.is_available = lambda: False
torch.cuda.device_count = lambda: 0
for name in ('init', '_lazy_init', 'current_device'):
setattr(torch.cuda, name, forbidden)
import execution
import folder_paths
from comfy_api.latest._input_impl.video_types import VideoFromComponents, VideoFromFile, VideoFromList
from comfy_api.latest._util import VideoComponents
for name in ('is_available', 'device_count'):
setattr(torch.cuda, name, forbidden)
spec = importlib.util.spec_from_file_location('typed_bridge_probe', Path(sys.argv[2])/'serverless_bridge/__init__.py', submodule_search_locations=[str(Path(sys.argv[2])/'serverless_bridge')])
bridge = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = bridge
spec.loader.exec_module(bridge)
from typed_bridge_probe.nodes import MediaImportV1, MediaExportV1, export_values, import_value
from typed_bridge_probe.contract import SOCKET_KINDS
from typed_bridge_probe.video_codec import encode_video
def tiny_video():
buffer = io.BytesIO()
with av.open(buffer, 'w', format='matroska') as container:
stream = container.add_stream('ffv1', rate=Fraction(24, 1))
stream.width = stream.height = 32
stream.pix_fmt = 'yuv420p'
for level in (16, 64, 128):
frame = av.VideoFrame.from_ndarray(np.full((32, 32, 3), level, dtype=np.uint8), format='rgb24')
for packet in stream.encode(frame): container.mux(packet)
for packet in stream.encode(): container.mux(packet)
return buffer.getvalue()
def exact_tensor(actual, expected):
assert actual.device.type == 'cpu' and actual.dtype == expected.dtype
assert actual.shape == expected.shape
assert torch.equal(actual.contiguous().view(torch.uint8), expected.contiguous().view(torch.uint8))
def exact_video(actual, expected, wire):
assert type(actual) is type(expected)
assert encode_video(actual) == wire
if type(actual) is VideoFromComponents:
a = vars(actual)['_VideoFromComponents__components']
e = vars(expected)['_VideoFromComponents__components']
assert type(a) is VideoComponents and type(a.frame_rate) is Fraction
assert a.frame_rate == e.frame_rate and a.metadata == e.metadata
assert a.metadata is not e.metadata
for name in ('images', 'alpha'): exact_tensor(getattr(a, name), getattr(e, name))
exact_tensor(a.audio['waveform'], e.audio['waveform'])
assert a.audio['sample_rate'] == e.audio['sample_rate']
assert actual.get_bit_depth() == 10 and actual.get_color_space() == 'HDR PQ'
else:
values = actual.videos if type(actual) is VideoFromList else [actual]
for value in values:
buffer = vars(value)['_VideoFromFile__file']
assert type(buffer) is io.BytesIO and not buffer.closed
buffer.seek(0)
with av.open(buffer) as container:
assert len(list(container.decode(video=0))) == 3
if type(actual) is VideoFromList:
exact_tensor(actual.complete_audio['waveform'], expected.complete_audio['waveform'])
async def check():
extension = await bridge.comfy_entrypoint()
assert await extension.get_node_list() == [MediaImportV1, MediaExportV1]
imp = MediaImportV1.GET_NODE_INFO_V1()
exp = MediaExportV1.GET_NODE_INFO_V1()
assert imp['output'] == ['IMAGE','AUDIO','MASK','STRING','VIDEO','*'], imp
assert exp['output_node'] and MediaExportV1.define_schema().is_input_list, exp
with tempfile.TemporaryDirectory(prefix='typed-media-', dir=os.environ.get('TMPDIR')) as scratch:
base = Path(scratch)
roots = {key: base/key for key in ('input','output','temp')}
for path in roots.values(): path.mkdir()
def annotated(name):
for key, root in roots.items():
suffix = f' [{key}]'
if name.endswith(suffix): return str(root/name[:-len(suffix)])
return str(roots['input']/name)
with patch.object(folder_paths,'get_input_directory',lambda: str(roots['input'])), patch.object(folder_paths,'get_output_directory',lambda: str(roots['output'])), patch.object(folder_paths,'get_temp_directory',lambda: str(roots['temp'])), patch.object(folder_paths,'get_annotated_filepath',annotated):
examples = {'images': torch.rand(2,2,3,4,dtype=torch.float64),
'masks': torch.tensor([[[0.123456789,0.987654321]]],dtype=torch.float64),
'audio': {'waveform':torch.randn(1,6,31,dtype=torch.float64),'sample_rate':44100},
'texts': ' café\n🎥\r\n'}
media = tiny_video()
sources = [roots['input']/f'source-{n}.mkv' for n in range(3)]
for path in sources: path.write_bytes(media)
images = torch.arange(36, dtype=torch.float64, device='cpu').reshape(2,2,3,3) / 997
images[0,0,0,0] = -0.0
component_video = VideoFromComponents(VideoComponents(
images=images, frame_rate=Fraction(30000,1001),
alpha=torch.full((2,2,3,1),0.123456789,dtype=torch.float64,device='cpu'),
audio=examples['audio'], metadata={'title':'雪', 'nested':[True,None,-4,0.125]}),
bit_depth=10,color_space='HDR PQ')
videos = [component_video,
VideoFromFile(str(sources[0]),start_time=-0.0,duration=0,crop=(0,0,2,2)),
VideoFromList([VideoFromFile(str(sources[1]),start_time=-1.25,duration=-2.5,crop=(-3,5,7,-9)),
VideoFromFile(str(sources[2]),start_time=0,duration=0)],
complete_audio=examples['audio'])]
for socket,value in [*examples.items(), *[('video',v) for v in videos]]:
kind = SOCKET_KINDS[socket]
expected_wire = encode_video(value) if kind == 'VIDEO' else None
binding = dict(binding_id='b-'+socket,collector_id='collector',job_id='job',participant_id='worker',node_id='export-'+socket,socket=socket,kind=kind)
controls = {'request_id':'request','binding_json':json.dumps(binding)}
data, missing, v3 = execution.get_input_data(controls,MediaExportV1,'export-'+socket)
assert not missing, missing
data[socket] = [value]
results = await execution._async_map_node_over_list('probe','export-'+socket,MediaExportV1,data,MediaExportV1.FUNCTION,v3_data=v3)
results = await execution.resolve_map_node_over_list_results(results)
raw = results[0].ui
exported = raw['media_exports'][0]
assert exported['binding']['count'] == 1
artifact = exported['artifacts'][0]
transport = artifact.pop('transport')
filename = transport['subfolder']+'/'+transport['filename']+' [temp]'
decoded = import_value(artifact,filename)
if kind in ('IMAGE','MASK'):
assert decoded.dtype == value.dtype and torch.equal(decoded,value)
elif kind == 'AUDIO':
assert decoded['sample_rate'] == value['sample_rate']
assert torch.equal(decoded['waveform'],value['waveform'])
elif kind == 'VIDEO': exact_video(decoded,value,expected_wire)
else: assert decoded == value
mapped, missing, hidden = execution.get_input_data({'artifact_json':json.dumps(artifact),'filename':filename},MediaImportV1,'import')
got = await execution._async_map_node_over_list('probe','import',MediaImportV1,mapped,MediaImportV1.FUNCTION,v3_data=hidden)
got = await execution.resolve_map_node_over_list_results(got)
assert len(got[0].result) == 6
assert all(x is None for n,x in enumerate(got[0].result) if n != list(SOCKET_KINDS).index(socket))
bad = dict(artifact,sha256='0'*64)
try: import_value(bad,filename)
except ValueError: pass
else: raise AssertionError('checksum accepted')
try: import_value(artifact,str(base/'outside.bin'))
except ValueError: pass
else: raise AssertionError('outside path accepted')
if kind == 'VIDEO':
mapped_video = got[0].result[list(SOCKET_KINDS).index(socket)]
# Remove both the exported artifact and the original input
# containers before checking independently owned BytesIOs.
Path(annotated(filename)).unlink()
originals = value.videos if type(value) is VideoFromList else [value]
for original in originals:
if type(original) is VideoFromFile:
Path(vars(original)['_VideoFromFile__file']).unlink()
exact_video(decoded,value,expected_wire)
exact_video(mapped_video,value,expected_wire)
if type(value) is VideoFromFile:
assert tuple(decoded.get_components().images.shape) == (3,2,2,3)
text_binding = dict(binding_id='empty',collector_id='collector',job_id='job',participant_id='worker',node_id='export-empty',socket='texts',kind='STRING')
out = export_values('request',text_binding,[''])
assert out['artifacts'][0]['byte_length'] == 0
before = set(roots['temp'].rglob('*.bin'))
try: export_values('request',text_binding,['first',None])
except ValueError: pass
else: raise AssertionError('invalid text accepted')
assert set(roots['temp'].rglob('*.bin')) == before
print('MEDIA_BRIDGE_CPU_OK')
asyncio.run(check())
'''
env = dict(os.environ)
env.pop("PYTHONPATH", None)
env.pop("COMFYUI_SOURCE_ROOT", None)
scratch = Path(os.environ.get("TMPDIR", str(Path.home() / ".hermes/cache/scratch")))
scratch.mkdir(parents=True, exist_ok=True)
env.update(CUDA_VISIBLE_DEVICES="", HIP_VISIBLE_DEVICES="", PYTHONDONTWRITEBYTECODE="1",
TMPDIR=str(scratch))
result = subprocess.run([sys.executable, "-c", probe, str(Path(core).resolve()), str(ROOT)],
capture_output=True, text=True, timeout=90, env=env)
assert result.returncode == 0, result.stdout + result.stderr
assert "MEDIA_BRIDGE_CPU_OK" in result.stdout