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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user