Merge pull request #141 from Meisoftcoltd/jules-6404593657163714911-723f8952
feat(tools): add robust Disk I/O nodes and native LTXV single frame injector
This commit is contained in:
@@ -44,7 +44,11 @@ NODE_DISPLAY_NAME_MAPPINGS.update({
|
||||
"ResTool64x": "🎬 ResTool 64x (Hunyuan)",
|
||||
"ResTool64xLTX": "🌌 ResTool 64x (LTX 2.3)",
|
||||
"ConditionalAudioRouter": "🎛️ Conditional Audio Router (Bypass)",
|
||||
|
||||
"PreciseAudioSlicer": "✂️ Precise Audio Slicer",
|
||||
"SaveSceneKeyframe": "💾 Save Scene Keyframe",
|
||||
"LoadSceneKeyframe": "🖼️ Load Scene Keyframe",
|
||||
"LTXVSingleFrameInjector": "💉 LTXV Single Frame Injector",
|
||||
})
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -450,3 +450,132 @@ class PreciseAudioSlicer:
|
||||
_log(f"{'='*50}\n")
|
||||
|
||||
return ({"waveform": sliced_waveform, "sample_rate": sample_rate}, "\n".join(log_output))
|
||||
|
||||
@register_node
|
||||
class SaveSceneKeyframe:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"file_path": ("STRING", {"forceInput": True, "tooltip": "Ruta desde el Scene Director"})
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("file_path",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "save"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def save(self, image, file_path):
|
||||
import os
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
img_array = 255. * image[0].cpu().numpy()
|
||||
img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8))
|
||||
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
img.save(file_path)
|
||||
print(f"💾 [Keyframe Saver] Guardado con éxito en: {file_path}")
|
||||
return (file_path,)
|
||||
|
||||
@register_node
|
||||
class LoadSceneKeyframe:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"file_path": ("STRING", {"forceInput": True})
|
||||
}
|
||||
}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("IMAGE",)
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def load(self, file_path):
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"⚠️ [Keyframe Loader] Archivo no encontrado: {file_path}. Generando tensor negro de seguridad.")
|
||||
return (torch.zeros((1, 512, 512, 3), dtype=torch.float32),)
|
||||
|
||||
img = Image.open(file_path)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = img.convert("RGB")
|
||||
img_tensor = torch.from_numpy(np.array(img).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
print(f"📥 [Keyframe Loader] Imagen cargada desde: {file_path}")
|
||||
return (img_tensor,)
|
||||
|
||||
@register_node
|
||||
class LTXVSingleFrameInjector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"vae": ("VAE",),
|
||||
"latent": ("LATENT",),
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT",)
|
||||
RETURN_NAMES = ("latent",)
|
||||
FUNCTION = "inject"
|
||||
CATEGORY = "🔁 Sequential Batcher/Tools"
|
||||
|
||||
def inject(self, vae, latent, image):
|
||||
import torch
|
||||
import comfy.utils
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"💉 [Secuencial Batcher] NODO: LTXV Single Frame Injector")
|
||||
|
||||
samples = latent["samples"].clone()
|
||||
|
||||
# Extraer factores de escala del VAE (Típicamente 8x32x32 para LTXV)
|
||||
scale_factors = getattr(vae, "downscale_index_formula", (8, 32, 32))
|
||||
height_scale_factor = scale_factors[1]
|
||||
width_scale_factor = scale_factors[2]
|
||||
|
||||
batch, channels, latent_frames, latent_height, latent_width = samples.shape
|
||||
width = latent_width * width_scale_factor
|
||||
height = latent_height * height_scale_factor
|
||||
|
||||
if "noise_mask" in latent:
|
||||
mask = latent["noise_mask"].clone()
|
||||
else:
|
||||
mask = torch.ones((batch, 1, latent_frames, 1, 1), dtype=torch.float32, device=samples.device)
|
||||
|
||||
print(f" -> 📐 Adaptando resolución de la imagen ({image.shape[2]}x{image.shape[1]}) a la del Latent ({width}x{height})")
|
||||
|
||||
# Ajustar resolución de la imagen si no coincide con el latent
|
||||
if image.shape[1] != height or image.shape[2] != width:
|
||||
pixels = comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "bilinear", "center").movedim(1, -1)
|
||||
else:
|
||||
pixels = image
|
||||
|
||||
encode_pixels = pixels[:, :, :, :3]
|
||||
|
||||
# Codificar imagen usando el VAE de LTX
|
||||
t = vae.encode(encode_pixels)
|
||||
|
||||
# Compatibilidad 4D a 5D (Si el VAE devuelve [B, C, H, W] lo pasamos a [B, C, T, H, W])
|
||||
if len(t.shape) == 4:
|
||||
t = t.unsqueeze(2)
|
||||
|
||||
latent_idx = 0
|
||||
end_index = min(latent_idx + t.shape[2], latent_frames)
|
||||
|
||||
# Inyección directa
|
||||
samples[:, :, latent_idx:end_index] = t[:, :, :end_index - latent_idx]
|
||||
mask[:, :, latent_idx:end_index] = 0.0 # 0.0 significa "Proteger este frame del ruido"
|
||||
|
||||
print(f" -> ✅ Imagen inyectada y sellada en el Frame 0 (Índice Latente {latent_idx}).")
|
||||
print(f"{'='*50}\n")
|
||||
|
||||
return ({"samples": samples, "noise_mask": mask},)
|
||||
|
||||
Reference in New Issue
Block a user