24 KiB
LingBot-Video TI2V Training Guide
This document provides the complete ti2v (first-frame image + text to video) full-parameter training workflow for LingBot-Video (single-stream joint DiT + Qwen3-VL text encoder), including environment setup, data preparation, FSDP distributed training and inference testing.
Note
: LingBot-Video has a completely different architecture from LingBot-World (Wan2.2 dual-Transformer + camera control):
- Single-stream joint DiT: video tokens and text tokens are concatenated and processed with full self-attention (
LingBotVideoTransformer3DModel);- Qwen3-VL text encoder: prompts go through a chat template, and the first-frame image is fed into the text sequence as visual tokens;
- Flow matching:
x_t = (1-σ)·x0 + σ·noise, targetnoise - x0, the transformer receivestimestep = σ·1000;- ti2v first-frame conditioning: besides entering the Qwen3-VL text sequence, the first frame is also VAE-encoded separately into
cond_latent, which is written back to the temporal prefix of the latent at every denoising step (inpainting semantics). The loss is computed only on non-conditioning frames.The training script is
scripts/lingbot_video/train.py;scripts/lingbot_video/train.sh(dense 1.3B) andscripts/lingbot_video/train_moe.sh(MoE 30B-A3B) are the default FSDP FULL_SHARD launch configs.
Table of Contents
1. Environment Setup
Option 1: requirements.txt
pip install -r requirements.txt
Option 2: manual installation
pip install Pillow einops safetensors timm tomesd librosa "torch>=2.1.2" torchdiffeq torchsde decord datasets numpy scikit-image scipy
pip install omegaconf SentencePiece imageio[ffmpeg] imageio[pyav] tensorboard beautifulsoup4 ftfy func_timeout onnxruntime
pip install "peft>=0.17.0" "accelerate>=0.25.0" "gradio>=3.41.2" "diffusers>=0.30.1" "transformers>=4.46.2"
pip install modelscope openpyxl
pip uninstall opencv-python opencv-contrib-python opencv-python-headless -y
pip install opencv-python-headless
Note
: the Qwen3-VL text encoder requires a recent
transformers—videox_fun/models/__init__.pyimportsQwen3VLForConditionalGenerationfrom transformers and falls back toNonewhen it is missing, printingYour transformers version is too old to load Qwen3VLForConditionalGeneration. If you see that line (orKeyError: 'qwen3_vl'while reading the config), upgrade transformers.Note: the optional prompt rewriter (see 2.3) needs an even newer stack (
transformers>=5.xwith theqwen3_5module) plus the official rewriter package underrepo/lingbot-video/rewriter; run it from a dedicated venv so the training environment stays untouched.
2. Data Preparation
2.1 Quick-test Dataset
We provide a demo dataset that ships a handful of ready-to-train samples.
# Download the official demo dataset
modelscope download --dataset PAI/X-Fun-Videos-Demo --local_dir ./datasets/X-Fun-Videos-Demo
It contains 16 videos at 832x480 under train/ plus metadata_lingbot_video_add_width_height.json, whose text fields are already LingBot-Video structured JSON captions (see 2.3), so it can be used for training as-is. LingBot-Video needs no camera trajectory / action files, and the ti2v condition frame is taken automatically from the first frame of each video (used both as the Qwen3-VL visual input and as the VAE cond_latent), so no separate image files are needed either.
2.2 metadata_lingbot_video_add_width_height.json Format
Standard VideoX-Fun format, one entry per video:
[
{
"file_path": "train/00000000.mp4",
"text": "{\"comprehensive_description\": {...}, \"prominent_elements\": [...], \"camera_info\": {...}}",
"width": 832,
"height": 480,
"type": "video"
},
{
"file_path": "train/00000001.mp4",
"text": "{\"comprehensive_description\": {...}, \"prominent_elements\": [...], \"camera_info\": {...}}",
"width": 832,
"height": 480,
"type": "video"
}
]
file_path: video path relative to--train_data_dir(set--train_data_dir=""to use absolute paths);text: structured JSON caption, serialized as a single string. The DiT was trained only on rewriter-style JSON captions; natural-language captions are out-of-distribution and degrade fine-tuning.train.pyvalidates every entry withis_valid_captionat startup and logs a warning with the number of offending entries. See 2.3;width/height: original video size, used to pick the aspect-ratio bucket. Recommended; when missing,AspectRatioBatchImageVideoSamplerprobes the file with OpenCV while bucketing (extra IO per epoch).scripts/process_json_add_width_and_height.pyfills the fields in for an existing metadata file;type: must be"video"(entries without it are treated as images and use--image_sample_size).
2.3 Captions must be structured JSON captions
The rewriter weights default to models/Diffusion_Transformer/Qwen3.6-27B and
models/Diffusion_Transformer/lingbot-video-rewriter-lora:
modelscope download --model Qwen/Qwen3.6-27B --local_dir models/Diffusion_Transformer/Qwen3.6-27B
modelscope download --model Robbyant/lingbot-video-rewriter-lora --local_dir models/Diffusion_Transformer/lingbot-video-rewriter-lora
A valid caption is a JSON object carrying the three top-level keys checked by
is_valid_caption (videox_fun/models/lingbot_video_rewriter.py, the single
source of truth for the schema):
{
"comprehensive_description": {
"scene_content_description": "A drone flies over a mountain ridge at sunrise ...",
"camera_movement_description": "The camera pushes forward slowly."
},
"prominent_elements": [
{
"name": "mountain ridge",
"description": "a snow-dusted ridge line",
"actions": [{"timestamp": "[0.0s - 3.3s]", "action": ""}],
"location": "center",
"relative_size": "large",
"shape_and_color": "grey rock with white snow",
"texture": "rough",
"appearance_details": "",
"relationship": "",
"orientation": "",
"pose": "",
"expression": "",
"clothing": "",
"gender": "",
"skin_tone_and_texture": ""
}
],
"camera_info": {
"color": "Cool",
"frame_size": "Wide",
"shot_type_angle": "Aerial",
"lens_size": "Wide",
"composition": "Balanced",
"lighting": "Soft light",
"lighting_type": "Daylight"
}
}
The allowed camera_info values are enumerated in CAMERA_CHOICES, and
build_caption / element / cam in the same module assemble a caption
programmatically.
Batch conversion of a dataset — rewrite the text field of the metadata
BEFORE training with the official prompt rewriter (needs the rewriter base VLM +
LoRA adapter):
export REWRITER_BASE_MODEL=models/Diffusion_Transformer/Qwen3.6-27B
export REWRITER_ADAPTER=models/Diffusion_Transformer/lingbot-video-rewriter-lora
python scripts/lingbot_video/prepare_captions.py \
--metadata datasets/my_dataset/metadata.json \
--data_root datasets/my_dataset \
--output datasets/my_dataset/metadata_json.json \
--mode ti2v --duration 3.3
# then in train.sh: DATASET_META_NAME="datasets/my_dataset/metadata_json.json"
--mode:t2v/ti2v/t2i; withti2vthe video's first frame is read (decord, OpenCV fallback) and fed to the rewriter, so use the same mode you train with;--duration: clip duration in seconds handed to the rewriter — match your training clip (video_sample_n_frames / fps, e.g. 81 frames @ 24fps ≈ 3.3s);--base/--adapter: rewriter weights, alternativelyREWRITER_BASE_MODEL/REWRITER_ADAPTER;- entries that already hold a valid JSON caption are kept as-is unless
--overwriteis passed; the output file is rewritten after every sample so long runs can be interrupted and resumed, and--metadatais never modified in place; - entries the rewriter fails on keep their original text and are reported in the final summary — fix or re-run before training.
Single prompt (e.g. to build --validation_prompts) — ensure_json_caption
is the one entry point; already-valid captions pass through untouched, otherwise
the rewriter is loaded, used, freed, and the result cached:
from PIL import Image
from videox_fun.models.lingbot_video_rewriter import ensure_json_caption
caption = ensure_json_caption(
"A drone slowly flies over the mountains, clouds drift in the background.",
mode="ti2v", duration=3.3,
first_frame=Image.open("asset/1.png").convert("RGB"), # ti2v only
cache_file="samples/caption_cache.json",
)
3. Full-Parameter Training
3.1 Download Pretrained Models
LingBot-Video ships two variants (diffusers-format directories containing transformer / vae / text_encoder / processor / scheduler subfolders):
| Model | Notes |
|---|---|
lingbot-video-dense-1.3b |
Dense 1.3B, trainable on 1-2 GPUs, recommended for pipeline validation (train.sh) |
lingbot-video-moe-30b-a3b |
MoE 30B-A3B, multi-GPU FSDP FULL_SHARD training (train_moe.sh, ≥8×80GB recommended) |
Directory layout (train.py loads each subfolder by path, so all five must be present):
lingbot-video-dense-1.3b/
├── transformer/ # LingBotVideoTransformer3DModel (the only trained module)
├── vae/ # AutoencoderKLQwenImage (frozen)
├── text_encoder/ # Qwen3-VL (frozen)
├── processor/ # Qwen3-VL processor (AutoProcessor)
└── scheduler/ # FlowUniPCMultistepScheduler (only sigma_max / sigma_min are read)
The MoE model additionally ships a refiner DiT, which training does not touch (it is only used by examples/lingbot_video/predict_t2v_refine.py).
3.2 Quick Start (FSDP)
Edit the three environment variables at the top of scripts/lingbot_video/train.sh (dense 1.3B) or scripts/lingbot_video/train_moe.sh (MoE 30B) and run:
export MODEL_NAME="models/Diffusion_Transformer/lingbot-video-dense-1.3b"
export DATASET_NAME="datasets/X-Fun-Videos-Demo"
export DATASET_META_NAME="datasets/X-Fun-Videos-Demo/metadata_lingbot_video_add_width_height.json"
sh scripts/lingbot_video/train.sh
The script launches FSDP FULL_SHARD with LingBotVideoBlock as the wrap unit (the MoE experts of the 30B model must be sharded together with their block, so the wrap class is fixed to LingBotVideoBlock). train.sh in full:
# NCCL_IB_DISABLE=1 and NCCL_P2P_DISABLE=1 are used in multi nodes without RDMA.
# export NCCL_IB_DISABLE=1
# export NCCL_P2P_DISABLE=1
NCCL_DEBUG=INFO
accelerate launch --mixed_precision="bf16" --use_fsdp --fsdp_auto_wrap_policy TRANSFORMER_BASED_WRAP \
--fsdp_transformer_layer_cls_to_wrap=LingBotVideoBlock --fsdp_sharding_strategy "FULL_SHARD" \
--fsdp_state_dict_type=SHARDED_STATE_DICT --fsdp_backward_prefetch "BACKWARD_PRE" \
--fsdp_cpu_ram_efficient_loading False scripts/lingbot_video/train.py \
--pretrained_model_name_or_path=$MODEL_NAME \
--train_data_dir=$DATASET_NAME \
--train_data_meta=$DATASET_META_NAME \
--image_sample_size=640 \
--video_sample_size=640 \
--token_sample_size=640 \
--video_sample_stride=1 \
--video_sample_n_frames=81 \
--train_batch_size=1 \
--video_repeat=1 \
--gradient_accumulation_steps=1 \
--dataloader_num_workers=8 \
--num_train_epochs=100 \
--checkpointing_steps=50 \
--learning_rate=1e-05 \
--lr_scheduler="constant_with_warmup" \
--lr_warmup_steps=100 \
--seed=42 \
--output_dir="output_dir_lingbot_video_ti2v" \
--gradient_checkpointing \
--mixed_precision="bf16" \
--adam_weight_decay=3e-2 \
--adam_epsilon=1e-10 \
--vae_mini_batch=1 \
--max_grad_norm=0.05 \
--random_hw_adapt \
--training_with_video_token_length \
--enable_bucket \
--uniform_sampling \
--train_shift=3.0 \
--trainable_modules "." \
--resume_from_checkpoint=latest
train_moe.sh is the same command with MODEL_NAME=models/Diffusion_Transformer/lingbot-video-moe-30b-a3b and --output_dir="output_dir_lingbot_video_moe_ti2v"; every other argument (including the 640 sample sizes) is identical.
Note
:
--use_fsdpis not passed on the command line —train.pydetects the accelerate FSDP plugin, derives the FSDP stage from the sharding strategy and switches to sharded checkpoint saving on its own. Neither script sets--validation_prompts, so periodic sampling is off by default (see 3.3).
VRAM tips: with
--train_batch_size=1+--gradient_checkpointing, dense 1.3B fits 81-frame 480p clips on 2×H20 (97GB); both scripts ship with 640 sample sizes, which needs correspondingly more — lower--video_sample_size/--token_sample_sizeif you OOM. MoE 30B is recommended on ≥8×80GB. The frozen bf16 Qwen3-VL text encoder is encoded online every step and is also FSDP-sharded (itsQwen3VLTextDecoderLayer/Qwen3VLVisionBlocklayers are wrapped).
Monitor with tensorboard:
tensorboard --logdir=output_dir_lingbot_video_ti2v
3.3 Key Training Arguments
| Argument | Notes |
|---|---|
--pretrained_model_name_or_path |
LingBot-Video model root (must contain transformer/ vae/ text_encoder/ processor/ scheduler/) |
--train_data_dir / --train_data_meta |
Dataset root + metadata json; file_path entries are resolved against the root |
--train_batch_size=1 |
Keep it 1. Qwen3-VL has variable-length text; batch>1 triggers the padding + attention_mask path, which works but is inefficient. Raise the effective batch with --gradient_accumulation_steps |
--video_sample_n_frames=81 |
Sampled frames; the collate function rounds the batch length down to 4n+1 (VAE temporal ratio 4) |
--video_sample_stride=1 |
Frame stride inside the sampled clip |
--video_repeat=1 |
Repeats every video entry this many times in the dataset list (balances mixed image/video datasets) |
--video_sample_size=640 / --image_sample_size=640 |
Short-side bucket size for video / image entries (both launch scripts use 640) |
--token_sample_size=640 |
Reference resolution of the token budget video_sample_n_frames × token_sample_size² used by --training_with_video_token_length |
--enable_bucket + --random_hw_adapt |
Aspect-ratio bucketing + random resolution per batch |
--training_with_video_token_length |
Keeps the total video token count constant while adapting H/W (fewer frames at higher resolution) |
--fix_sample_size H W |
Forces one fixed bucket size; it also disables --random_hw_adapt / --training_with_video_token_length |
--random_ratio_crop |
Random aspect-ratio crop instead of the closest-bucket resize |
--uniform_sampling |
Stratified uniform σ sampling: each rank group draws from its own slice of the --train_sampling_steps grid, so the ranks jointly cover the full noise range. Without it, σ indices come from the --weighting_scheme density (--logit_mean / --logit_std / --mode_scale) |
--train_sampling_steps=1000 |
Number of discrete σ levels in the training sigma table |
--train_shift=3.0 |
Sigma shift, consistent with the inference default shift=3.0: σ' = s·σ/(1+(s-1)·σ) |
--weighting_scheme |
Loss weighting (sigma_sqrt / logit_normal / mode / cosmap / none), default none (uniform) |
--gradient_checkpointing |
Gradient checkpointing, roughly halves activation memory at a slight speed cost |
--trainable_modules "." |
Full-parameter training; see 3.4 |
--trainable_modules_low_learning_rate |
Same substring matching, but those parameters get learning_rate / 2 |
--transformer_path / --vae_path |
Load transformer / VAE weights from an external .safetensors or .pt before training starts |
--vae_mini_batch=1 |
VAE chunked encoding batch size |
--low_vram |
Low-VRAM mode: VAE/Qwen3-VL are moved between GPU and CPU on demand |
--max_grad_norm=0.05 |
Gradient clipping. In non-FSDP mode --initial_grad_norm_ratio / --abnormal_norm_clip_start additionally decay the limit and damp abnormal gradients; under FSDP the raw max_grad_norm is used |
--checkpointing_steps=50 / --checkpoints_total_limit |
Checkpoint interval / maximum number of kept checkpoints |
--validation_prompts + --validation_paths |
ti2v sampling with LingBotVideoI2VPipeline every --validation_steps steps and every --validation_epochs epochs (prompts paired one-to-one with first-frame images, asserted at startup). Sampling is fixed to guidance_scale=3.0, 25 steps, shift=--train_shift, fps 24, resolution derived from image_sample_size² and the image aspect ratio; videos are written to output_dir/sample/sample-{step}-rank{rank}-image-{i}.mp4. The prompts must be JSON captions too |
--use_ema |
Non-FSDP only — FSDP FULL_SHARD raises NotImplementedError |
--report_model_info |
Logs per-parameter gradient norms to tensorboard (non-FSDP only) |
--resume_from_checkpoint=latest |
Resume from the latest checkpoint |
3.4 Trainable Module Selection
--trainable_modules filters parameter names by substring match; the default ["."] trains everything:
# Full-parameter (default)
--trainable_modules "."
# Attention projections only
--trainable_modules "attn"
# FFN / MoE experts only
--trainable_modules "ffn" "experts"
Anything matched by --trainable_modules_low_learning_rate instead is trained at half the learning rate; a parameter matched by both lists is assigned to the full-rate group.
3.5 Resume Training
- Under FSDP (the default of both launch scripts) each
checkpoint-*directory contains:diffusion_pytorch_model.safetensors(full weights gathered on the main process, cast to bf16, directly usable by the inference scripts);- the accelerate sharded state (optimizer/scheduler, for resuming);
sampler_pos_start.pkl(sampler position + epoch, restored on resume to keep data order).
- Without FSDP the checkpoint holds a diffusers-style
transformer/folder instead (plustransformer_ema/when--use_emais set). - Simply add
--resume_from_checkpoint=latestto resume; on load, the sampler position is rewound bydataloader_num_workers × num_processes × 2samples to compensate for prefetched batches. - A final
checkpoint-{global_step}is also written when training finishes.
3.6 How a Training Step Works
Each training step (strictly aligned with the inference path):
- Data: the DataLoader yields
(B, C, T, H, W) ∈ [-1, 1](resize + center-crop to the bucket size, then normalized with mean/std 0.5); the first frame[:, :, 0]is used as the condition image automatically. The first batch of the run is dumped tooutput_dir/sanity_check/for inspection. The dataset applies the default 10% text dropout (text_drop_ratio=0.1), i.e. some samples train with an empty prompt for classifier-free guidance; - VAE encoding (frozen, bf16; only the
latents_mean/stdnormalization is computed in fp32):- full video →
latents(normalized into DiT space vialatents_mean/std); - first frame encoded separately →
cond_latent(1 temporal frame);
- full video →
- Qwen3-VL encoding (frozen, bf16, no_grad): the prompt is wrapped with the chat template; the first frame is
smart_resized (aligned topatch_size×merge_size) and encoded as image tokens, producingprompt_embedsandprompt_mask; - Flow-matching noise:
- σ is sampled from the sigma table (
linspace(sigma_max, sigma_min, N+1)[:-1]with--train_shiftapplied,N = --train_sampling_steps;sigma_max/sigma_mincome from the pretrainedscheduler/config); x_t = (1-σ)·x0 + σ·noise;
- σ is sampled from the sigma table (
- ti2v inpainting:
cond_latentoverwrites the temporal prefix ofx_t(clean frame, no noise) and aframe_mask(condition frames = 0) is recorded; - Forward:
transformer(x_t, σ·1000, prompt_embeds, encoder_attention_mask=prompt_mask)(the timestep scaling matches the pipeline's_transformer_timestep); - Loss:
Σ(MSE(noise_pred, noise - x0) · frame_mask · weighting) / (frame_mask.sum() · C · H · W)— condition frames never contribute, andweightingcomes from--weighting_scheme(all-ones fornone). TheC·H·Wfactor is a constant rescaling of the reported/optimized loss.
Numerically sensitive modules (norm / router / modulation / scale_shift_table) stay in fp32 following the model's own rules while the rest runs in bf16, identical to inference. Under FSDP every parameter and buffer is force-cast to bf16 instead, because one flat FSDP shard requires a uniform dtype.
4. Inference Testing
The produced checkpoint-*/diffusion_pytorch_model.safetensors can be fed directly to examples/lingbot_video/predict_i2v.py via transformer_path:
# examples/lingbot_video/predict_i2v.py
model_name = "models/Diffusion_Transformer/lingbot-video-dense-1.3b"
transformer_path = "output_dir_lingbot_video_ti2v/checkpoint-500/diffusion_pytorch_model.safetensors"
shift = 3.0 # keep it equal to the training --train_shift
Then:
python examples/lingbot_video/predict_i2v.py
The script loads the fine-tuned weights on top of the base model (load_state_dict(..., strict=False)) and runs ti2v sampling; predict_t2v.py / predict_t2v_refine.py work the same way (the latter additionally needs the refiner weights, which only ship with the MoE model as its refiner subfolder).
predict_i2v.py also turns its plain prompt into a JSON caption on its own: before any generation model is loaded it calls ensure_json_caption(..., mode="ti2v", duration=round(video_length / fps, 2), first_frame=validation_image, base=rewriter_base_model, adapter=rewriter_lora_path) and caches the result in save_path/caption_cache.json. rewriter_base_model / rewriter_lora_path already point at the weights from 2.3; passing an already-valid JSON caption skips the rewriter entirely.
5. FAQ
- OOM: first try
--gradient_checkpointing+--train_batch_size=1; then--low_vram; otherwise reduce--video_sample_n_frames/--video_sample_size. - Loss not decreasing: make sure
--train_shiftmatches inference (3.0); check that captions match the video content; on tiny datasets you may raise--learning_rate(e.g. 5e-5) to see whether the model responds at all. - MoE 30B training: the code path supports it natively (FSDP FULL_SHARD wraps
LingBotVideoBlock, experts travel with their block), but validate the data pipeline on dense 1.3B first. - EMA:
--use_emaonly works in non-FSDP (single-GPU/DDP) mode; FSDP FULL_SHARD is incompatible with EMAModel. - Batch > 1: it works, but Qwen3-VL right-pads the text and the DiT takes the attention_mask path, which is slower than batch=1.
... dataset captions are NOT structured JSON captionswarning at startup: the metadata still holds natural-language text — convert it as described in 2.3. Training continues, but the DiT is fed out-of-distribution prompts.- Validation crashes:
log_validationcatches every exception, printsEval error on rank ...and continues training, so a failed sampling step never aborts the run — check the printed message (typically a non-JSON validation prompt or a missing--validation_pathsimage).