Signed-off-by: Celeste-jq <591998922@qq.com> Co-authored-by: Canlin Guo <canlinguosdu@gmail.com>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Assemble a Wan2.2-I2V-A14B-Diffusers-style model directory using a Diffusers
|
|
skeleton and optional replacement transformer checkpoints.
|
|
|
|
This tool does NOT run any external conversion step. You can use it in two
|
|
ways:
|
|
- keep the original weights from the Diffusers skeleton
|
|
- replace transformer/transformer_2 with converted checkpoints such as
|
|
LightX2V outputs
|
|
- use legacy LightX2V arg names (--high-noise-weight/--low-noise-weight),
|
|
which are accepted as aliases
|
|
|
|
Typical use:
|
|
python tools/wan22/assemble_wan22_i2v_diffusers.py \
|
|
--diffusers-skeleton /path/to/Wan2.2-I2V-A14B-Diffusers \
|
|
--transformer-weight /path/to/high_noise_out/diffusion_pytorch_model.safetensors \
|
|
--transformer-2-weight /path/to/low_noise_out/diffusion_pytorch_model.safetensors \
|
|
--output-dir /path/to/Wan2.2-I2V-A14B-Custom-Diffusers
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
WEIGHT_CANDIDATES = (
|
|
"diffusion_pytorch_model.safetensors",
|
|
"diffusion_pytorch_model.bin",
|
|
"diffusion_pytorch_model.pt",
|
|
"model.safetensors",
|
|
"pytorch_model.bin",
|
|
"model.pt",
|
|
)
|
|
WEIGHT_INDEX_CANDIDATES = (
|
|
"diffusion_pytorch_model.safetensors.index.json",
|
|
"model.safetensors.index.json",
|
|
"pytorch_model.bin.index.json",
|
|
)
|
|
|
|
ROOT_REQUIRED_FILES = ("model_index.json",)
|
|
ROOT_REQUIRED_DIRS = ("tokenizer", "text_encoder", "vae", "transformer", "transformer_2")
|
|
OPTIONAL_DIRS = ("image_encoder", "image_processor", "scheduler", "feature_extractor")
|
|
|
|
|
|
class AssembleError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WeightSpec:
|
|
kind: str # "single" | "sharded"
|
|
single_file: Path | None = None
|
|
index_file: Path | None = None
|
|
shard_files: tuple[Path, ...] = ()
|
|
|
|
|
|
def _load_shard_files_from_index(index_file: Path, role: str) -> tuple[Path, ...]:
|
|
try:
|
|
with index_file.open(encoding="utf-8") as f:
|
|
payload = json.load(f)
|
|
except Exception as exc:
|
|
raise AssembleError(f"Failed to parse {role} index file: {index_file}. error={exc}") from exc
|
|
|
|
weight_map = payload.get("weight_map")
|
|
if not isinstance(weight_map, dict) or not weight_map:
|
|
raise AssembleError(f"Invalid {role} index file (missing/empty weight_map): {index_file}")
|
|
|
|
shard_names = sorted({str(v) for v in weight_map.values()})
|
|
shard_paths: list[Path] = []
|
|
missing: list[str] = []
|
|
for shard_name in shard_names:
|
|
shard_path = index_file.parent / shard_name
|
|
if not shard_path.is_file():
|
|
missing.append(str(shard_path))
|
|
else:
|
|
shard_paths.append(shard_path)
|
|
|
|
if missing:
|
|
raise AssembleError(f"{role} index references missing shard file(s): " + ", ".join(missing))
|
|
|
|
if not shard_paths:
|
|
raise AssembleError(f"No shard files referenced by {role} index: {index_file}")
|
|
|
|
return tuple(shard_paths)
|
|
|
|
|
|
def _resolve_weight_spec(path: Path, role: str) -> WeightSpec:
|
|
if path.is_file():
|
|
return WeightSpec(kind="single", single_file=path)
|
|
|
|
if path.is_dir():
|
|
for name in WEIGHT_CANDIDATES:
|
|
candidate = path / name
|
|
if candidate.is_file():
|
|
return WeightSpec(kind="single", single_file=candidate)
|
|
|
|
for index_name in WEIGHT_INDEX_CANDIDATES:
|
|
index_file = path / index_name
|
|
if not index_file.is_file():
|
|
continue
|
|
shard_files = _load_shard_files_from_index(index_file, role=role)
|
|
return WeightSpec(
|
|
kind="sharded",
|
|
index_file=index_file,
|
|
shard_files=shard_files,
|
|
)
|
|
|
|
shard_candidates = sorted(path.glob("diffusion_pytorch_model-*.safetensors"))
|
|
if shard_candidates:
|
|
raise AssembleError(
|
|
f"Detected sharded {role} files under {path}, but index json is missing. "
|
|
f"Expected one of: {', '.join(WEIGHT_INDEX_CANDIDATES)}"
|
|
)
|
|
|
|
raise AssembleError(
|
|
f"Cannot find {role} weight under directory: {path}. "
|
|
f"Expected one of single files [{', '.join(WEIGHT_CANDIDATES)}] "
|
|
f"or sharded index files [{', '.join(WEIGHT_INDEX_CANDIDATES)}]."
|
|
)
|
|
|
|
raise AssembleError(f"{role} path does not exist: {path}")
|
|
|
|
|
|
def _canonical_weight_name(weight_file: Path) -> str:
|
|
suffix = weight_file.suffix.lower()
|
|
if suffix == ".safetensors":
|
|
return "diffusion_pytorch_model.safetensors"
|
|
if suffix == ".bin":
|
|
return "diffusion_pytorch_model.bin"
|
|
if suffix == ".pt":
|
|
return "diffusion_pytorch_model.pt"
|
|
return weight_file.name
|
|
|
|
|
|
def _validate_skeleton(skeleton: Path) -> None:
|
|
if not skeleton.is_dir():
|
|
raise AssembleError(f"--diffusers-skeleton is not a directory: {skeleton}")
|
|
|
|
for file_name in ROOT_REQUIRED_FILES:
|
|
if not (skeleton / file_name).is_file():
|
|
raise AssembleError(f"Missing required file in skeleton: {skeleton / file_name}")
|
|
|
|
for dir_name in ROOT_REQUIRED_DIRS:
|
|
if not (skeleton / dir_name).is_dir():
|
|
raise AssembleError(f"Missing required directory in skeleton: {skeleton / dir_name}")
|
|
|
|
if not (skeleton / "transformer" / "config.json").is_file():
|
|
raise AssembleError(f"Missing transformer config: {skeleton / 'transformer/config.json'}")
|
|
|
|
if not (skeleton / "transformer_2" / "config.json").is_file():
|
|
raise AssembleError(f"Missing transformer_2 config: {skeleton / 'transformer_2/config.json'}")
|
|
|
|
|
|
def _ensure_clean_output(output_dir: Path, overwrite: bool) -> None:
|
|
if output_dir.exists():
|
|
if not overwrite:
|
|
raise AssembleError(
|
|
f"Output directory already exists: {output_dir}. Use --overwrite to remove and recreate it."
|
|
)
|
|
shutil.rmtree(output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=False)
|
|
|
|
|
|
def _copy_or_link_dir(src: Path, dst: Path, asset_mode: str) -> None:
|
|
if asset_mode == "copy":
|
|
shutil.copytree(src, dst)
|
|
elif asset_mode == "symlink":
|
|
dst.symlink_to(src, target_is_directory=True)
|
|
else:
|
|
raise AssembleError(f"Unknown asset mode: {asset_mode}")
|
|
|
|
|
|
def _materialize_weight(weight: WeightSpec, dst_dir: Path, role: str) -> tuple[Path, ...]:
|
|
if weight.kind == "single":
|
|
assert weight.single_file is not None
|
|
dst = dst_dir / _canonical_weight_name(weight.single_file)
|
|
shutil.copy2(weight.single_file, dst)
|
|
return (dst,)
|
|
|
|
if weight.kind == "sharded":
|
|
assert weight.index_file is not None
|
|
copied: list[Path] = []
|
|
index_dst = dst_dir / weight.index_file.name
|
|
shutil.copy2(weight.index_file, index_dst)
|
|
copied.append(index_dst)
|
|
for shard_file in weight.shard_files:
|
|
shard_dst = dst_dir / shard_file.name
|
|
shutil.copy2(shard_file, shard_dst)
|
|
copied.append(shard_dst)
|
|
return tuple(copied)
|
|
|
|
raise AssembleError(f"Unknown {role} weight kind: {weight.kind}")
|
|
|
|
|
|
def _assemble(
|
|
skeleton: Path,
|
|
output_dir: Path,
|
|
transformer_weight: WeightSpec,
|
|
transformer_2_weight: WeightSpec,
|
|
asset_mode: str,
|
|
) -> tuple[tuple[Path, ...], tuple[Path, ...]]:
|
|
shutil.copy2(skeleton / "model_index.json", output_dir / "model_index.json")
|
|
|
|
for dir_name in ROOT_REQUIRED_DIRS:
|
|
if dir_name in ("transformer", "transformer_2"):
|
|
continue
|
|
_copy_or_link_dir(skeleton / dir_name, output_dir / dir_name, asset_mode)
|
|
|
|
for dir_name in OPTIONAL_DIRS:
|
|
src_dir = skeleton / dir_name
|
|
if src_dir.is_dir():
|
|
_copy_or_link_dir(src_dir, output_dir / dir_name, asset_mode)
|
|
|
|
(output_dir / "transformer").mkdir(parents=True, exist_ok=True)
|
|
(output_dir / "transformer_2").mkdir(parents=True, exist_ok=True)
|
|
|
|
shutil.copy2(skeleton / "transformer" / "config.json", output_dir / "transformer" / "config.json")
|
|
shutil.copy2(skeleton / "transformer_2" / "config.json", output_dir / "transformer_2" / "config.json")
|
|
|
|
transformer_copied = _materialize_weight(transformer_weight, output_dir / "transformer", role="transformer")
|
|
transformer_2_copied = _materialize_weight(
|
|
transformer_2_weight,
|
|
output_dir / "transformer_2",
|
|
role="transformer_2",
|
|
)
|
|
|
|
return transformer_copied, transformer_2_copied
|
|
|
|
|
|
def _validate_output(
|
|
output_dir: Path,
|
|
transformer_copied: tuple[Path, ...],
|
|
transformer_2_copied: tuple[Path, ...],
|
|
) -> None:
|
|
if not (output_dir / "model_index.json").is_file():
|
|
raise AssembleError("Output validation failed: model_index.json missing")
|
|
|
|
required_paths = (
|
|
output_dir / "tokenizer",
|
|
output_dir / "text_encoder",
|
|
output_dir / "vae",
|
|
output_dir / "transformer" / "config.json",
|
|
output_dir / "transformer_2" / "config.json",
|
|
*transformer_copied,
|
|
*transformer_2_copied,
|
|
)
|
|
missing = [str(p) for p in required_paths if not p.exists()]
|
|
if missing:
|
|
raise AssembleError("Output validation failed, missing: " + ", ".join(missing))
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Assemble a Wan2.2-I2V-A14B-Diffusers directory while optionally "
|
|
"replacing transformer and transformer_2 weights."
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"--diffusers-skeleton",
|
|
type=Path,
|
|
required=True,
|
|
help="Path to a local Wan-AI/Wan2.2-I2V-A14B-Diffusers directory.",
|
|
)
|
|
parser.add_argument(
|
|
"--transformer-weight",
|
|
type=Path,
|
|
help=(
|
|
"Optional checkpoint file, or directory containing either a single-file "
|
|
"weight or sharded index+shards for transformer/. If omitted, keep the "
|
|
"skeleton's original transformer weights."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--transformer-2-weight",
|
|
type=Path,
|
|
help=(
|
|
"Optional checkpoint file, or directory containing either a single-file "
|
|
"weight or sharded index+shards for transformer_2/. If omitted, keep the "
|
|
"skeleton's original transformer_2 weights."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--high-noise-weight",
|
|
type=Path,
|
|
help=argparse.SUPPRESS,
|
|
)
|
|
parser.add_argument(
|
|
"--low-noise-weight",
|
|
type=Path,
|
|
help=argparse.SUPPRESS,
|
|
)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
type=Path,
|
|
required=True,
|
|
help="Output directory for the assembled model.",
|
|
)
|
|
parser.add_argument(
|
|
"--asset-mode",
|
|
choices=("symlink", "copy"),
|
|
default="symlink",
|
|
help=(
|
|
"How to materialize non-transformer assets (tokenizer/text_encoder/vae/optional dirs). "
|
|
"symlink saves disk and is default."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--overwrite",
|
|
action="store_true",
|
|
help="Overwrite output-dir if it exists.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
|
|
skeleton = args.diffusers_skeleton.resolve()
|
|
output_dir = args.output_dir.resolve()
|
|
|
|
if args.transformer_weight is not None and args.high_noise_weight is not None:
|
|
print(
|
|
"[ERROR] --transformer-weight and --high-noise-weight are aliases; please provide only one.",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
if args.transformer_2_weight is not None and args.low_noise_weight is not None:
|
|
print(
|
|
"[ERROR] --transformer-2-weight and --low-noise-weight are aliases; please provide only one.",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
transformer_weight_arg = args.transformer_weight if args.transformer_weight is not None else args.high_noise_weight
|
|
transformer_2_weight_arg = (
|
|
args.transformer_2_weight if args.transformer_2_weight is not None else args.low_noise_weight
|
|
)
|
|
|
|
transformer_input = (
|
|
transformer_weight_arg.resolve() if transformer_weight_arg is not None else skeleton / "transformer"
|
|
)
|
|
transformer_2_input = (
|
|
transformer_2_weight_arg.resolve() if transformer_2_weight_arg is not None else skeleton / "transformer_2"
|
|
)
|
|
|
|
try:
|
|
_validate_skeleton(skeleton)
|
|
transformer_weight = _resolve_weight_spec(transformer_input, role="transformer")
|
|
transformer_2_weight = _resolve_weight_spec(transformer_2_input, role="transformer_2")
|
|
|
|
_ensure_clean_output(output_dir, overwrite=args.overwrite)
|
|
transformer_copied, transformer_2_copied = _assemble(
|
|
skeleton=skeleton,
|
|
output_dir=output_dir,
|
|
transformer_weight=transformer_weight,
|
|
transformer_2_weight=transformer_2_weight,
|
|
asset_mode=args.asset_mode,
|
|
)
|
|
_validate_output(output_dir, transformer_copied, transformer_2_copied)
|
|
except AssembleError as exc:
|
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
def _weight_summary(copied: tuple[Path, ...]) -> str:
|
|
if len(copied) == 1:
|
|
return copied[0].name
|
|
return f"{copied[0].name} + {len(copied) - 1} shard files"
|
|
|
|
print("[OK] Assembled Wan2.2 I2V Diffusers directory:")
|
|
print(f" output_dir: {output_dir}")
|
|
print(f" transformer weight: {_weight_summary(transformer_copied)}")
|
|
print(f" transformer_2 weight: {_weight_summary(transformer_2_copied)}")
|
|
print("\nUse it with vLLM-Omni, for example:")
|
|
print(f" vllm serve {output_dir} --omni --port 8091")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|