feat: Implement lazy evaluation Cycle 0 architecture

- Introduced global state memory (`global_source_frame_count`, `global_target_frames`, `global_stride`) in `loop.py`.
- Removed visual `source_frame_count` wire from `AutoLoopCalculator` in `video.py`
- Upgraded `AutoLoopCalculator` to execute as a blind "Explorer" in Cycle 0.
- Updated `LoadVideoWithSourceAudio` to intercept real `source_frame_count` directly from VHS payloads.
- Added dynamic calculation of total cycles based on intercepted frames at the end of Cycle 0.
This commit is contained in:
Meisoftcoltd
2026-03-29 16:08:21 +00:00
parent 18f5041640
commit bb703951d5
8 changed files with 57 additions and 98 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -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
-73
View File
@@ -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()
+52 -25
View File
@@ -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 ""