"""Configuration-only validation for TF/FCP workflows. No dataset, checkpoint, model, distributed group or CUDA is initialized here. The narrow auxiliary range check uses a CPU timestep table to preserve the scheduler's float32 endpoint semantics; the usual full range needs no tensor imports. """ import math import re # channels, backbone blocks, hidden width, FPS; these are family facts, not recipes. FAMILIES = { "Wan2.1-T2V-1.3B": (16, 30, 1536, 16), "Wan2.1-T2V-14B": (16, 40, 5120, 16), "Wan2.2-TI2V-5B": (48, 30, 3072, 24), } def fail(field, rule): raise ValueError(f"{field}: {rule}") def integer(value, field, minimum=None): if type(value) is not int or (minimum is not None and value < minimum): fail( field, f"must be a non-boolean integer >= {minimum}" if minimum is not None else "must be a non-boolean integer", ) return value def number(value, field, minimum=None, maximum=None, *, strict_min=False, strict_max=False): if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): fail(field, "must be a finite non-boolean number") if minimum is not None and (value < minimum or (strict_min and value == minimum)): fail(field, f"must be {'>' if strict_min else '>='} {minimum}") if maximum is not None and (value > maximum or (strict_max and value == maximum)): fail(field, f"must be {'<' if strict_max else '<='} {maximum}") return value def string(value, field, *, nullable=False, empty=False): if nullable and value is None: return if not isinstance(value, str) or (not empty and not value.strip()): fail(field, "must be a string" if empty else "must be a nonempty string") def choice(value, field, choices): if value not in choices: fail(field, f"must be one of {', '.join(map(str, choices))}") def items(value, field, *, nonempty=False): if not isinstance(value, list) or (nonempty and not value): fail(field, "must be a nonempty list" if nonempty else "must be a list") return value def increasing(values, field, *, minimum=0, nonempty=False): items(values, field, nonempty=nonempty) for value in values: integer(value, field, minimum) if any(left >= right for left, right in zip(values, values[1:], strict=False)): fail(field, "must be strictly increasing with no duplicates") def validate_sources(config, *, required): sources = config.get("data_sources") if sources is None and not required: return items(sources, "data_sources", nonempty=True) seen = set() for index, source in enumerate(sources): prefix = f"data_sources[{index}]" source_id = source.get("id", f"source_{index}") string(source_id, f"{prefix}.id") source_id = source_id.strip() if source_id in seen: fail(f"{prefix}.id", f"duplicate source ID {source_id!r}") seen.add(source_id) source["id"] = source_id for key in ("data_path", "annotation_path"): string(source.get(key), f"{prefix}.{key}") if not source["data_path"].rstrip("/\\").lower().endswith("shards"): fail(f"{prefix}.data_path", "must name a directory ending in 'shards'") number(source.get("weight"), f"{prefix}.weight", 0, strict_min=True) if required: for key in ("data_path", "annotation_path"): if config.get(key) is not None: fail(key, "must be null/omitted when using data_sources") def validate_auxiliary_range(bounds, shift): field = "future_prediction.aux_future_timestep_range" items(bounds, field) if len(bounds) != 2: fail(field, "requires two finite nonnegative endpoints") lower, upper = [number(value, field, 0) for value in bounds] if lower > upper: fail(field, "lower endpoint must not exceed upper endpoint") # Every retained training schedule starts at 1000. Avoid importing torch for # the reference interval; a narrower interval needs the exact CPU arithmetic. if lower <= 1000 <= upper: return import torch sigmas = torch.linspace(1.0, 0.0, 1001, dtype=torch.float32, device="cpu")[:-1] timesteps = (shift * sigmas / (1 + (shift - 1) * sigmas)) * 1000 if not ((timesteps >= lower) & (timesteps <= upper)).any().item(): fail(field, "closed interval selects no actual training scheduler timestep") def validate_future_prediction(config, family, chunks): fp = config["future_prediction"] prefix = "future_prediction." if type(fp["enabled"]) is not bool: fail(prefix + "enabled", "must be boolean") if fp["enabled"]: for field in ("future_offsets", "offset_loss_weights", "source_layers", "mini_dit_depth"): if field not in fp: fail( prefix + field, "is required for FCP; method changes do not construct a recipe" ) offsets = fp.get("future_offsets") if "future_offsets" in fp: increasing(offsets, prefix + "future_offsets", minimum=1, nonempty=True) if offsets[-1] >= chunks: fail(prefix + "future_offsets", f"every offset must be below {chunks} regular chunks") if "offset_loss_weights" in fp: weights = items(fp["offset_loss_weights"], prefix + "offset_loss_weights", nonempty=True) if offsets is not None and len(weights) != len(offsets): fail(prefix + "offset_loss_weights", "requires one weight per offset") for weight in weights: number(weight, prefix + "offset_loss_weights", 0) if not any(weight > 0 for weight in weights): fail(prefix + "offset_loss_weights", "at least one weight must be positive") layers = fp.get("source_layers") if "source_layers" in fp: increasing(layers, prefix + "source_layers", nonempty=True) if layers[-1] >= family[1]: fail(prefix + "source_layers", f"indices must be below {family[1]}") depth = None if "mini_dit_depth" in fp: depth = integer(fp["mini_dit_depth"], prefix + "mini_dit_depth", 1) mode = fp["init_mode"] choice(mode, prefix + "init_mode", ("copy", "random")) if mode == "random": if "init_source" in fp: fail( prefix + "init_source", "must be absent for random initialization (null is invalid)" ) elif fp["enabled"] or "init_source" in fp: if depth is None: fail(prefix + "mini_dit_depth", "is required with init_source") if "init_source" not in fp: if len(layers) != 1: fail(prefix + "init_source", "is required for multi-layer copy initialization") indices = list(range(layers[0] + 1, layers[0] + 1 + depth)) else: indices = items(fp["init_source"], prefix + "init_source", nonempty=True) if len(indices) == 1 and isinstance(indices[0], str): match = re.fullmatch(r"\s*(-?\d+)\s*:\s*(-?\d+)\s*", indices[0]) if not match: fail(prefix + "init_source", "range must use start:end (end exclusive)") start, end = map(int, match.groups()) if not 0 <= start < end <= family[1]: fail(prefix + "init_source", "range must lie inside backbone blocks") indices = list(range(start, end)) for index in indices: integer(index, prefix + "init_source", 0) if index >= family[1]: fail(prefix + "init_source", f"indices must be below {family[1]}") if len(indices) != depth or len(set(indices)) != len(indices): fail(prefix + "init_source", "distinct index count must equal mini_dit_depth") fp["init_source"] = indices fusion = fp["source_layer_fusion"] choice(fusion["mode"], prefix + "source_layer_fusion.mode", ("mlp",)) choice(fusion["activation"], prefix + "source_layer_fusion.activation", ("gelu",)) ratio = number( fusion["hidden_ratio"], prefix + "source_layer_fusion.hidden_ratio", 0, strict_min=True ) if not math.isfinite(ratio * family[2]) or round(ratio * family[2]) <= 0: fail( prefix + "source_layer_fusion.hidden_ratio", "must produce a positive finite MLP width" ) streams = fp["source_context"].get("streams") if streams is None: streams = ["noisy"] items(streams, prefix + "source_context.streams", nonempty=True) if any(not isinstance(value, str) or value not in ("clean", "noisy") for value in streams): fail(prefix + "source_context.streams", "only clean and noisy are supported") if len(set(streams)) != len(streams): fail(prefix + "source_context.streams", "duplicates are not allowed") fp["source_context"]["streams"] = [name for name in ("clean", "noisy") if name in streams] shift = fp.get("aux_future_timestep_shift", config["model_kwargs"]["timestep_shift"]) number(shift, prefix + "aux_future_timestep_shift", 0, strict_min=True) validate_auxiliary_range(fp["aux_future_timestep_range"], shift) def validate_config(config: dict, *, training: bool, world_size: int = 1) -> None: """Validate and normalize one final merged public config before side effects.""" for key in ( "image_or_video_shape", "num_frame_per_block", "ema_weight", "ema_start_step", "mixed_precision", "gradient_checkpointing", "sharding_strategy", "log_iters", "num_workers", "guidance_scale", ): if key not in config: fail(key, "is required; select a complete training recipe") bool_fields = """load_diffusion_pretrained generator_fsdp_sync_module_states independent_first_frame condition_only_first_frame i2v mixed_precision gradient_checkpointing separate_predictive_grad_clip save_resume_checkpoint require_raw_generator_checkpoint no_save no_visualize disable_wandb use_ema use_train_data""".split() for key in bool_fields: if type(config[key]) is not bool: fail(key, "must be boolean") model = config["model_name"] if not isinstance(model, str) or model not in FAMILIES: fail("model_name", f"must be one of {', '.join(FAMILIES)}") family = FAMILIES[model] positive_ints = """batch_size val_batch_size log_iters gc_interval timestep_bucket_log_interval num_frame_per_block sampling_steps long_inference_threshold vae_decode_chunk""".split() for key in positive_ints: integer(config[key], key, 1) for key in ( "num_workers", "val_num_workers", "lr_warmup_steps", "ema_start_step", "wds_sample_shuffle", "wds_sample_shuffle_initial", ): integer(config[key], key, 0) for key in ("max_iters", "snapshot_iters", "num_output_frames"): value = integer(config[key], key) if value != -1 and value <= 0: fail(key, "must be positive or -1") integer(config["test_sample_limit"], "test_sample_limit", -1) if config["max_checkpoints"] is not None: integer(config["max_checkpoints"], "max_checkpoints") seed = integer(config["seed"], "seed", 0) integer(world_size, "world_size", 1) if seed > 2**32 - 1 or (training and (seed == 0 or seed + world_size - 1 > 2**32 - 1)): fail("seed", "must fit NumPy uint32 for every rank; training also requires nonzero") string(config["optimizer"], "optimizer") config["optimizer"] = config["optimizer"].strip().lower() choice(config["optimizer"], "optimizer", ("adamw",)) choice(config["ema_checkpoint_mode"], "ema_checkpoint_mode", ("default",)) choice(config["sample_solver"], "sample_solver", ("unipc", "dpm++")) choice( config["sharding_strategy"], "sharding_strategy", ("full", "hybrid_full", "hybrid_zero2", "no_shard"), ) for key in ("generator_fsdp_wrap_strategy", "text_encoder_fsdp_wrap_strategy"): if key in config: choice(config[key], key, ("size",)) adamw = config["optimizer_config"]["adamw"] for key in ("lr", "weight_decay"): number(adamw[key], f"optimizer_config.adamw.{key}", 0) for key in ("beta1", "beta2"): number(adamw[key], f"optimizer_config.adamw.{key}", 0, 1, strict_max=True) increasing(config["lr_drop_steps"], "lr_drop_steps", minimum=1) if any(step <= config["lr_warmup_steps"] for step in config["lr_drop_steps"]): fail("lr_drop_steps", "each node must be after lr_warmup_steps") number(config["lr_drop_gamma"], "lr_drop_gamma", 0, 1, strict_min=True, strict_max=True) number(config["ema_weight"], "ema_weight", 0, 1, strict_max=True) number(config["caption_dropout_p"], "caption_dropout_p", 0, 1) number(config["guidance_scale"], "guidance_scale", 0) number(config["max_grad_norm"], "max_grad_norm", 0, strict_min=True) number(config["timestep_shift"], "timestep_shift", 0, strict_min=True) number( config["model_kwargs"]["timestep_shift"], "model_kwargs.timestep_shift", 0, strict_min=True ) string(config["negative_prompt"], "negative_prompt", empty=True) for key in ("wandb_host", "wandb_project"): string(config[key], key) for key in ( "wandb_entity", "wandb_save_dir", "expdir", "checkpoint_path", "generator_ckpt", "resume_checkpoint_path", "data_path", "annotation_path", "output_path", "checkpoint_name", "inference_train_data_source_id", ): string(config[key], key, nullable=True) shape = items(config["image_or_video_shape"], "image_or_video_shape") if len(shape) != 5: fail("image_or_video_shape", "must be [B,T,C,H,W]") for value in shape: integer(value, "image_or_video_shape", 1) _, frames, channels, height, width = shape if channels != family[0]: fail("image_or_video_shape", f"{model} requires C={family[0]}") if height % 2 or width % 2: fail("image_or_video_shape", "H and W must be divisible by the spatial patch size 2") independent = config["independent_first_frame"] if config["condition_only_first_frame"] and not independent: fail("condition_only_first_frame", "requires independent_first_frame=true") regular = frames - int(independent) block = config["num_frame_per_block"] if regular <= 0 or regular % block: fail("image_or_video_shape", "T must contain positive whole regular chunks after C0") fps = config.get("fps", family[3]) number(fps, "fps", 0, strict_min=True) if int(fps) != fps: fail("fps", "must be integer-valued") kwargs = config["model_kwargs"] window = integer(kwargs["local_attn_size"], "model_kwargs.local_attn_size") if window == -1: window = frames elif window <= 0: fail("model_kwargs.local_attn_size", "must be positive or -1 (automatic)") sink = integer(kwargs["sink_size"], "model_kwargs.sink_size", 0) if sink + block > window: fail("model_kwargs.local_attn_size", "must fit sink_size plus an incoming regular chunk") validate_future_prediction(config, family, regular // block) predictive = config["future_prediction"]["enabled"] separate = config["separate_predictive_grad_clip"] if separate and not predictive: fail("separate_predictive_grad_clip", "requires future_prediction.enabled=true") if separate: number(config["max_predictive_grad_norm"], "max_predictive_grad_norm", 0, strict_min=True) elif config["max_predictive_grad_norm"] is not None: fail("max_predictive_grad_norm", "must be null/absent when separate clipping is off") if config["save_resume_checkpoint"] and config["no_save"]: fail("save_resume_checkpoint", "cannot be enabled with no_save=true") single_image = config.get("image_path") is not None if training: for key in ( "image_path", "video_path", "prompt", "prompt_file", "v2v", "v2v_context_latents", ): if key in config: fail(key, "is an inference-only input") else: string(config.get("image_path"), "image_path", nullable=True) string(config.get("prompt"), "prompt", nullable=True) string(config.get("prompt_file"), "prompt_file", nullable=True) string(config.get("video_path"), "video_path", nullable=True) single_video = config.get("video_path") is not None if single_image and single_video: fail("video_path", "cannot combine with image_path") if type(config["v2v"]) is not bool: fail("v2v", "must be boolean") if single_video: config["v2v"] = True v2v = config["v2v"] if v2v and config["i2v"]: fail("v2v", "cannot combine with i2v") if v2v and single_image: fail("v2v", "cannot combine with image_path") if v2v and not (single_video or config["use_train_data"]): fail("v2v", "requires video_path or use_train_data=true") if single_video: if config["data_path"] is not None or config["use_train_data"]: fail("video_path", "cannot combine with data_path or use_train_data") if config["inference_train_data_source_id"] is not None: fail("video_path", "cannot combine with train_data_source_id") if not v2v and config["v2v_context_latents"] is not None: fail("v2v_context_latents", "requires v2v=true or video_path") inline_prompt = config.get("prompt") is not None file_prompt = config.get("prompt_file") is not None if inline_prompt and file_prompt: fail("prompt/prompt_file", "are mutually exclusive; choose only one") if file_prompt and not config["prompt_file"].lower().endswith(".txt"): fail("prompt_file", "must name a .txt file") if (single_image or single_video) != (inline_prompt or file_prompt): fail( "image_path/--prompt", "provide both --image_path or --video_path and either --prompt or --prompt_file", ) if single_image: if config["data_path"] is not None: fail("image_path", "cannot combine with data_path; select one input route") if config["use_train_data"] or config["inference_train_data_source_id"] is not None: fail("image_path", "cannot combine with use_train_data or train_data_source_id") # A single supplied first frame selects TI2V without a redundant --i2v flag. config["i2v"] = True validate_sources(config, required=training or config["use_train_data"]) duration = config["duration"] string(duration, "duration") if config["num_output_frames"] == -1 and not re.fullmatch(r"[1-9][0-9]*[smhSMH]", duration): fail("duration", "must be a positive whole duration with s/m/h units") if training: return if not config["i2v"] and not config["v2v"]: fail("i2v", "ordinary inference requires text plus supplied C0; enable i2v") if not config["v2v"] and not independent and block != 1: fail( "num_frame_per_block", "single-C0 inference without an independent first frame requires 1", ) if config["v2v"]: from src.utils.video_prefix import prefix_geometry if single_video and config["v2v_context_latents"] is None: # Metadata-dependent tail selection happens before GPU/model loading. minimum = 1 + block if independent else block prefix_geometry({**config, "v2v_context_latents": minimum}) else: prefix_geometry(config) source_id = config["inference_train_data_source_id"] if config["use_train_data"]: sources = config["data_sources"] if source_id is None and len(sources) == 1: source_id = sources[0]["id"] if source_id not in {source["id"] for source in sources}: fail("inference_train_data_source_id", "select one configured source for inference") config["inference_train_data_source_id"] = source_id else: if source_id is not None: fail("inference_train_data_source_id", "requires use_train_data=true") if not single_image and not single_video: string(config["data_path"], "data_path") if world_size != 1: fail("i2v", "external-image input requires non-distributed Python execution")