Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com> Co-authored-by: Matthew Noto <99706358+RandNMR73@users.noreply.github.com> Co-authored-by: XOR-op <17672363+XOR-op@users.noreply.github.com> Co-authored-by: Zhang Peiyuan <42993249+jzhang38@users.noreply.github.com>
406 lines
15 KiB
Python
406 lines
15 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""LTX-2 SR (refine) numerical alignment harness — public vs internal.
|
|
|
|
Runs the LTX-2 stage-2 spatial-refine pipeline with a fixed prompt and
|
|
seed and writes the decoded video frames + intermediate latents to a
|
|
torch save file. Intended to be invoked twice with different
|
|
``PYTHONPATH`` prefixes — once against ``FastVideo-internal`` and once
|
|
against the public ``FastVideo`` package — so a third pass can diff the
|
|
two outputs tensor-by-tensor.
|
|
|
|
Usage::
|
|
|
|
# 1) Internal reference run.
|
|
PYTHONPATH=/home/william5lin/FastVideo-internal \
|
|
python scripts/ltx2_sr_alignment.py --label internal \
|
|
--output /tmp/ltx2_sr_internal.pt
|
|
|
|
# 2) Public run against the new typed API.
|
|
PYTHONPATH=/home/william5lin/FastVideo \
|
|
python scripts/ltx2_sr_alignment.py --label public --use-typed-api \
|
|
--output /tmp/ltx2_sr_public.pt
|
|
|
|
# 3) Diff.
|
|
python scripts/ltx2_sr_alignment.py --diff \
|
|
--reference /tmp/ltx2_sr_internal.pt \
|
|
--candidate /tmp/ltx2_sr_public.pt
|
|
|
|
The harness deliberately matches `examples/inference/basic/basic_ltx2_upscale.py`
|
|
defaults (LTX2-Distilled, 8 base steps, 3 refine steps, 1088x1920, seed=10)
|
|
but skips FP4 and Dreamverse-specific knobs to keep the bf16 path clean.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def _bootstrap_fastvideo_source() -> None:
|
|
"""If FASTVIDEO_FROM is set in the environment, prepend that path to
|
|
sys.path **and** strip any editable-install finders from
|
|
``sys.meta_path`` so the named source wins over a .pth-installed
|
|
sibling. This lets us point the same harness at either
|
|
``../FastVideo`` or ``../FastVideo-internal`` without juggling
|
|
venvs."""
|
|
src = os.environ.get("FASTVIDEO_FROM")
|
|
if not src:
|
|
return
|
|
src = os.path.abspath(src)
|
|
if not os.path.isdir(src):
|
|
raise SystemExit(f"FASTVIDEO_FROM={src!r} is not a directory")
|
|
|
|
# Remove editable-install finders that would otherwise win over
|
|
# PYTHONPATH. uv-installed editables register a custom finder
|
|
# named like ``__editable___fastvideo_0_1_7_finder``.
|
|
sys.meta_path = [
|
|
finder for finder in sys.meta_path
|
|
if "__editable___fastvideo" not in type(finder).__module__
|
|
]
|
|
# Drop pre-resolved fastvideo modules from any earlier import.
|
|
for name in [k for k in sys.modules if k == "fastvideo" or k.startswith("fastvideo.")]:
|
|
sys.modules.pop(name, None)
|
|
# Drop the corresponding .pth-pointed paths from sys.path so the
|
|
# editable repo doesn't shadow the explicit source.
|
|
sys.path = [p for p in sys.path if not p.endswith(".egg-info")]
|
|
sys.path.insert(0, src)
|
|
|
|
|
|
_bootstrap_fastvideo_source()
|
|
|
|
|
|
import numpy as np # noqa: E402 (after bootstrap so torch picks up the right env)
|
|
import torch # noqa: E402
|
|
|
|
# Pinned alignment fixture — matches basic_ltx2_upscale.py upstream.
|
|
PROMPT = (
|
|
"A warm sunny backyard. The camera starts in a tight cinematic close-up "
|
|
"of a woman and a man in their 30s, facing each other with serious "
|
|
"expressions. The woman, emotional and dramatic, says softly, \"That's "
|
|
"it... Dad's lost it. And we've lost Dad.\" The man exhales, slightly "
|
|
"annoyed: \"Stop being so dramatic, Jess.\" A beat. He glances aside, "
|
|
"then mutters defensively, \"He's just having fun.\" The camera slowly "
|
|
"pans right, revealing the grandfather in the garden wearing enormous "
|
|
"butterfly wings, waving his arms in the air like he's trying to take "
|
|
"off. He shouts, \"Wheeeew!\" as he flaps his wings with full commitment. "
|
|
"The woman covers her face, on the verge of tears. The tone is deadpan, "
|
|
"absurd, and quietly tragic.")
|
|
|
|
DEFAULT_MODEL = "FastVideo/LTX2-Distilled-Diffusers"
|
|
DEFAULT_SEED = 10
|
|
DEFAULT_HEIGHT = 1088
|
|
DEFAULT_WIDTH = 1920
|
|
DEFAULT_FRAMES = 121
|
|
DEFAULT_FPS = 24
|
|
DEFAULT_BASE_STEPS = 8
|
|
DEFAULT_REFINE_STEPS = 3
|
|
|
|
|
|
@dataclass
|
|
class RunResult:
|
|
label: str
|
|
fastvideo_repo: str
|
|
output_frames: torch.Tensor
|
|
metadata: dict[str, Any]
|
|
|
|
|
|
def _detect_fastvideo_repo() -> str:
|
|
import fastvideo # noqa: PLC0415
|
|
return os.path.realpath(os.path.dirname(fastvideo.__file__))
|
|
|
|
|
|
def _run_legacy(args: argparse.Namespace) -> RunResult:
|
|
"""Legacy ``VideoGenerator.from_pretrained(**flat_kwargs)`` path —
|
|
used by the internal reference run because the internal API is
|
|
still flat-kwarg-shaped."""
|
|
from fastvideo import VideoGenerator # noqa: PLC0415
|
|
|
|
generator = VideoGenerator.from_pretrained(
|
|
args.model,
|
|
num_gpus=1,
|
|
ltx2_refine_enabled=True,
|
|
ltx2_refine_lora_path="", # disable refine LoRA — distilled needs none
|
|
ltx2_refine_num_inference_steps=args.refine_steps,
|
|
ltx2_refine_guidance_scale=1.0,
|
|
ltx2_refine_add_noise=True,
|
|
dit_cpu_offload=False,
|
|
vae_cpu_offload=False,
|
|
text_encoder_cpu_offload=False,
|
|
pin_cpu_memory=True,
|
|
dit_layerwise_offload=False,
|
|
enable_torch_compile=False,
|
|
ltx2_vae_tiling=False,
|
|
)
|
|
|
|
# Pin the LTX-2-specific sampling knobs explicitly so both
|
|
# internal and public runs use identical denoising mechanics —
|
|
# otherwise the public LTX2_BASE preset's modality/rescale/stg
|
|
# defaults (3.0 / 0.7 / 1.0) would diverge from internal's
|
|
# ForwardBatch defaults (1.0 / 0.0 / 0.0).
|
|
result = generator.generate_video(
|
|
prompt=PROMPT,
|
|
output_path=str(args.output) + ".legacy.mp4",
|
|
fps=args.fps,
|
|
seed=args.seed,
|
|
num_inference_steps=args.base_steps,
|
|
guidance_scale=1.0,
|
|
save_video=False,
|
|
return_frames=True,
|
|
height=args.height,
|
|
width=args.width,
|
|
num_frames=args.frames,
|
|
ltx2_cfg_scale_video=1.0,
|
|
ltx2_cfg_scale_audio=1.0,
|
|
ltx2_modality_scale_video=1.0,
|
|
ltx2_modality_scale_audio=1.0,
|
|
ltx2_rescale_scale=0.0,
|
|
ltx2_stg_scale_video=0.0,
|
|
ltx2_stg_scale_audio=0.0,
|
|
)
|
|
generator.shutdown()
|
|
frames = _result_frames(result)
|
|
return RunResult(
|
|
label=args.label,
|
|
fastvideo_repo=_detect_fastvideo_repo(),
|
|
output_frames=frames,
|
|
metadata={
|
|
"api": "legacy_from_pretrained",
|
|
"prompt": PROMPT,
|
|
"seed": args.seed,
|
|
"height": args.height,
|
|
"width": args.width,
|
|
"num_frames": args.frames,
|
|
"fps": args.fps,
|
|
"base_steps": args.base_steps,
|
|
"refine_steps": args.refine_steps,
|
|
},
|
|
)
|
|
|
|
|
|
def _run_typed(args: argparse.Namespace) -> RunResult:
|
|
"""New typed public API: GeneratorConfig + GenerationRequest with
|
|
pipeline.preset_overrides["refine"] driving the SR path."""
|
|
from fastvideo import VideoGenerator # noqa: PLC0415
|
|
from fastvideo.api import ( # noqa: PLC0415
|
|
ComponentConfig, EngineConfig, GenerationRequest, GeneratorConfig,
|
|
InputConfig, OutputConfig, PipelineSelection, SamplingConfig)
|
|
|
|
config = GeneratorConfig(
|
|
model_path=args.model,
|
|
engine=EngineConfig(num_gpus=1, ),
|
|
pipeline=PipelineSelection(
|
|
components=ComponentConfig(),
|
|
preset_overrides={
|
|
"refine": {
|
|
"enabled": True,
|
|
"add_noise": True,
|
|
"num_inference_steps": args.refine_steps,
|
|
"guidance_scale": 1.0,
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
generator = VideoGenerator.from_pretrained(config=config)
|
|
|
|
# The typed SamplingConfig doesn't expose the LTX-2-specific
|
|
# modality/rescale/stg knobs, so route them through experimental
|
|
# so the legacy pipeline batch builder picks them up. This keeps
|
|
# the typed run's denoising mechanics identical to the legacy
|
|
# run's (and therefore to the internal reference).
|
|
request = GenerationRequest(
|
|
prompt=PROMPT,
|
|
sampling=SamplingConfig(
|
|
num_frames=args.frames,
|
|
height=args.height,
|
|
width=args.width,
|
|
fps=args.fps,
|
|
num_inference_steps=args.base_steps,
|
|
guidance_scale=1.0,
|
|
seed=args.seed,
|
|
),
|
|
inputs=InputConfig(),
|
|
output=OutputConfig(
|
|
save_video=False,
|
|
return_frames=True,
|
|
),
|
|
)
|
|
# Set the LTX-2 sampling overrides on the request via attribute so
|
|
# the legacy SamplingParam translation picks them up alongside the
|
|
# typed fields. (Until the typed SamplingConfig grows these
|
|
# fields, this is the canonical override path on the public side.)
|
|
for attr, value in {
|
|
"ltx2_cfg_scale_video": 1.0,
|
|
"ltx2_cfg_scale_audio": 1.0,
|
|
"ltx2_modality_scale_video": 1.0,
|
|
"ltx2_modality_scale_audio": 1.0,
|
|
"ltx2_rescale_scale": 0.0,
|
|
"ltx2_stg_scale_video": 0.0,
|
|
"ltx2_stg_scale_audio": 0.0,
|
|
}.items():
|
|
setattr(request, attr, value)
|
|
|
|
result = generator.generate(request)
|
|
generator.shutdown()
|
|
frames = _result_frames(result)
|
|
return RunResult(
|
|
label=args.label,
|
|
fastvideo_repo=_detect_fastvideo_repo(),
|
|
output_frames=frames,
|
|
metadata={
|
|
"api": "typed",
|
|
"prompt": PROMPT,
|
|
"seed": args.seed,
|
|
"height": args.height,
|
|
"width": args.width,
|
|
"num_frames": args.frames,
|
|
"fps": args.fps,
|
|
"base_steps": args.base_steps,
|
|
"refine_steps": args.refine_steps,
|
|
},
|
|
)
|
|
|
|
|
|
def _result_frames(result: Any) -> torch.Tensor:
|
|
"""Coerce whatever generate() returned into ``[F, H, W, C]`` uint8."""
|
|
frames = getattr(result, "frames", None)
|
|
if frames is None and isinstance(result, dict):
|
|
frames = result.get("frames")
|
|
if frames is None:
|
|
raise RuntimeError(
|
|
"Generation returned no frames; ensure return_frames=True.")
|
|
if isinstance(frames, list):
|
|
frames = np.stack(frames, axis=0)
|
|
if isinstance(frames, np.ndarray):
|
|
return torch.from_numpy(frames)
|
|
if torch.is_tensor(frames):
|
|
return frames.detach().cpu()
|
|
raise TypeError(f"Unsupported frames container: {type(frames)!r}")
|
|
|
|
|
|
def _run(args: argparse.Namespace) -> None:
|
|
if args.use_typed_api:
|
|
result = _run_typed(args)
|
|
else:
|
|
result = _run_legacy(args)
|
|
|
|
output_path = Path(args.output)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
torch.save(
|
|
{
|
|
"frames": result.output_frames,
|
|
"metadata": result.metadata,
|
|
"label": result.label,
|
|
"fastvideo_repo": result.fastvideo_repo,
|
|
},
|
|
output_path,
|
|
)
|
|
print(
|
|
f"[align] saved {result.output_frames.shape} frames "
|
|
f"({result.fastvideo_repo}) -> {output_path}",
|
|
flush=True,
|
|
)
|
|
|
|
|
|
def _diff(args: argparse.Namespace) -> None:
|
|
ref_path = Path(args.reference)
|
|
cand_path = Path(args.candidate)
|
|
if not ref_path.is_file() or not cand_path.is_file():
|
|
raise FileNotFoundError(
|
|
f"Need both --reference and --candidate to exist: {ref_path}, "
|
|
f"{cand_path}")
|
|
ref = torch.load(ref_path, map_location="cpu")
|
|
cand = torch.load(cand_path, map_location="cpu")
|
|
|
|
ref_frames = ref["frames"]
|
|
cand_frames = cand["frames"]
|
|
print(f"[align] reference: {ref['label']} {ref_frames.shape} from "
|
|
f"{ref['fastvideo_repo']}")
|
|
print(f"[align] candidate: {cand['label']} {cand_frames.shape} from "
|
|
f"{cand['fastvideo_repo']}")
|
|
|
|
if ref_frames.shape != cand_frames.shape:
|
|
print(f"[align] shape MISMATCH: ref={tuple(ref_frames.shape)} "
|
|
f"vs cand={tuple(cand_frames.shape)}")
|
|
return
|
|
|
|
diff = (ref_frames.float() - cand_frames.float()).abs()
|
|
max_abs = float(diff.max().item())
|
|
mean_abs = float(diff.mean().item())
|
|
rms = float(torch.sqrt((diff**2).mean()).item())
|
|
|
|
# PSNR over uint8 [0,255] data range.
|
|
if rms == 0.0:
|
|
psnr = float("inf")
|
|
else:
|
|
psnr = 20.0 * float(torch.log10(torch.tensor(255.0 / rms)).item())
|
|
|
|
print("[align] frame-level uint8 metrics (per-pixel):")
|
|
print(f" max_abs_diff = {max_abs:.4f}")
|
|
print(f" mean_abs_diff = {mean_abs:.6f}")
|
|
print(f" rms_diff = {rms:.4f}")
|
|
print(f" psnr_db = {psnr:.2f}")
|
|
|
|
# Bucket pixels by exactness so we have a sense of how close we are.
|
|
eq = (diff == 0).float().mean().item()
|
|
within_1 = (diff <= 1).float().mean().item()
|
|
within_2 = (diff <= 2).float().mean().item()
|
|
within_4 = (diff <= 4).float().mean().item()
|
|
print("[align] pixel buckets:")
|
|
print(f" exact = {eq * 100:.2f}%")
|
|
print(f" within 1 = {within_1 * 100:.2f}%")
|
|
print(f" within 2 = {within_2 * 100:.2f}%")
|
|
print(f" within 4 = {within_4 * 100:.2f}%")
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = parser.add_subparsers(dest="mode", required=False)
|
|
|
|
run = parser
|
|
run.add_argument("--label",
|
|
default="run",
|
|
help="Identifier saved alongside the output (e.g. internal/public).")
|
|
run.add_argument("--output",
|
|
default="/tmp/ltx2_sr_alignment.pt",
|
|
help="Destination .pt file.")
|
|
run.add_argument("--use-typed-api",
|
|
action="store_true",
|
|
help="Use the new typed GeneratorConfig API "
|
|
"(public side); otherwise use legacy from_pretrained.")
|
|
run.add_argument("--model", default=DEFAULT_MODEL)
|
|
run.add_argument("--seed", type=int, default=DEFAULT_SEED)
|
|
run.add_argument("--height", type=int, default=DEFAULT_HEIGHT)
|
|
run.add_argument("--width", type=int, default=DEFAULT_WIDTH)
|
|
run.add_argument("--frames", type=int, default=DEFAULT_FRAMES)
|
|
run.add_argument("--fps", type=int, default=DEFAULT_FPS)
|
|
run.add_argument("--base-steps", type=int, default=DEFAULT_BASE_STEPS)
|
|
run.add_argument("--refine-steps", type=int, default=DEFAULT_REFINE_STEPS)
|
|
|
|
run.add_argument("--diff",
|
|
action="store_true",
|
|
help="Diff two .pt outputs instead of running.")
|
|
run.add_argument("--reference")
|
|
run.add_argument("--candidate")
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = _build_parser()
|
|
args = parser.parse_args(argv)
|
|
if args.diff:
|
|
if not (args.reference and args.candidate):
|
|
parser.error("--diff requires --reference and --candidate")
|
|
_diff(args)
|
|
return 0
|
|
_run(args)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|