Files
alisson-anjos-ComfyUI-BFSNodes/tests/test_bfs_h3_native_workflow.py

144 lines
7.7 KiB
Python

import importlib.util
from pathlib import Path
import sys
from types import ModuleType
import pytest
import torch
spec=importlib.util.spec_from_file_location('h3_native_workflow_tested',Path(__file__).resolve().parents[1]/'bfs_h3_native_workflow.py')
w=importlib.util.module_from_spec(spec);spec.loader.exec_module(w)
def test_auto_geometry_uses_actual_video_and_preserves_24fps_timing():
images=torch.arange(150).float()[:,None,None,None].expand(150,96,160,3)/150
video,width,height,length,meta=w.prepare_video(images,30.,1.,.4,.5)
assert (width,height,length)==(160,96,107)
assert video.shape==(107,32,64,3)
assert video[-1,0,0,0].item()==pytest.approx(132/150)
assert meta['source_frames']==150 and meta['grid_tail_frames']==13
assert meta['duration_seconds']==107/24
@pytest.mark.parametrize('shape,expected', [((90,160),(160,64)),((160,90),(64,160))])
def test_portrait_landscape_from_source_not_fixed(shape,expected):
_,a,b,n,_=w.prepare_video(torch.zeros(22,*shape,3),24.)
assert (a,b)==expected and n==22
def test_explicit_frame_cap_and_canvas_budget_are_reported():
video,a,b,n,meta=w.prepare_video(torch.zeros(141,96,160,3),24.,2.,.02,1.,107)
assert a*b<=20000 and n==107 and meta['capped']
assert len(video)==107
@pytest.mark.parametrize('fps',[0,-2,float('nan')])
def test_bad_fps_rejected(fps):
with pytest.raises(ValueError):w.prepare_video(torch.zeros(22,32,32,3),fps)
def test_too_short_clip_fails_instead_of_padding_fake_motion():
with pytest.raises(ValueError,match='at least 5'):w.prepare_video(torch.zeros(4,32,32,3),24.)
def test_frame_sheet_uses_original_pixels_and_sampled_timestamps():
sheet=w.contact_sheet(torch.rand(90,48,64,3),6,30.,73)
assert sheet.shape==(1,416,1280,3) and torch.isfinite(sheet).all()
def test_references_stable_order_and_gaps_fail():
a,b=torch.zeros(1,32,48,3),torch.ones(1,64,32,3)
assert w.collect_references(dict(ref_image_2=b,ref_image_1=a))==(a,b)
with pytest.raises(ValueError,match='consecutive'):w.collect_references(dict(ref_image_1=a,ref_image_3=b))
with pytest.raises(ValueError,match='one image'):w.collect_references(dict(ref_image_1=a.expand(2,-1,-1,-1)))
def test_each_reference_is_inspected_separately_and_returned_for_same_encoder(monkeypatch):
a,b=torch.zeros(1,32,48,3),torch.ones(1,64,32,3);calls=[]
def gen(clip,image,prompt,tokens):
calls.append((image,prompt));return 'red coat' if image is a else 'beach'
monkeypatch.setattr(w,'generate_reference',gen)
refs,inventory,images=w.BFSH3DescribeReferences().describe(object(),'identity 1; setting 2',ref_image_2=b,ref_image_1=a)
assert len(calls)==2 and calls[0][0] is a and calls[1][0] is b
assert '<Picture 1>: red coat' in inventory and '<Picture 2>: beach' in inventory
assert refs[0] is a and refs[1] is b
assert images[0] is a and images[1] is b
assert w.BFSH3DescribeReferences.OUTPUT_IS_LIST == (False, False, True)
def test_template_does_not_recursively_expand_user_braces():
result=w.build_prompt('{request}|{inventory}|{guide_analysis}|{metadata}','keep {inventory}','one','motion','{}')
assert result=='keep {inventory}|one|motion|{}'
def full_prompt():return 'motion_transfer: '+ '\n'.join(s+': N/A' for s in w.SECTIONS)
def test_h3_format_validation_rejects_truncation():
w.validate_prompt(full_prompt())
with pytest.raises(ValueError):w.validate_prompt(full_prompt().split('overall_soundscape')[0])
def test_encoding_calls_native_ref_video_and_all_n_refs_without_add_guide(monkeypatch):
module=ModuleType('comfy_extras.nodes_minimax_h3');seen={}
class Native:
@classmethod
def execute(cls,**kwargs):seen.update(kwargs);return ('positive','latent')
module.MiniMaxH3ReferenceToVideo=Native
package=ModuleType('comfy_extras');package.__path__=[]
monkeypatch.setitem(sys.modules,'comfy_extras',package)
monkeypatch.setitem(sys.modules,'comfy_extras.nodes_minimax_h3',module)
refs=tuple(torch.zeros(1,32,32,3) for _ in range(3));guide=torch.zeros(22,32,32,3)
result=w.BFSH3NativeReferenceSetToVideo().encode(object(),object(),guide,refs,full_prompt(),64,32,22)
assert result[:2]==('positive','latent')
assert seen['ref_videos']['ref_video_1'] is guide
assert list(seen['ref_images'])==['ref_image_1','ref_image_2','ref_image_3']
with pytest.raises(ValueError,match='unattached'):
w.BFSH3NativeReferenceSetToVideo().encode(None,None,guide,refs,full_prompt()+' <Picture 4>',64,32,22)
def test_generated_reference_uses_native_textgenerate_and_old_mtp_signature(monkeypatch):
module=ModuleType('comfy_extras.nodes_textgen');calls=[]
class Old:
@classmethod
def execute(cls,clip,image,prompt,max_length,sampling_mode,thinking,use_default_template):
calls.append((image,thinking));return ('Answer: description','')
module.TextGenerate=Old
package=ModuleType('comfy_extras');package.__path__=[]
monkeypatch.setitem(sys.modules,'comfy_extras',package);monkeypatch.setitem(sys.modules,'comfy_extras.nodes_textgen',module)
image=torch.zeros(1,32,32,3)
assert w.generate_reference(None,image,'test',128)=='description'
assert calls[0][0] is image and calls[0][1] is False
def test_pad_last_frame_keeps_full_source_duration_for_decoded_trim():
images=torch.arange(24).float()[:,None,None,None].expand(24,32,32,3)/24
result=w.BFSH3PrepareNativeVideo().prepare(images,source_fps=24.,request='swap')
video,_,_,_,length,metadata,_,_,trim=result
assert length==39 and trim==24
assert torch.equal(video[-1],images[-1])
import json
assert json.loads(metadata)['padded_tail_frames']==15
def test_one_image_request_for_picture_two_fails_before_any_vlm_call(monkeypatch):
def must_not_run(*args):raise AssertionError('VLM must not run for a missing requested reference')
monkeypatch.setattr(w,'generate_reference',must_not_run)
with pytest.raises(ValueError,match=r'User request.*\[2\].*1 image'):
w.BFSH3DescribeReferences().describe(None,'Replace background with <Picture 2>',ref_image_1=torch.zeros(1,32,32,3))
def test_exact_reference_set_and_target_audit_apply_to_saved_old_templates():
node=w.BFSH3BuildNativePrompt()
result=node.build('Replace performer with <Picture 1>','Attached references: 1 (Picture 1 through Picture 1).\n\n<Picture 1>: beige shirt and jeans','Source wears a hoodie','{}',template='OLD TEMPLATE {request} {inventory} {guide_analysis}')[0]
assert 'EXACT ATTACHED REFERENCE SET: <Picture 1>.' in result
assert 'SOURCE, not the target appearance' in result
assert 'do not copy source hoodie/pants' in result
with pytest.raises(ValueError,match='User request references unattached'):
node.build('Use @Picture2','Attached references: 1','Source','{}')
def test_reference_validation_handles_native_and_editor_tags_without_renumbering():
w.validate_reference_indices('Use <Picture 1>, <Picture 2> and @Picture2',2,'Request')
with pytest.raises(ValueError,match=r'\[3\]'):
w.validate_reference_indices('Use <picture3>',2,'Request')
def test_head_swap_preserves_source_body_even_with_old_template():
node=w.BFSH3BuildNativePrompt()
assert node.INPUT_TYPES()['required']['mode'][0] == ['motion_transfer','body_swap','head_swap']
result=node.build('Use <Picture 1> for the head','Attached references: 1','Source body wears a hoodie','{}',template='OLD body swap template {request}',mode='head_swap')[0]
assert 'Begin exactly with head_swap:' in result
assert 'overrides any full-body replacement' in result
assert 'Preserve each guide performer' in result and 'wardrobe' in result
assert 'visible mouth articulation' in result
w.validate_prompt(full_prompt().replace('motion_transfer:', 'head_swap:'))