diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8ddf9de Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/image.cpython-312.pyc b/__pycache__/image.cpython-312.pyc new file mode 100644 index 0000000..0eec312 Binary files /dev/null and b/__pycache__/image.cpython-312.pyc differ diff --git a/__pycache__/loop.cpython-312.pyc b/__pycache__/loop.cpython-312.pyc new file mode 100644 index 0000000..825b82b Binary files /dev/null and b/__pycache__/loop.cpython-312.pyc differ diff --git a/__pycache__/video.cpython-312.pyc b/__pycache__/video.cpython-312.pyc new file mode 100644 index 0000000..788cafc Binary files /dev/null and b/__pycache__/video.cpython-312.pyc differ diff --git a/loop.py b/loop.py index 8e2b53d..efd8f37 100644 --- a/loop.py +++ b/loop.py @@ -6,6 +6,9 @@ from . import register_node global_loop_index = 0 global_total_loops = 1 # 🌍 NUEVA MEMORIA GLOBAL PARA LOS CICLOS +global_source_frame_count = 0 # NUEVO: Memoria del total de frames +global_target_frames = 50 # NUEVO: Memoria del target del usuario +global_stride = 1 # NUEVO: Memoria del salto @register_node class SequentialLoopStart: @@ -30,6 +33,7 @@ class SequentialLoopStart: def get_index(self, reset_loop, loop_idx): global global_loop_index global global_total_loops + global global_source_frame_count print(f"\n{'='*50}") print(f"🚀 [DEBUG] NODO: Loop Start") @@ -40,6 +44,7 @@ class SequentialLoopStart: if is_reset: global_loop_index = 0 global_total_loops = 1 # Reinicio de seguridad limpia la memoria fantasma + global_source_frame_count = 0 # Reinicio limpio print(" -> 🔄 Bucle reiniciado a 0 manualmente.") else: global_loop_index = loop_idx diff --git a/tests/__pycache__/test_batch.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_batch.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..f534887 Binary files /dev/null and b/tests/__pycache__/test_batch.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_batch.py b/tests/test_batch.py deleted file mode 100644 index 0c3acd8..0000000 --- a/tests/test_batch.py +++ /dev/null @@ -1,73 +0,0 @@ -import unittest -import sys -import os -import importlib.util -from unittest.mock import MagicMock - -# --- Import setup (same as benchmark) --- -mock_folder_paths = MagicMock() -mock_folder_paths.folder_names_and_paths = {"checkpoint": ([], {})} -sys.modules['folder_paths'] = mock_folder_paths -sys.modules['torch'] = MagicMock() -sys.modules['numpy'] = MagicMock() -sys.modules['PIL'] = MagicMock() -sys.modules['PIL.Image'] = MagicMock() -sys.modules['PIL.ImageOps'] = MagicMock() -sys.modules['PIL.ImageSequence'] = MagicMock() -sys.modules['PIL.ImageFile'] = MagicMock() - -repo_root = os.getcwd() -spec = importlib.util.spec_from_file_location("comfy_nodes", os.path.join(repo_root, "__init__.py")) -comfy_nodes = importlib.util.module_from_spec(spec) -sys.modules["comfy_nodes"] = comfy_nodes -spec.loader.exec_module(comfy_nodes) - -format_batch_as_table = comfy_nodes.batch.format_batch_as_table - -class TestBatchFormat(unittest.TestCase): - def test_basic_batch(self): - batch = [{'a': 1, 'b': 2}, {'a': 3, 'b': 4}] - result = format_batch_as_table(batch) - self.assertIn("| a | b |", result) - self.assertIn("| 1 | 2 |", result) - self.assertIn("| 3 | 4 |", result) - - def test_order_preservation(self): - # Keys should appear in order of first appearance - # Row 1 introduces 'b', 'a' - # Row 2 introduces 'c' - # Expected order: b, a, c - batch = [{'b': 1, 'a': 2}, {'c': 3}] - result = format_batch_as_table(batch) - # Check header line - lines = result.split('\n') - header = lines[0] - # We expect something like "| b | a | c |" - # Spacing might vary depending on value lengths, so we check order in string - self.assertTrue(header.index('b') < header.index('a')) - self.assertTrue(header.index('a') < header.index('c')) - - def test_disjoint_keys(self): - batch = [{'a': 1}, {'b': 2}] - result = format_batch_as_table(batch) - self.assertIn("| a | b |", result) - # Row 1 should have empty for b - # Row 2 should have empty for a - # Note: Current implementation puts empty string for missing keys - - # Verify row 1 has '1' and empty for 'b' - # Since we don't know exact spacing easily, we trust the table formatter logic - # but ensure no crash and content is there. - self.assertIn("1", result) - self.assertIn("2", result) - - def test_empty_batch(self): - result = format_batch_as_table([]) - self.assertEqual(result, "(Empty Batch)") - - def test_empty_rows(self): - result = format_batch_as_table([{}, {}]) - self.assertEqual(result, "(Empty rows)") - -if __name__ == '__main__': - unittest.main() diff --git a/video.py b/video.py index f7e2d82..c8b2414 100644 --- a/video.py +++ b/video.py @@ -12,54 +12,57 @@ class AutoLoopCalculator: def INPUT_TYPES(s): return { "required": { - "source_frame_count": ("INT", {"forceInput": True}), "target_frames_per_loop": ("INT", {"default": 50, "min": 1, "max": 10000}), - "select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}), # NUEVO PARÁMETRO + "select_every_nth": ("INT", {"default": 1, "min": 1, "max": 100}), "current_loop_index": ("INT", {"forceInput": True}), } } - # Ahora devolvemos 3 valores para el cargador de vídeo RETURN_TYPES = ("INT", "INT", "INT") RETURN_NAMES = ("chunk_frames", "skip_frames", "select_every_nth") FUNCTION = "calculate" CATEGORY = "🔁 Sequential Batcher/Video" - def calculate(self, source_frame_count, target_frames_per_loop, select_every_nth, current_loop_index): + def calculate(self, target_frames_per_loop, select_every_nth, current_loop_index): import math from . import loop - if source_frame_count <= 0 or target_frames_per_loop <= 0: - loop.global_total_loops = 1 - return (max(1, source_frame_count), 0, select_every_nth) + # Guardamos la config para que el Cargador la use luego en el Ciclo 0 + loop.global_target_frames = target_frames_per_loop + loop.global_stride = select_every_nth - # 1. Calcular los frames efectivos reales que vamos a procesar - effective_source_frames = math.ceil(source_frame_count / select_every_nth) + source_frames = loop.global_source_frame_count - # 2. La matemática proporcional del usuario SOBRE LOS FRAMES EFECTIVOS - total_loops = math.ceil(effective_source_frames / target_frames_per_loop) - base_frames = effective_source_frames // total_loops - remainder = effective_source_frames % total_loops + # CICLO 0: Disparo a ciegas (El Explorador) + if current_loop_index == 0 or source_frames == 0: + print(f"\n📊 [Auto Calculator] Ciclo 0 (Explorador). Solicitando {target_frames_per_loop} frames a ciegas.") + return (target_frames_per_loop, 0, select_every_nth) - plan = [] - for i in range(total_loops): + # CICLOS > 0: Matemática proporcional para el resto del vídeo + effective_total = math.ceil(source_frames / select_every_nth) + + if effective_total <= target_frames_per_loop: + return (target_frames_per_loop, 0, select_every_nth) + + remaining_effective = effective_total - target_frames_per_loop + remaining_loops = math.ceil(remaining_effective / target_frames_per_loop) + + base_frames = remaining_effective // remaining_loops + remainder = remaining_effective % remaining_loops + + plan = [target_frames_per_loop] # El Ciclo 0 ya se llevó su parte + for i in range(remaining_loops): plan.append(base_frames + (1 if i < remainder else 0)) - safe_index = min(current_loop_index, total_loops - 1) + safe_index = min(current_loop_index, len(plan) - 1) chunk_frames = plan[safe_index] - # 3. Calcular el salto de frames originales - # Sumamos los frames efectivos de ciclos anteriores y los multiplicamos por el salto effective_skip = sum(plan[:safe_index]) skip_frames = effective_skip * select_every_nth - # Guardamos en la memoria fantasma - loop.global_total_loops = total_loops - - print(f"\n📊 [Auto Calculator] Video Original: {source_frame_count} frames | Stride: {select_every_nth}") - print(f" -> Frames efectivos a procesar: {effective_source_frames}") - print(f" -> Planificando {total_loops} ciclos: {plan}") - print(f" -> 🚀 Ciclo {current_loop_index}: Cargando {chunk_frames} frames (Saltando {skip_frames} frames de origen)") + print(f"\n📊 [Auto Calculator] Video Original: {source_frames} frames | Efectivos: {effective_total}") + print(f" -> Plan maestro: {plan}") + print(f" -> 🚀 Ciclo {current_loop_index}: Cargando {chunk_frames} frames (Saltando {skip_frames})") return (chunk_frames, skip_frames, select_every_nth) @@ -109,6 +112,30 @@ class LoadVideoWithSourceAudio: res = list(vhs_output) ui = {} + # 🎯 NUEVO: INTERCEPTACIÓN DEL TOTAL DE FRAMES + video_info_dict = res[3] if len(res) > 3 else {} + source_frame_count = video_info_dict.get("source_frame_count", 0) if isinstance(video_info_dict, dict) else 0 + + if source_frame_count > 0: + from . import loop + import math + loop.global_source_frame_count = source_frame_count + + # Solo fijamos el número total de ciclos durante el Ciclo 0 + if loop.global_loop_index == 0: + target = loop.global_target_frames + stride = loop.global_stride + eff_total = math.ceil(source_frame_count / stride) + + if eff_total <= target: + loop.global_total_loops = 1 + else: + remaining = eff_total - target + rem_loops = math.ceil(remaining / target) + loop.global_total_loops = 1 + rem_loops + + print(f"🎥 [Video Loader] Interceptado: {source_frame_count} frames. Total Loops fijado en: {loop.global_total_loops}") + raw_video = kwargs.get("video") video_name = raw_video[0] if isinstance(raw_video, (list, tuple)) else raw_video video_path = folder_paths.get_annotated_filepath(video_name) if video_name else ""