Files

252 lines
13 KiB
Python

"""Direct LTX video-to-video, run for real on CPU: a multi-frame mp4 in the media library, a pipeline saved with the old
first-frame wiring, the per-run values the app sets, the graph FunUI builds from them, then the nodes that make the
latent run in order (Load Media, size, VAE encode, empty sound, concat) and the sampler on a real tiny LTX model.
Only the two VAEs are stand-ins (their weights are not in the repo): they have the real shapes (8x time, 32x space)."""
import json
import pytest
import torch
@pytest.fixture
def clip_id(comfyui, tmp_path, monkeypatch):
import av
from core import config, media
monkeypatch.setattr(config, "MEDIA_DIR", tmp_path / "media")
path = tmp_path / "clip.mp4"
with av.open(str(path), "w") as out:
stream = out.add_stream("libx264", rate=25)
stream.width, stream.height, stream.pix_fmt = 128, 64, "yuv420p"
for i in range(41):
frame = av.VideoFrame.from_ndarray(torch.full((64, 128, 3), i * 6, dtype=torch.uint8).numpy(), format="rgb24")
for packet in stream.encode(frame):
out.mux(packet)
for packet in stream.encode():
out.mux(packet)
return media.save_upload("clip.mp4", path.read_bytes())["id"]
class StubVae:
"""The LTX video VAE's shape contract: 8x in time (first frame alone), 32x in space, 128 channels."""
def encode(self, pixels):
b, f, h, w, _ = pixels.shape if pixels.ndim == 5 else (1, *pixels.shape)
return torch.randn(1, 128, (f - 1) // 8 + 1, h // 32, w // 32)
class StubAudioVae:
latent_channels = 8
class first_stage_model:
latent_frequency_bins = 16
@staticmethod
def num_of_latents_from_frames(frames, rate):
return max(1, round(frames / rate * 25))
@pytest.fixture
def real_tiny_ltx(comfyui):
"""ComfyUI's own model_base.LTXAV at a few thousand parameters: the real sampling object and packing."""
import comfy.model_patcher as mp
import comfy.supported_models as sm
torch.manual_seed(0)
config = sm.LTXAV(dict(
image_model="ltxav", in_channels=128, audio_in_channels=128, cross_attention_dim=32, audio_cross_attention_dim=16,
attention_head_dim=16, audio_attention_head_dim=8, num_attention_heads=2, audio_num_attention_heads=2,
caption_channels=48, num_layers=2, caption_proj_before_connector=True))
config.set_inference_dtype(torch.float32, None)
base = config.get_model({}, device=torch.device("cpu"))
for p in base.diffusion_model.parameters():
torch.nn.init.normal_(p, std=0.05)
p.requires_grad_(False)
return mp.ModelPatcher(base, load_device=torch.device("cpu"), offload_device=torch.device("cpu"))
def _run(prompt, stubs):
"""Run the graph's nodes in order. Loaders are replaced by `stubs` {slot id: outputs tuple}."""
import nodes as comfy_nodes
done = {}
def value(v):
return done[v[0]][v[1]] if isinstance(v, list) and len(v) == 2 and isinstance(v[0], str) and v[0] in done else v
def run(node_id):
if node_id in done:
return
spec = prompt[node_id]
for v in spec["inputs"].values():
if isinstance(v, list) and len(v) == 2 and v[0] in prompt:
run(v[0])
if node_id in stubs:
done[node_id] = stubs[node_id]
return
cls = comfy_nodes.NODE_CLASS_MAPPINGS[spec["class_type"]]
kwargs = {k: value(v) for k, v in spec["inputs"].items()}
if hasattr(cls, "GET_SCHEMA"):
out = cls.EXECUTE_NORMALIZED(**kwargs)
done[node_id] = tuple(out.result)
else:
done[node_id] = getattr(cls(), cls.FUNCTION)(**kwargs)
return run, done
def test_direct_video_to_video_end_to_end(clip_id, real_tiny_ltx, monkeypatch):
import asyncio
from pathlib import Path
import nodes as comfy_nodes
from core import graph
from modules.models.ltx.pipeline import presets, upgrade
async def load():
await comfy_nodes.init_extra_nodes(init_custom_nodes=False)
await comfy_nodes.load_custom_node(str(Path(__file__).resolve().parents[4]), module_parent="custom_nodes")
asyncio.run(load())
import types
monkeypatch.setattr(comfy_nodes.NODE_CLASS_MAPPINGS["GetImageSize"], "hidden", types.SimpleNamespace(unique_id=None), raising=False) # no executor here
slots = next(p["slots"] for p in presets() if p["id"] == "ltx23_video_to_video_direct")
# saved by the build that wired the first-frame output: brought up to date as a run does
old = [dict(s, inputs={k: (["source_image", 0] if v == ["source_image", 1] else v) for k, v in s["inputs"].items()}) for s in slots]
# what the old wiring did, which is what a run showed as "1 frames": the whole-video flag cannot reach output 0
old_slots, _ = graph.override(old, {"source_image": {"media_id": clip_id, "full": True}})
old_prompt, _ = graph.build(old_slots)
run_old, done_old = _run(old_prompt, {"vae": (StubVae(), ""), "audio_vae": (StubAudioVae(), "")})
run_old("size")
assert done_old["size"][2] == 1
slots = upgrade(old)
prepare = json.dumps({"skip_first": 1, "cut_last": 0, "every_nth": 1, "multiple_of": 32, "fps": 25})
slots, problems = graph.override(slots, {"source_image": {"media_id": clip_id, "full": True, "prepare": prepare},
"positive": {"text": "a red fox runs"}})
assert problems == []
prompt, problems = graph.build(slots)
unset = ("needs 'ckpt_name'", "needs 'model_name'", "needs 'vae_name'", "needs 'clip_name1'", "is '', which is not one of")
assert [p for p in problems if not any(u in p for u in unset)] == []
# the typed prompt is what the sampler's positive input comes from, through the conditioning node and nothing else
node = prompt["sampler"]["inputs"]["positive"]
while prompt[node[0]]["class_type"] != "CLIPTextEncode":
node = next(v for v in prompt[node[0]]["inputs"].values() if isinstance(v, list) and prompt[v[0]]["class_type"] in ("LTXVConditioning", "CLIPTextEncode"))
assert prompt[node[0]]["inputs"]["text"] == "a red fox runs"
assert prompt["sampler"]["inputs"]["latent"][0] == "latent" and prompt["sampler"]["inputs"]["denoise"] == 1.0
vae, audio_vae = StubVae(), StubAudioVae()
run, done = _run(prompt, {"model": (real_tiny_ltx, ""), "vae": (vae, ""), "audio_vae": (audio_vae, ""), "clip": (None, "")})
run("latent")
frames = done["source_image"][1]
assert frames.shape[0] == 40 and frames.shape[1:3] == (64, 128) # the whole video, first frame skipped
assert done["size"][2] == 40 # the sound is sized from the real count
video, audio = done["latent"][0]["samples"].unbind()
assert video.shape == (1, 128, 5, 2, 4) # 40 frames -> 1 + (40-1)//8 latent frames
assert audio.shape[-1] == 16
# the sampler runs on it from part-way down the schedule, with the real tiny model
ctx = torch.randn(1, 5, 48)
from modules.sampling.sampler.nodes import FunUISampler
run("sigmas")
sig = done["sigmas"][0]
assert [round(float(x), 3) for x in sig] == [0.909, 0.725, 0.422, 0.0] # the tail of the distilled schedule
start = done["latent"][0]
out = FunUISampler.execute(real_tiny_ltx, [[ctx, {}]], [[ctx, {}]], start, 1, 2, 1.0, "euler", "normal", sigmas=sig).result[0]
# the prompt reaches the sampler: a different text embedding, same seed and latent, gives a different video AND sound
other = FunUISampler.execute(real_tiny_ltx, [[torch.randn(1, 5, 48), {}]], [[ctx, {}]], start, 1, 2, 1.0, "euler", "normal", sigmas=sig).result[0]
assert not torch.allclose(other["samples"].unbind()[0], out["samples"].unbind()[0])
assert not torch.allclose(other["samples"].unbind()[1], out["samples"].unbind()[1])
got_video, got_audio = out["samples"].unbind()
assert got_video.shape == video.shape and got_audio.shape == audio.shape
assert torch.isfinite(got_video).all() and not torch.allclose(got_video, video) # it was denoised, not passed through
def test_direct_video_to_video_with_no_video_runs_the_whole_schedule_over_a_grey_clip(comfyui, real_tiny_ltx, monkeypatch):
"""What the app sends when nothing is fed in (text to video): a stand-in clip of the project's size and the whole schedule."""
import asyncio
import types
from pathlib import Path
import nodes as comfy_nodes
from core import graph
from modules.models.ltx.pipeline import DISTILLED_SIGMAS, presets
async def load():
await comfy_nodes.init_extra_nodes(init_custom_nodes=False)
await comfy_nodes.load_custom_node(str(Path(__file__).resolve().parents[4]), module_parent="custom_nodes")
asyncio.run(load())
monkeypatch.setattr(comfy_nodes.NODE_CLASS_MAPPINGS["GetImageSize"], "hidden", types.SimpleNamespace(unique_id=None), raising=False)
slots = next(p["slots"] for p in presets() if p["id"] == "ltx23_video_to_video_direct")
slots, problems = graph.override(slots, {"source_image": {"media_id": "", "blank_width": 128, "blank_height": 64, "blank_frames": 17},
"sigmas": {"sigmas": DISTILLED_SIGMAS}})
assert problems == []
prompt, _ = graph.build(slots)
run, done = _run(prompt, {"model": (real_tiny_ltx, ""), "vae": (StubVae(), ""), "audio_vae": (StubAudioVae(), ""), "clip": (None, "")})
run("latent")
assert done["source_image"][1].shape == (17, 64, 128, 3) and done["size"][2] == 17
video, audio = done["latent"][0]["samples"].unbind()
assert video.shape == (1, 128, 3, 2, 4) # 17 frames -> 1 + 16//8 latent frames
run("sigmas")
assert round(float(done["sigmas"][0][0]), 3) == 1.0 and len(done["sigmas"][0]) == 9
def test_image_to_video_takes_its_shape_from_the_aspect_source_picture(comfyui, real_tiny_ltx, tmp_path, monkeypatch):
"""A portrait picture on the timeline makes a portrait latent at the project's pixel budget; with none the project's own shape."""
import asyncio
import io as pyio
from pathlib import Path
import nodes as comfy_nodes
from PIL import Image
from core import config, graph, media
from modules.models.ltx.pipeline import presets
async def load():
await comfy_nodes.init_extra_nodes(init_custom_nodes=False)
await comfy_nodes.load_custom_node(str(Path(__file__).resolve().parents[4]), module_parent="custom_nodes")
asyncio.run(load())
monkeypatch.setattr(config, "MEDIA_DIR", tmp_path / "media")
buf = pyio.BytesIO()
Image.new("RGB", (300, 600), (255, 0, 0)).save(buf, format="PNG")
portrait = media.save_upload("tall.png", buf.getvalue())["id"]
slots = next(p["slots"] for p in presets() if p["id"] == "ltx23_image_to_video_references")
def latent_shape(edits):
s, problems = graph.override(slots, edits)
assert problems == []
prompt, _ = graph.build(s)
run, done = _run(prompt, {"model": (real_tiny_ltx, ""), "vae": (StubVae(), ""), "audio_vae": (StubAudioVae(), ""), "clip": (None, "")})
run("latent")
return done["aspect"], done["latent"][0]["samples"].unbind()[0].shape
sized, shape = latent_shape({"aspect_source": {"media_id": portrait}, "aspect": {"width": 256, "height": 256}, "latent": {"length": 9}})
assert sized[0] < sized[1] and (sized[0] % 32, sized[1] % 32) == (0, 0) # a portrait budget of 256 x 256 pixels
assert shape[-2:] == (sized[1] // 32, sized[0] // 32) # the latent follows it
sized, _ = latent_shape({"aspect_source": {"media_id": "", "blank_width": 256, "blank_height": 128}, "aspect": {"width": 256, "height": 128}, "latent": {"length": 9}})
assert sized == (256, 128) # no picture: the project's own shape
def test_image_to_video_with_nothing_fed_in_builds_its_latent_and_samples(comfyui, real_tiny_ltx, monkeypatch):
"""What the app sends with no reference picture: stand-in pictures, the anchor bypassed. The whole chain up to the sampler runs."""
import asyncio
from pathlib import Path
import nodes as comfy_nodes
from core import graph
from modules.models.ltx.pipeline import presets
async def load():
await comfy_nodes.init_extra_nodes(init_custom_nodes=False)
await comfy_nodes.load_custom_node(str(Path(__file__).resolve().parents[4]), module_parent="custom_nodes")
asyncio.run(load())
slots = next(p["slots"] for p in presets() if p["id"] == "ltx23_image_to_video_references")
edits = {"aspect_source": {"media_id": "", "blank_width": 256, "blank_height": 128},
"aspect": {"width": 256, "height": 128}, "latent": {"length": 17},
"source_image": {"media_id": "", "blank_width": 256, "blank_height": 128},
"anchor": {"bypass": True}}
slots, problems = graph.override(slots, edits)
assert problems == []
prompt, _ = graph.build(slots)
cond = [[torch.randn(1, 5, 48), {}]]
run, done = _run(prompt, {"model": (real_tiny_ltx, ""), "vae": (StubVae(), ""), "audio_vae": (StubAudioVae(), ""),
"clip": (None, ""), "positive": (cond, ""), "negative": (cond, "")})
run("join")
video, audio = done["join"][0]["samples"].unbind()
assert video.shape[-2:] == (4, 8)