This commit is contained in:
yatchiya
2026-08-21 18:54:21 +02:00
parent 1bab711820
commit 3a40795776
11 changed files with 1916 additions and 31 deletions

View File

@@ -100,7 +100,7 @@ VOLC_ACCESSKEY=
VOLC_SECRETKEY=
# Set to "true" for local video gen (needs GPU + diffusers).
VIDEO_GEN_LOCAL_ENABLED=
# Local model: wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b.
# Local model: wan2.2-ti2v-5b, wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b.
VIDEO_GEN_LOCAL_MODEL=
# Modal self-hosted LTX-2 endpoint (optional).
MODAL_LTX2_ENDPOINT_URL=

3
.gitignore vendored
View File

@@ -101,3 +101,6 @@ venv/
# Workspace-local third-party runtimes (for example the portable Blender LTS
# used by blender_world). These are checksum-verified but never committed.
.runtime/
# Local Wan / diffusers GPU environment
.venv-wan/

View File

@@ -272,7 +272,7 @@ make install-gpu
# Then add to .env:
VIDEO_GEN_LOCAL_ENABLED=true
VIDEO_GEN_LOCAL_MODEL=wan2.1-1.3b # or wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b
VIDEO_GEN_LOCAL_MODEL=wan2.2-ti2v-5b # or wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b
```
</details>

View File

@@ -200,7 +200,7 @@ make install-gpu
# 然后添加到 .env:
VIDEO_GEN_LOCAL_ENABLED=true
VIDEO_GEN_LOCAL_MODEL=wan2.1-1.3b # 或 wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b
VIDEO_GEN_LOCAL_MODEL=wan2.2-ti2v-5b # 或 wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b
```
</details>

View File

@@ -73,7 +73,7 @@ TENCENT_TOKENHUB_API_KEY= # Tencent Hunyuan cloud video via TokenHub API
# LOCAL (no keys needed — just GPU + install)
VIDEO_GEN_LOCAL_ENABLED= # Set to "true" for local video gen
VIDEO_GEN_LOCAL_MODEL= # wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b
VIDEO_GEN_LOCAL_MODEL= # wan2.2-ti2v-5b, wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b
# COMFYUI (optional overrides; localhost:8188 is the default)
COMFYUI_SERVER_URL= # Local ComfyUI server for shared workflows
@@ -1261,8 +1261,9 @@ pip install diffusers transformers accelerate torch pillow requests
VIDEO_GEN_LOCAL_ENABLED=true
# 3. Choose a model based on your GPU VRAM
VIDEO_GEN_LOCAL_MODEL=wan2.1-1.3b # 6GB+ VRAM (entry-level)
VIDEO_GEN_LOCAL_MODEL=wan2.1-14b # 24GB+ VRAM (best local quality)
VIDEO_GEN_LOCAL_MODEL=wan2.2-ti2v-5b # 12GB+ VRAM (default; 720p @ 24fps, T2V+I2V+V2V)
VIDEO_GEN_LOCAL_MODEL=wan2.1-1.3b # 6GB+ VRAM (entry-level, text-to-video only)
VIDEO_GEN_LOCAL_MODEL=wan2.1-14b # 24GB+ VRAM (best Wan 2.1 quality)
VIDEO_GEN_LOCAL_MODEL=hunyuan-1.5 # 12GB+ VRAM
VIDEO_GEN_LOCAL_MODEL=ltx2-local # 8GB+ VRAM (fastest)
VIDEO_GEN_LOCAL_MODEL=cogvideo-5b # 10GB+ VRAM
@@ -1273,14 +1274,76 @@ VIDEO_GEN_LOCAL_MODEL=cogvideo-2b # 6GB+ VRAM (lightest)
| Model | VRAM | Quality | Speed | Best for |
|-------|------|---------|-------|----------|
| **WAN 2.1 (1.3B)** | 6GB | Good | Fast | Entry-level GPU, quick iteration |
| **WAN 2.1 (14B)** | 24GB | Excellent | Slow | Best quality-to-VRAM ratio |
| **WAN 2.2 TI2V (5B)** | 12GB | Excellent | Medium | Default. 720p @ 24fps, one checkpoint for T2V/I2V/V2V |
| **WAN 2.1 (1.3B)** | 6GB | Good | Fast | Entry-level GPU, quick iteration (text-to-video only) |
| **WAN 2.1 (14B)** | 24GB | Excellent | Slow | Best Wan 2.1 quality |
| **Hunyuan 1.5** | 12GB | Very good | Medium | Mid-range GPUs |
| **LTX-2** | 8GB | Good | Fastest | Quick drafts, lowest latency |
| **CogVideo (5B)** | 10GB | Good | Medium | Balanced option |
| **CogVideo (2B)** | 6GB | Fair | Fast | Low-VRAM experimentation |
**All local models support:** Image-to-video, text-to-video, offline generation, seeded reproducibility.
**All local models support:** text-to-video, offline generation, seeded reproducibility.
#### WAN 2.2 — the full operation matrix
`wan_video` drives one checkpoint across every Wan task. The `operation` input selects
the diffusers pipeline; the tool refuses an operation the chosen variant has no weights for.
| `operation` | What it does | Extra inputs |
|-------------|--------------|--------------|
| `text_to_video` | Prompt to clip | — |
| `image_to_video` | Animate a still | `reference_image_path` / `reference_image_url` |
| `video_to_video` | Restyle an existing clip | `source_video_path`, `strength` |
| `first_last_frame` | Interpolate between two stills | `reference_image_*` + `last_image_*` |
| `text_to_image` | Single frame, saved as PNG | — |
Geometry is snapped to what the VAE can encode, so you can ask for any size: frame counts
land on `4k + 1`, and width/height land on a multiple of 32 for the TI2V line (its 16x VAE
plus 2x patching is why 720p is **1280x704**, not 1280x720) or 16 elsewhere.
#### Clips longer than five seconds
Wan is trained for roughly a five-second pass. Ask for more with `duration_seconds` and the
tool chains segments: each continuation is seeded with the previous segment's last frame,
regenerates it as its own frame 0, and that duplicate is dropped at the seam.
```python
wan_video.execute({
"prompt": "A red convertible on a coastal road at golden hour",
"duration_seconds": 30, # -> 720 frames @ 24fps -> 6 chained segments
"width": 704, "height": 480,
"num_inference_steps": 20,
"offload_mode": "sequential",
"segment_prompts": [...], # optional shot list, one entry per segment
})
```
Chaining drifts: every hop conditions on already-generated pixels, so contrast and colour
creep. `correct_drift` (on by default) re-grades each segment to the opening segment's
channel statistics. Pass `segment_prompts` to steer the clip through a shot list instead of
repeating one prompt six times.
#### Fitting a 12GB card
`precision` (`auto`/`bf16`/`int8`/`int4`) and `offload_mode` (`auto`/`model`/`sequential`)
both default to `auto` and are picked from visible VRAM.
A bf16 5B transformer is ~10GB resident. With `offload_mode="model"` that leaves almost
nothing for activations on a 12GB card — measured peak was 10.85GB at just 512x320.
`offload_mode="sequential"` streams one submodule at a time: the same card then peaks at
**1.97GB** and renders 704x480 comfortably, at the cost of ~4s per denoising step of PCIe
traffic. On a 12GB GPU `auto` selects it for you.
> **NVIDIA driver note.** If the loaded kernel module and the installed userspace libraries
> are different versions, `nvmlInit` fails. CUDA compute still runs, but PyTorch calls NVML
> while composing an out-of-memory report — so real OOMs surface as an internal assert, and
> bitsandbytes (`int8`/`int4`) cannot load at all. Compare `cat /proc/driver/nvidia/version`
> with `nvidia-smi`; if they disagree, reboot. `wan_video` detects this and says so.
Image-to-video is *not* universal — Wan 2.1 never shipped 1.3B I2V weights and CogVideo is
text-only. Each variant's real capability list is `operations` in `tools/video/_shared.py`,
and `wan_video` refuses an operation the selected checkpoint cannot do.
---

115
scripts/wan_disk_cleanup.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
#
# Reclaim disk used by local Wan video generation.
#
# Local generation is free of API cost but not of disk: the Wan 2.2 TI2V-5B
# checkpoint alone is ~32GB, the CUDA-enabled virtualenv is ~7GB, and uv keeps
# every wheel it has ever downloaded.
#
# Nothing is deleted unless you pass a target. Without --yes it only reports.
#
# ./scripts/wan_disk_cleanup.sh # show what is on disk
# ./scripts/wan_disk_cleanup.sh --models --yes # delete Wan model weights
# ./scripts/wan_disk_cleanup.sh --all --yes # weights + venv + uv cache
#
# Deleted weights are re-downloaded automatically on the next run, so this is
# safe to run whenever you need the space back.
set -euo pipefail
HF_HOME_DIR="${HF_HOME:-$HOME/.cache/huggingface}"
HUB_DIR="$HF_HOME_DIR/hub"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV_DIR="$REPO_ROOT/.venv-wan"
UV_CACHE_DIR="${UV_CACHE_DIR:-$HOME/.cache/uv}"
# Only these are touched by --models. Other repos in the shared HF cache
# (OCR models, LLMs, anything another project put there) are left alone.
WAN_PATTERNS=(
"models--Wan-AI--*"
"models--FastVideo--FastWan*"
"models--lightx2v--Wan*"
"models--Comfy-Org--Wan*"
"models--QuantStack--Wan*"
)
DO_MODELS=0; DO_VENV=0; DO_UV=0; CONFIRM=0
for arg in "$@"; do
case "$arg" in
--models) DO_MODELS=1 ;;
--venv) DO_VENV=1 ;;
--uv-cache) DO_UV=1 ;;
--all) DO_MODELS=1; DO_VENV=1; DO_UV=1 ;;
--yes|-y) CONFIRM=1 ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown option: $arg (try --help)" >&2; exit 2 ;;
esac
done
human () { du -sh "$1" 2>/dev/null | cut -f1; }
# Expand the Wan patterns into real paths.
wan_paths () {
local pattern path
for pattern in "${WAN_PATTERNS[@]}"; do
for path in "$HUB_DIR"/$pattern; do
[ -e "$path" ] && printf '%s\n' "$path"
done
done
}
echo "=== Current usage ==="
if [ -d "$HUB_DIR" ]; then
total_wan=0
while IFS= read -r path; do
printf ' %-8s %s\n' "$(human "$path")" "$(basename "$path")"
total_wan=$(( total_wan + $(du -sk "$path" 2>/dev/null | cut -f1) ))
done < <(wan_paths)
[ "$total_wan" -gt 0 ] \
&& printf ' %-8s Wan model weights (total)\n' "$(( total_wan / 1024 / 1024 ))G" \
|| echo " (no Wan weights cached)"
printf ' %-8s entire HF cache at %s\n' "$(human "$HF_HOME_DIR")" "$HF_HOME_DIR"
fi
[ -d "$VENV_DIR" ] && printf ' %-8s %s\n' "$(human "$VENV_DIR")" "$VENV_DIR"
[ -d "$UV_CACHE_DIR" ] && printf ' %-8s %s\n' "$(human "$UV_CACHE_DIR")" "$UV_CACHE_DIR"
echo
if [ $((DO_MODELS + DO_VENV + DO_UV)) -eq 0 ]; then
echo "Nothing selected. Pass --models, --venv, --uv-cache or --all (add --yes to delete)."
exit 0
fi
targets=()
[ "$DO_MODELS" -eq 1 ] && while IFS= read -r path; do targets+=("$path"); done < <(wan_paths)
[ "$DO_VENV" -eq 1 ] && [ -d "$VENV_DIR" ] && targets+=("$VENV_DIR")
[ "$DO_UV" -eq 1 ] && [ -d "$UV_CACHE_DIR" ] && targets+=("$UV_CACHE_DIR")
if [ "${#targets[@]}" -eq 0 ]; then
echo "Nothing to delete."
exit 0
fi
echo "=== Would delete ==="
for path in "${targets[@]}"; do printf ' %-8s %s\n' "$(human "$path")" "$path"; done
echo
if [ "$CONFIRM" -ne 1 ]; then
echo "Dry run. Re-run with --yes to actually delete."
exit 0
fi
for path in "${targets[@]}"; do
echo "removing $path"
rm -rf -- "$path"
done
echo
echo "Done. Free space now:"
df -h "$HOME" | tail -1
echo
echo "To rebuild: model weights re-download on the next wan_video run;"
echo "recreate the venv with"
echo " uv venv --python 3.12 .venv-wan"
echo " uv pip install --python .venv-wan/bin/python torch torchvision --index-url https://download.pytorch.org/whl/cu128"
echo " uv pip install --python .venv-wan/bin/python 'diffusers>=0.36' transformers accelerate safetensors sentencepiece imageio imageio-ffmpeg ftfy protobuf"

View File

@@ -0,0 +1,509 @@
"""Contract tests for the local Wan 2.x video tool.
These run without a GPU, without model weights and without network: they cover
the tool contract, the variant catalog's internal consistency, and the frame /
segment arithmetic that decides how a long clip is chained together.
"""
import pytest
from tools.base_tool import (
BaseTool,
ToolRuntime,
ToolStability,
ToolStatus,
ToolTier,
)
from tools.tool_registry import ToolRegistry
from tools.video._shared import WAN_VARIANTS
from tools.video._wan_engine import (
_PIPELINE_FOR_OPERATION,
_oom_aware_error,
generate_wan,
plan_segments,
resolve_offload_mode,
resolve_precision,
snap_dimension,
snap_frames,
)
from tools.video.wan_video import DEFAULT_VARIANT, WanVideo
# Wan's VAE compresses 4x in time. Spatial alignment is per-variant: the VAE's
# spatial compression times the transformer patch size, which is 32 for the
# 16x-VAE TI2V line and 16 for every 8x-VAE Wan line.
TEMPORAL_SCALE = 4
# ------------------------------------------------------------------
# Contract compliance
# ------------------------------------------------------------------
class TestContract:
def test_inherits_base_tool(self):
assert issubclass(WanVideo, BaseTool)
def test_identity(self):
tool = WanVideo()
assert tool.name == "wan_video"
assert tool.provider == "wan"
assert tool.capability == "video_generation"
assert tool.tier == ToolTier.GENERATE
assert tool.stability == ToolStability.EXPERIMENTAL
assert tool.runtime == ToolRuntime.LOCAL_GPU
def test_input_schema(self):
schema = WanVideo().input_schema
assert schema["type"] == "object"
assert "prompt" in schema["required"]
assert schema["properties"]["model_variant"]["default"] == DEFAULT_VARIANT
def test_local_generation_is_free(self):
assert WanVideo().estimate_cost({"prompt": "x"}) == 0.0
def test_status_requires_opt_in(self, monkeypatch):
monkeypatch.delenv("VIDEO_GEN_LOCAL_ENABLED", raising=False)
assert WanVideo().get_status() == ToolStatus.UNAVAILABLE
def test_registry_discovers_tool(self):
registry = ToolRegistry()
registry.discover()
assert "wan_video" in registry.list_all()
assert registry.get("wan_video").provider == "wan"
def test_schema_operations_match_engine(self):
declared = set(WanVideo().input_schema["properties"]["operation"]["enum"])
assert declared == set(_PIPELINE_FOR_OPERATION)
# ------------------------------------------------------------------
# Variant catalog
# ------------------------------------------------------------------
@pytest.mark.parametrize("key", sorted(WAN_VARIANTS), ids=lambda k: k)
class TestVariantCatalog:
def test_required_fields(self, key):
meta = WAN_VARIANTS[key]
for field in (
"name",
"hf_id",
"params_b",
"operations",
"default_width",
"default_height",
"default_num_frames",
"spatial_alignment",
"temporal_scale",
"default_steps",
"default_guidance",
"fps",
"license",
):
assert field in meta, f"{key} is missing {field}"
def test_operations_are_known_and_non_empty(self, key):
operations = WAN_VARIANTS[key]["operations"]
assert operations
assert set(operations) <= set(_PIPELINE_FOR_OPERATION)
def test_default_geometry_is_vae_aligned(self, key):
meta = WAN_VARIANTS[key]
alignment = meta["spatial_alignment"]
assert meta["default_width"] % alignment == 0
assert meta["default_height"] % alignment == 0
def test_declares_vae_scales(self, key):
meta = WAN_VARIANTS[key]
assert meta["spatial_alignment"] in (16, 32)
assert meta["temporal_scale"] == TEMPORAL_SCALE
def test_default_frame_count_is_vae_aligned(self, key):
frames = WAN_VARIANTS[key]["default_num_frames"]
assert (frames - 1) % TEMPORAL_SCALE == 0
def test_image_operations_have_a_checkpoint(self, key):
"""A variant may only claim an operation it has weights for."""
meta = WAN_VARIANTS[key]
for operation in meta["operations"]:
assert meta.get(f"hf_{operation}_id") or meta["hf_id"]
def test_legacy_flags_agree_with_operations(self, key):
meta = WAN_VARIANTS[key]
assert meta["t2v"] is ("text_to_video" in meta["operations"])
assert meta["i2v"] is ("image_to_video" in meta["operations"])
def test_wan21_1_3b_does_not_claim_image_to_video():
"""Wan 2.1 never shipped 1.3B I2V weights; claiming it pulled a 14B model."""
meta = WAN_VARIANTS["wan2.1-1.3b"]
assert "image_to_video" not in meta["operations"]
assert "1.3B" in meta["hf_id"] or "1.3B" in meta["name"]
def test_default_variant_covers_the_full_matrix():
"""The default must handle every operation the tool advertises."""
assert set(WAN_VARIANTS[DEFAULT_VARIANT]["operations"]) == set(_PIPELINE_FOR_OPERATION)
# ------------------------------------------------------------------
# Frame and segment arithmetic
# ------------------------------------------------------------------
class TestGeometry:
@pytest.mark.parametrize("value", [1, 5, 25, 80, 81, 100, 121, 720])
def test_snap_frames_lands_on_a_latent_boundary(self, value):
snapped = snap_frames(value)
assert snapped == 1 or (snapped - 1) % TEMPORAL_SCALE == 0
assert snapped >= 1
def test_snap_frames_is_idempotent(self):
assert snap_frames(snap_frames(97)) == snap_frames(97)
def test_snap_frames_rejects_zero(self):
with pytest.raises(ValueError):
snap_frames(0)
@pytest.mark.parametrize("value", [17, 300, 480, 704, 1280])
def test_snap_dimension_aligns(self, value):
assert snap_dimension(value, 32) % 32 == 0
def test_snap_dimension_never_returns_zero(self):
assert snap_dimension(3, 32) == 32
class TestSegmentPlanning:
@pytest.mark.parametrize("segment", [25, 49, 121])
def test_plan_covers_every_requested_length(self, segment):
"""Segments must always reach the target; the tail is trimmed, never short.
Swept rather than spot-checked: an off-by-one in the carry (using the
segment length instead of segment-1) still lands on the right answer for
most inputs and only shows up at particular totals.
"""
for total in range(1, 40 * segment):
count = plan_segments(total, segment)
covered = segment + (count - 1) * (segment - 1)
assert covered >= total, f"{total} frames left short by {total - covered}"
# and must never buy a segment it does not need
if count > 1:
previous = segment + (count - 2) * (segment - 1)
assert previous < total
def test_carry_accounts_for_the_duplicated_anchor_frame(self):
"""Each continuation reproduces the anchor, so it adds segment-1 frames."""
segment = 121
# 1210 is an exact multiple of the segment length but not of the carry,
# which is where an off-by-one in the carry first becomes visible.
total = segment + 1210
count = plan_segments(total, segment)
assert segment + (count - 1) * (segment - 1) >= total
def test_single_segment_when_it_fits(self):
assert plan_segments(121, 121) == 1
assert plan_segments(50, 121) == 1
def test_thirty_seconds_at_24fps(self):
"""The headline case: 30s of 24fps video off a 121-frame model."""
count = plan_segments(30 * 24, 121)
assert count == 6
assert 121 + (count - 1) * 120 >= 720
class TestPrecisionSelection:
def test_explicit_precision_is_respected(self):
assert resolve_precision("int4", 5.0) == "int4"
assert resolve_precision("bf16", 28.0) == "bf16"
def test_auto_degrades_as_the_model_grows(self, monkeypatch):
monkeypatch.setattr("tools.video._wan_engine._vram_gb", lambda: 12.0)
assert resolve_precision("auto", 1.3) == "bf16"
assert resolve_precision("auto", 28.0) == "int4"
def test_auto_on_a_big_card_stays_bf16(self, monkeypatch):
monkeypatch.setattr("tools.video._wan_engine._vram_gb", lambda: 80.0)
assert resolve_precision("auto", 28.0) == "bf16"
def test_cpu_never_asks_for_a_cuda_only_quantizer(self, monkeypatch):
monkeypatch.setattr("tools.video._wan_engine._vram_gb", lambda: 0.0)
assert resolve_precision("auto", 28.0) == "bf16"
# ------------------------------------------------------------------
# Planning surface on the tool
# ------------------------------------------------------------------
class TestPlan:
def test_duration_drives_segment_count(self):
plan = WanVideo()._plan({"prompt": "x", "duration_seconds": 30})
assert plan["fps"] == 24
assert plan["total_frames"] == 720
assert plan["segments"] == 6
def test_native_length_needs_one_segment(self):
plan = WanVideo()._plan({"prompt": "x"})
assert plan["segments"] == 1
def test_explicit_num_frames_overrides_duration(self):
plan = WanVideo()._plan({"prompt": "x", "duration_seconds": 30, "num_frames": 49})
assert plan["total_frames"] == 49
assert plan["segments"] == 1
def test_text_to_image_is_a_single_frame(self):
plan = WanVideo()._plan({"prompt": "x", "operation": "text_to_image", "duration_seconds": 30})
assert plan["total_frames"] == 1
assert plan["segments"] == 1
def test_longer_request_estimates_longer_runtime(self):
tool = WanVideo()
short = tool.estimate_runtime({"prompt": "x", "duration_seconds": 5})
long = tool.estimate_runtime({"prompt": "x", "duration_seconds": 30})
assert long > short
# ------------------------------------------------------------------
# Validation happens before any weights are touched
# ------------------------------------------------------------------
def _generate(**inputs):
return generate_wan(
tool_name="wan_video",
variants=WAN_VARIANTS,
default_variant=DEFAULT_VARIANT,
inputs={"prompt": "a cat", **inputs},
)
class TestValidation:
def test_unknown_variant_is_rejected(self):
result = _generate(model_variant="wan9000")
assert not result.success
assert "Unknown model_variant" in result.error
def test_unknown_operation_is_rejected(self):
result = _generate(operation="mind_to_video")
assert not result.success
assert "Unknown operation" in result.error
def test_operation_unsupported_by_variant_is_rejected(self):
"""wan2.1-1.3b has no I2V weights — this must fail fast, not load 14B."""
result = _generate(model_variant="wan2.1-1.3b", operation="image_to_video")
assert not result.success
assert "does not support" in result.error
def test_missing_prompt_is_rejected(self):
result = generate_wan(
tool_name="wan_video",
variants=WAN_VARIANTS,
default_variant=DEFAULT_VARIANT,
inputs={"prompt": ""},
)
assert not result.success
assert "prompt is required" in result.error
def test_unavailable_tool_reports_install_instructions(self, monkeypatch):
monkeypatch.delenv("VIDEO_GEN_LOCAL_ENABLED", raising=False)
result = WanVideo().execute({"prompt": "a cat"})
assert not result.success
assert "VIDEO_GEN_LOCAL_ENABLED" in result.error
def test_ti2v_is_the_only_32_aligned_line():
"""The 16x VAE plus 2x patching is what forces 1280x704 instead of 1280x720."""
assert WAN_VARIANTS["wan2.2-ti2v-5b"]["spatial_alignment"] == 32
assert WAN_VARIANTS["wan2.2-ti2v-5b"]["default_height"] == 704
for key, meta in WAN_VARIANTS.items():
if key != "wan2.2-ti2v-5b":
assert meta["spatial_alignment"] == 16
# ------------------------------------------------------------------
# Offload strategy and allocator diagnostics
# ------------------------------------------------------------------
class TestOffloadSelection:
def test_explicit_mode_is_respected(self):
assert resolve_offload_mode("model", "bf16", 5.0) == "model"
assert resolve_offload_mode("sequential", "bf16", 1.3) == "sequential"
def test_bf16_5b_on_a_12gb_card_goes_sequential(self, monkeypatch):
"""10GB of resident bf16 weights leaves no room for activations."""
monkeypatch.setattr("tools.video._wan_engine._vram_gb", lambda: 11.6)
assert resolve_offload_mode("auto", "bf16", 5.0) == "sequential"
def test_quantized_weights_fit_resident_on_the_same_card(self, monkeypatch):
monkeypatch.setattr("tools.video._wan_engine._vram_gb", lambda: 11.6)
assert resolve_offload_mode("auto", "int4", 5.0) == "model"
def test_big_card_keeps_weights_resident(self, monkeypatch):
monkeypatch.setattr("tools.video._wan_engine._vram_gb", lambda: 80.0)
assert resolve_offload_mode("auto", "bf16", 28.0) == "model"
def test_cpu_only_does_not_ask_for_sequential(self, monkeypatch):
monkeypatch.setattr("tools.video._wan_engine._vram_gb", lambda: 0.0)
assert resolve_offload_mode("auto", "bf16", 28.0) == "model"
class TestAllocatorDiagnostics:
def test_nvml_assert_is_reported_as_a_driver_mismatch(self):
"""PyTorch calls NVML while reporting OOM, so a driver mismatch masks it."""
result = _oom_aware_error(
RuntimeError(
"NVML_SUCCESS == DriverAPI::get()->nvmlInit_v2_() INTERNAL ASSERT FAILED"
),
"wan2.2-ti2v-5b", "bf16", "sequential", 704, 480, 121,
)
assert not result.success
assert "driver" in result.error.lower()
assert "reboot" in result.error.lower()
def test_real_oom_suggests_sequential_offload_first(self):
result = _oom_aware_error(
RuntimeError("CUDA out of memory. Tried to allocate 2.00 GiB"),
"wan2.2-ti2v-5b", "bf16", "model", 1280, 704, 121,
)
assert not result.success
assert "sequential" in result.error
def test_oom_already_sequential_does_not_suggest_it_again(self):
result = _oom_aware_error(
RuntimeError("CUDA out of memory"),
"wan2.2-ti2v-5b", "bf16", "sequential", 1280, 704, 121,
)
assert 'offload_mode="sequential"' not in result.error
assert "resolution" in result.error
def test_unrelated_failure_is_passed_through(self):
result = _oom_aware_error(
ValueError("something else entirely"),
"wan2.2-ti2v-5b", "bf16", "model", 704, 480, 49,
)
assert "something else entirely" in result.error
class TestRuntimeEstimate:
def test_scales_with_segment_count(self):
"""Each chained segment pays the full per-step cost again."""
tool = WanVideo()
base = dict(prompt="x", width=704, height=480, num_inference_steps=20,
offload_mode="sequential")
one = tool.estimate_runtime({**base, "num_frames": 121})
six = tool.estimate_runtime({**base, "duration_seconds": 30})
assert 5.0 < (six / one) < 7.0
def test_matches_measured_704x480_run(self):
"""Fitted against a real run: 49 frames, 20 steps, sequential -> 248s."""
estimate = WanVideo().estimate_runtime({
"prompt": "x", "num_frames": 49, "width": 704, "height": 480,
"num_inference_steps": 20, "offload_mode": "sequential",
})
assert 0.7 < estimate / 248.0 < 1.4
def test_sequential_costs_more_than_resident(self):
tool = WanVideo()
base = dict(prompt="x", num_frames=49, width=704, height=480, num_inference_steps=20)
assert (
tool.estimate_runtime({**base, "offload_mode": "sequential"})
> tool.estimate_runtime({**base, "offload_mode": "model"})
)
# ------------------------------------------------------------------
# Single-pass operations must not be handed a long-form frame count
# ------------------------------------------------------------------
class TestNonChainableOperations:
"""Only text_to_video and image_to_video can continue from a last frame.
Everything else is single-pass, so a long ``duration_seconds`` must be
clamped to the model's native clip length instead of being sent to the GPU
as one enormous denoise.
"""
@staticmethod
def _run(monkeypatch, **inputs):
"""Drive generate_wan with the GPU replaced by a recorder."""
from PIL import Image
from tools.video import _wan_engine
calls = {}
class FakePipeline:
vae_scale_factor_temporal = 4
vae_scale_factor_spatial = 16
transformer = None
monkeypatch.setattr(_wan_engine, "load_wan_pipeline", lambda **kw: FakePipeline())
monkeypatch.setattr(_wan_engine, "pipeline_scales", lambda pipeline: (4, 32))
def fake_single(pipeline, **kwargs):
calls["num_frames"] = kwargs["num_frames"]
return [Image.new("RGB", (16, 16))] * kwargs["num_frames"]
def fake_chained(pipeline, **kwargs):
calls["segment_count"] = kwargs["segment_count"]
total = kwargs["total_frames"]
return [Image.new("RGB", (16, 16))] * total, []
monkeypatch.setattr(_wan_engine, "_generate_single", fake_single)
monkeypatch.setattr(_wan_engine, "_generate_chained", fake_chained)
monkeypatch.setattr(_wan_engine, "export_frames", lambda *a, **k: None)
monkeypatch.setattr("tools.video._shared.probe_output", lambda path: {})
result = _wan_engine.generate_wan(
tool_name="wan_video",
variants=WAN_VARIANTS,
default_variant=DEFAULT_VARIANT,
inputs={"prompt": "x", **inputs},
)
return result, calls
NATIVE = WAN_VARIANTS[DEFAULT_VARIANT]["default_num_frames"]
@pytest.mark.parametrize("operation", ["video_to_video", "first_last_frame"])
def test_long_request_is_clamped_to_one_pass(self, monkeypatch, operation, tmp_path):
result, calls = self._run(
monkeypatch,
operation=operation,
duration_seconds=30,
source_video_path=str(tmp_path / "in.mp4"),
output_path=str(tmp_path / "out.mp4"),
)
assert result.success, result.error
assert result.data["segments"] == 1
assert calls["num_frames"] <= self.NATIVE
# and the caller is told it was clamped rather than silently short-changed
assert result.data["clamped_from_frames"] == 30 * 24
def test_text_to_video_still_chains(self, monkeypatch, tmp_path):
result, calls = self._run(
monkeypatch,
operation="text_to_video",
duration_seconds=30,
output_path=str(tmp_path / "out.mp4"),
)
assert result.success, result.error
assert calls["segment_count"] == 6
assert result.data["clamped_from_frames"] is None
def test_short_request_is_not_marked_as_clamped(self, monkeypatch, tmp_path):
result, _ = self._run(
monkeypatch,
operation="first_last_frame",
num_frames=49,
output_path=str(tmp_path / "out.mp4"),
)
assert result.success, result.error
assert result.data["clamped_from_frames"] is None
def test_chainable_operations_are_exactly_the_video_continuations(self):
chainable = {"text_to_video", "image_to_video"}
assert chainable < set(_PIPELINE_FOR_OPERATION)
assert "video_to_video" not in chainable
assert "first_last_frame" not in chainable

View File

@@ -32,37 +32,136 @@ HEYGEN_PROVIDERS = {
"ltx_distilled": {"name": "LTX Distilled", "quality": "low", "speed": "fastest"},
}
# Wan variants. ``spatial_alignment`` is the VAE spatial compression times the
# transformer patch size — the TI2V line's 16x VAE plus 2x patching means 720p
# is 1280x704, while the 8x-VAE lines take a true 1280x720.
# Wan variants. ``operations`` is the authoritative capability list — the engine
# refuses anything absent from it rather than loading a checkpoint that cannot
# do the job. ``params_b`` is the transformer size used to auto-pick precision.
WAN_VARIANTS = {
"wan2.1-1.3b": {
"name": "Wan 2.1 (1.3B)",
"hf_id": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"hf_i2v_id": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
"wan2.2-ti2v-5b": {
"name": "Wan 2.2 TI2V (5B)",
"hf_id": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
"pipeline_class": "WanPipeline",
"vram_mb": 8000,
"params_b": 5.0,
"vram_mb": 12000,
"quality": "high",
"speed": "medium",
"operations": [
"text_to_video",
"image_to_video",
"video_to_video",
"first_last_frame",
"text_to_image",
],
"t2v": True,
"i2v": True,
"license": "Apache-2.0",
# The TI2V line's 720p geometry is 1280x704, not 1280x720: its VAE
# compresses 16x spatially and the transformer patches 2x on top, so
# both dimensions must be multiples of 32 — and 720 is not.
"default_width": 1280,
"default_height": 704,
"default_num_frames": 121,
"default_steps": 50,
"default_guidance": 5.0,
"spatial_alignment": 32,
"temporal_scale": 4,
"fps": 24,
},
"wan2.2-t2v-a14b": {
"name": "Wan 2.2 T2V MoE (A14B)",
"hf_id": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
"pipeline_class": "WanPipeline",
"params_b": 28.0, # two 14B experts, both resident
"vram_mb": 40000,
"quality": "highest",
"speed": "slow",
"operations": ["text_to_video", "text_to_image"],
"t2v": True,
"i2v": False,
"license": "Apache-2.0",
"default_width": 1280,
"default_height": 720,
"default_num_frames": 81,
"default_steps": 40,
"default_guidance": 4.0,
"spatial_alignment": 16,
"temporal_scale": 4,
"fps": 16,
},
"wan2.2-i2v-a14b": {
"name": "Wan 2.2 I2V MoE (A14B)",
"hf_id": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
"pipeline_class": "WanImageToVideoPipeline",
"params_b": 28.0,
"vram_mb": 40000,
"quality": "highest",
"speed": "slow",
"operations": ["image_to_video", "first_last_frame"],
"t2v": False,
"i2v": True,
"license": "Apache-2.0",
"default_width": 1280,
"default_height": 720,
"default_num_frames": 81,
"default_steps": 40,
"default_guidance": 3.5,
"spatial_alignment": 16,
"temporal_scale": 4,
"fps": 16,
},
"wan2.1-1.3b": {
"name": "Wan 2.1 T2V (1.3B)",
"hf_id": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"pipeline_class": "WanPipeline",
"params_b": 1.3,
"vram_mb": 8000,
"quality": "medium",
"speed": "fast",
# Wan 2.1 never shipped a 1.3B image-to-video checkpoint; the only I2V
# weights in that generation are 14B. Claiming i2v here silently pulled
# a 14B model onto an 8GB budget.
"operations": ["text_to_video", "video_to_video", "text_to_image"],
"t2v": True,
"i2v": False,
"license": "Apache-2.0",
"default_width": 832,
"default_height": 480,
"default_num_frames": 81,
"default_steps": 50,
"default_guidance": 5.0,
"spatial_alignment": 16,
"temporal_scale": 4,
"fps": 16,
},
"wan2.1-14b": {
"name": "Wan 2.1 (14B)",
"hf_id": "Wan-AI/Wan2.1-T2V-14B-Diffusers",
"hf_i2v_id": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
"hf_image_to_video_id": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
"hf_first_last_frame_id": "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers",
"pipeline_class": "WanPipeline",
"params_b": 14.0,
"vram_mb": 24000,
"quality": "highest",
"speed": "slow",
"operations": [
"text_to_video",
"image_to_video",
"video_to_video",
"first_last_frame",
"text_to_image",
],
"t2v": True,
"i2v": True,
"license": "Apache-2.0",
"default_width": 1280,
"default_height": 720,
"default_num_frames": 81,
"default_steps": 50,
"default_guidance": 5.0,
"spatial_alignment": 16,
"temporal_scale": 4,
"fps": 16,
},
}

947
tools/video/_wan_engine.py Normal file
View File

@@ -0,0 +1,947 @@
"""Local Wan 2.x generation engine.
Covers the full Wan operation matrix on a single consumer GPU:
text_to_video WanPipeline
image_to_video WanImageToVideoPipeline
video_to_video WanVideoToVideoPipeline
first_last_frame WanImageToVideoPipeline (``last_image``)
text_to_image WanPipeline with a single frame
The entry point is :func:`generate_wan`. When the requested duration exceeds
what a Wan model produces in one pass (~5s), it chains image-to-video segments
off each previous segment's last frame to reach the target length.
Wan's VAE constrains the request geometry: frame counts must land on
``temporal * k + 1`` and both spatial dimensions must be a multiple of the
spatial compression factor. Callers pass whatever they like; this module snaps
the values and reports what it actually used.
"""
from __future__ import annotations
import gc
import math
from pathlib import Path
from typing import Any
# Wan 2.1 uses a 8x8x4 VAE, Wan 2.2's TI2V line uses the 16x16x4 high-compression
# VAE. Both are read off the loaded pipeline; these are only the fallbacks used
# for planning before a pipeline exists.
_DEFAULT_TEMPORAL_SCALE = 4
_DEFAULT_SPATIAL_SCALE = 16
# Wan's own reference implementation ships this Chinese negative prompt and the
# model is visibly tuned for it; it suppresses the static/oversaturated failure
# mode far better than an English equivalent.
DEFAULT_NEGATIVE_PROMPT = (
"色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,"
"低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,"
"毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
)
_PIPELINE_FOR_OPERATION = {
"text_to_video": "WanPipeline",
"text_to_image": "WanPipeline",
"image_to_video": "WanImageToVideoPipeline",
"first_last_frame": "WanImageToVideoPipeline",
"video_to_video": "WanVideoToVideoPipeline",
}
# Keyed by (model_id, pipeline class, precision, offload enabled, offload mode).
# One loaded pipeline is 10-28GB of host RAM, so at most one is kept alive.
_PIPELINE_CACHE: dict[tuple[str, str, str, bool, str], Any] = {}
# --------------------------------------------------------------------------
# geometry
# --------------------------------------------------------------------------
def snap_frames(num_frames: int, temporal_scale: int = _DEFAULT_TEMPORAL_SCALE) -> int:
"""Snap ``num_frames`` down onto the nearest valid ``temporal_scale * k + 1``.
Wan's causal VAE encodes the first frame on its own and then groups every
following ``temporal_scale`` frames, so only these counts round-trip. We
round to nearest and clamp to a single latent group.
"""
if num_frames < 1:
raise ValueError("num_frames must be >= 1")
if num_frames == 1:
return 1 # single-image mode
k = max(1, round((num_frames - 1) / temporal_scale))
return temporal_scale * k + 1
def snap_dimension(value: int, spatial_scale: int = _DEFAULT_SPATIAL_SCALE) -> int:
"""Snap a width/height onto a multiple of the VAE spatial compression."""
if value < spatial_scale:
return spatial_scale
return int(round(value / spatial_scale)) * spatial_scale
def plan_segments(total_frames: int, segment_frames: int) -> int:
"""Number of chained segments needed to cover ``total_frames``.
The first segment contributes all of its frames. Every continuation is
seeded with the previous segment's last frame, so it reproduces that frame
and contributes only ``segment_frames - 1`` new ones.
"""
if total_frames <= segment_frames:
return 1
carry = segment_frames - 1
return 1 + math.ceil((total_frames - segment_frames) / carry)
# --------------------------------------------------------------------------
# loading
# --------------------------------------------------------------------------
def _vram_gb() -> float:
try:
import torch
if not torch.cuda.is_available():
return 0.0
return torch.cuda.get_device_properties(0).total_memory / 1024**3
except Exception:
return 0.0
def resolve_precision(precision: str, model_params_b: float) -> str:
"""Pick a weight precision that fits the visible GPU.
``model_params_b`` is the transformer size in billions of parameters; bf16
needs roughly 2GB per billion resident on the device, and cpu-offload only
keeps one module at a time, so the transformer alone sets the floor.
"""
if precision != "auto":
return precision
vram = _vram_gb()
if vram <= 0:
return "bf16" # CPU/MPS: quantization backends are CUDA-only
bf16_need = model_params_b * 2.0 + 2.5 # weights + activation headroom
if vram >= bf16_need:
return "bf16"
if vram >= model_params_b * 1.0 + 2.5:
return "int8"
return "int4"
def resolve_offload_mode(offload_mode: str, precision: str, model_params_b: float) -> str:
"""Choose how aggressively to offload weights to host RAM.
``model`` keeps a whole component resident and is fastest. It only works if
the largest component plus its activations fit; a bf16 5B transformer is
10GB, so on a 12GB card that leaves too little for anything above about
512x320. ``sequential`` streams one submodule at a time, costing PCIe
bandwidth per step but freeing almost the entire card for activations.
"""
if offload_mode != "auto":
return offload_mode
vram = _vram_gb()
if vram <= 0:
return "model"
bytes_per_param = {"int4": 0.55, "int8": 1.05}.get(precision, 2.0)
resident = model_params_b * bytes_per_param
# Need room for the weights and a working set of activations on top.
return "model" if vram >= resident + 3.0 else "sequential"
def _quant_config(precision: str):
from diffusers import BitsAndBytesConfig
import torch
if precision == "int8":
return BitsAndBytesConfig(load_in_8bit=True)
return BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
def free_wan_pipelines() -> None:
"""Drop every cached pipeline and release GPU memory."""
_PIPELINE_CACHE.clear()
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
def load_wan_pipeline(
*,
model_id: str,
operation: str,
precision: str = "bf16",
enable_offload: bool = True,
offload_mode: str = "model",
):
"""Load (and cache) the Wan pipeline class matching ``operation``.
Wan repos declare a single ``_class_name`` in ``model_index.json`` but the
same weights back several tasks, so the pipeline class is chosen from the
operation rather than the repo metadata. Components absent from the repo
(Wan 2.2 TI2V has no CLIP image encoder) are optional on the diffusers
classes and resolve to ``None``.
"""
import diffusers
import torch
from tools.video._shared import get_torch_device
pipeline_name = _PIPELINE_FOR_OPERATION.get(operation)
if pipeline_name is None:
raise ValueError(
f"Unsupported operation: {operation}. "
f"Expected one of {', '.join(sorted(_PIPELINE_FOR_OPERATION))}"
)
key = (model_id, pipeline_name, precision, enable_offload, offload_mode)
if key in _PIPELINE_CACHE:
return _PIPELINE_CACHE[key]
# Only one Wan pipeline fits in host RAM at a time.
free_wan_pipelines()
device = get_torch_device()
if device == "cuda" and torch.cuda.is_bf16_supported():
dtype = torch.bfloat16
elif device == "cpu":
dtype = torch.float32
else:
dtype = torch.float16
pipeline_class = getattr(diffusers, pipeline_name)
kwargs: dict[str, Any] = {"torch_dtype": dtype}
if precision in {"int4", "int8"} and device == "cuda":
# Quantize the transformer(s) only. The T5 text encoder runs once per
# prompt and the VAE once per clip, so offloading those is cheaper than
# paying the dequantization cost on every denoising step.
from diffusers import WanTransformer3DModel
quant = _quant_config(precision)
transformer = WanTransformer3DModel.from_pretrained(
model_id, subfolder="transformer", quantization_config=quant, torch_dtype=dtype
)
kwargs["transformer"] = transformer
if _has_subfolder(model_id, "transformer_2"):
kwargs["transformer_2"] = WanTransformer3DModel.from_pretrained(
model_id, subfolder="transformer_2", quantization_config=quant, torch_dtype=dtype
)
pipeline = pipeline_class.from_pretrained(model_id, **kwargs)
# bitsandbytes modules cannot be moved after load; the accelerate hooks
# dispatch rather than relocate them, so offload still applies.
if enable_offload and device == "cuda":
if offload_mode == "sequential":
# Keeps only the executing submodule on the GPU. A 5B transformer
# in bf16 is 10GB, which leaves nothing for activations on a 12GB
# card; sequential offload trades PCIe bandwidth for that headroom
# and is what makes 480p/720p reachable here at all.
pipeline.enable_sequential_cpu_offload()
else:
pipeline.enable_model_cpu_offload()
elif precision in {"int4", "int8"}:
pass # already placed by the quantization loader
else:
pipeline = pipeline.to(device)
vae = getattr(pipeline, "vae", None)
if vae is not None:
# Tiling keeps VAE decode of a 720p/121-frame clip off the OOM line;
# it is the single biggest peak-memory win for long clips.
if hasattr(vae, "enable_tiling"):
vae.enable_tiling()
if hasattr(vae, "enable_slicing"):
vae.enable_slicing()
pipeline._is_sequentially_offloaded = bool(
enable_offload and device == "cuda" and offload_mode == "sequential"
)
_PIPELINE_CACHE[key] = pipeline
return pipeline
def _has_subfolder(model_id: str, subfolder: str) -> bool:
local = Path(model_id)
if local.is_dir():
return (local / subfolder).is_dir()
try:
from huggingface_hub import model_info
return any(s.rfilename.startswith(f"{subfolder}/") for s in model_info(model_id).siblings)
except Exception:
return False
def pipeline_scales(pipeline) -> tuple[int, int]:
"""Read (temporal, spatial) VAE compression factors off a loaded pipeline."""
temporal = getattr(pipeline, "vae_scale_factor_temporal", None) or _DEFAULT_TEMPORAL_SCALE
spatial = getattr(pipeline, "vae_scale_factor_spatial", None) or _DEFAULT_SPATIAL_SCALE
# Transformer patch size doubles the effective spatial alignment requirement.
transformer = getattr(pipeline, "transformer", None)
patch = getattr(getattr(transformer, "config", None), "patch_size", None)
if patch:
spatial = spatial * int(patch[-1])
return int(temporal), int(spatial)
# --------------------------------------------------------------------------
# media io
# --------------------------------------------------------------------------
def load_image(path: str | None, url: str | None, width: int, height: int):
"""Load a reference still from disk or URL, resized to the target geometry."""
from io import BytesIO
from PIL import Image
if path:
image = Image.open(path).convert("RGB")
elif url:
import requests
response = requests.get(url, timeout=60)
response.raise_for_status()
image = Image.open(BytesIO(response.content)).convert("RGB")
else:
raise ValueError("This operation requires reference_image_path or reference_image_url")
return image.resize((width, height), Image.LANCZOS)
def load_video_frames(path: str, width: int, height: int, max_frames: int) -> list:
"""Read up to ``max_frames`` frames from a video file as resized PIL images."""
import imageio.v3 as iio
from PIL import Image
frames = []
for index, frame in enumerate(iio.imiter(path)):
if index >= max_frames:
break
frames.append(Image.fromarray(frame).convert("RGB").resize((width, height), Image.LANCZOS))
if not frames:
raise ValueError(f"No frames could be read from {path}")
return frames
def export_frames(frames: list, output_path: Path, fps: int) -> None:
"""Encode PIL frames to an H.264 MP4 that plays everywhere."""
import imageio
import numpy as np
output_path.parent.mkdir(parents=True, exist_ok=True)
writer = imageio.get_writer(
str(output_path),
fps=fps,
codec="libx264",
quality=8,
macro_block_size=None,
ffmpeg_params=["-pix_fmt", "yuv420p"],
)
try:
for frame in frames:
writer.append_data(np.asarray(frame))
finally:
writer.close()
def _to_pil_list(output) -> list:
"""Normalise a diffusers video output into a flat list of PIL images."""
from PIL import Image
import numpy as np
frames = output.frames[0] if hasattr(output, "frames") else output.images
result = []
for frame in frames:
if isinstance(frame, Image.Image):
result.append(frame)
else:
array = np.asarray(frame)
if array.dtype != np.uint8:
array = (np.clip(array, 0.0, 1.0) * 255).round().astype(np.uint8)
result.append(Image.fromarray(array))
return result
# --------------------------------------------------------------------------
# drift control
# --------------------------------------------------------------------------
def channel_stats(frames: list) -> tuple:
"""Per-channel mean and std over a list of frames, for drift correction."""
import numpy as np
sample = np.stack([np.asarray(f, dtype=np.float32) for f in frames[:: max(1, len(frames) // 8)]])
return sample.mean(axis=(0, 1, 2)), sample.std(axis=(0, 1, 2)) + 1e-6
def match_channel_stats(frames: list, reference: tuple) -> list:
"""Re-grade ``frames`` so their colour statistics match ``reference``.
Chained image-to-video drifts in contrast and colour temperature every hop,
because each segment is conditioned on an already-generated frame. Matching
each segment back to the opening segment's statistics keeps a 30s chain from
visibly fading or warming over its length.
"""
import numpy as np
from PIL import Image
ref_mean, ref_std = reference
cur_mean, cur_std = channel_stats(frames)
scale = ref_std / cur_std
corrected = []
for frame in frames:
array = np.asarray(frame, dtype=np.float32)
array = (array - cur_mean) * scale + ref_mean
corrected.append(Image.fromarray(np.clip(array, 0, 255).astype(np.uint8)))
return corrected
# --------------------------------------------------------------------------
# generation
# --------------------------------------------------------------------------
def _generator(seed: int | None):
import torch
if seed is None:
return None
# A CPU generator stays reproducible regardless of how the pipeline is
# offloaded across devices.
return torch.Generator(device="cpu").manual_seed(seed)
def _run_pass(
pipeline,
*,
operation: str,
prompt: str,
negative_prompt: str,
width: int,
height: int,
num_frames: int,
steps: int,
guidance_scale: float,
guidance_scale_2: float | None,
seed: int | None,
image=None,
last_image=None,
video=None,
strength: float = 0.8,
) -> list:
"""Run one Wan pipeline call and return its frames as PIL images."""
args: dict[str, Any] = {
"prompt": prompt,
"negative_prompt": negative_prompt,
"height": height,
"width": width,
"num_inference_steps": steps,
"guidance_scale": guidance_scale,
"generator": _generator(seed),
"output_type": "pil",
}
# WanVideoToVideoPipeline derives its length from the input clip and takes
# no num_frames; every other Wan pipeline requires it.
if operation == "video_to_video":
args["video"] = video
args["strength"] = strength
else:
args["num_frames"] = num_frames
# guidance_scale_2 only exists on the MoE (A14B) pipelines that carry a
# second expert; passing it to the 5B dense model raises.
if guidance_scale_2 is not None and getattr(pipeline, "transformer_2", None) is not None:
args["guidance_scale_2"] = guidance_scale_2
if image is not None:
args["image"] = image
if last_image is not None:
args["last_image"] = last_image
return _to_pil_list(pipeline(**args))
def generate_wan(
*,
tool_name: str,
variants: dict[str, dict[str, Any]],
default_variant: str,
inputs: dict[str, Any],
):
"""Generate with a local Wan model, chaining segments for long durations."""
from tools.base_tool import ToolResult
variant_key = inputs.get("model_variant") or default_variant
if variant_key not in variants:
return ToolResult(
success=False,
error=f"Unknown model_variant: {variant_key}. Available: {', '.join(sorted(variants))}",
)
meta = variants[variant_key]
operation = inputs.get("operation", "text_to_video")
if operation not in _PIPELINE_FOR_OPERATION:
return ToolResult(
success=False,
error=(
f"Unknown operation: {operation}. "
f"Expected one of {', '.join(sorted(_PIPELINE_FOR_OPERATION))}"
),
)
supported = meta.get("operations", [])
if operation not in supported:
return ToolResult(
success=False,
error=(
f"{meta['name']} does not support {operation}. "
f"It supports: {', '.join(supported)}."
),
)
prompt = inputs.get("prompt")
if not prompt:
return ToolResult(success=False, error="prompt is required")
model_id = meta.get(f"hf_{operation}_id") or meta["hf_id"]
precision = resolve_precision(inputs.get("precision", "auto"), meta["params_b"])
enable_offload = inputs.get("enable_model_offload", True)
offload_mode = resolve_offload_mode(
inputs.get("offload_mode", "auto"), precision, meta["params_b"]
)
try:
pipeline = load_wan_pipeline(
model_id=model_id,
operation=operation,
precision=precision,
enable_offload=enable_offload,
offload_mode=offload_mode,
)
except Exception as exc:
return ToolResult(success=False, error=f"Failed to load {model_id}: {exc}")
temporal_scale, spatial_scale = pipeline_scales(pipeline)
width = snap_dimension(int(inputs.get("width") or meta["default_width"]), spatial_scale)
height = snap_dimension(int(inputs.get("height") or meta["default_height"]), spatial_scale)
fps = int(inputs.get("fps") or meta["fps"])
segment_frames = snap_frames(
int(inputs.get("segment_frames") or meta["default_num_frames"]), temporal_scale
)
# Requested length: explicit num_frames wins, else duration_seconds, else
# the model's native single-pass clip length.
if inputs.get("num_frames"):
total_frames = int(inputs["num_frames"])
elif inputs.get("duration_seconds"):
total_frames = max(1, round(float(inputs["duration_seconds"]) * fps))
else:
total_frames = segment_frames
if operation == "text_to_image":
total_frames = segment_frames = 1
steps = int(inputs.get("num_inference_steps") or meta["default_steps"])
guidance_scale = float(inputs.get("guidance_scale") or meta["default_guidance"])
guidance_scale_2 = inputs.get("guidance_scale_2")
negative_prompt = inputs.get("negative_prompt", DEFAULT_NEGATIVE_PROMPT)
seed = inputs.get("seed")
strength = float(inputs.get("strength", 0.8))
output_path = Path(
inputs.get("output_path")
or f"{tool_name}_{variant_key}_{operation}.{'png' if operation == 'text_to_image' else 'mp4'}"
)
# Only text_to_video and image_to_video can be continued from a last frame.
# The rest are single-pass, so a long duration request is clamped to what the
# model can actually denoise at once rather than being sent to certain OOM.
chainable = operation in {"text_to_video", "image_to_video"}
clamped_from = None
if chainable:
segment_count = plan_segments(total_frames, segment_frames)
else:
segment_count = 1
if total_frames > segment_frames:
clamped_from = total_frames
total_frames = segment_frames
progress = inputs.get("progress_callback")
try:
if segment_count > 1:
frames, segment_log = _generate_chained(
pipeline,
operation=operation,
inputs=inputs,
prompt=prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
segment_frames=segment_frames,
total_frames=total_frames,
segment_count=segment_count,
steps=steps,
guidance_scale=guidance_scale,
guidance_scale_2=guidance_scale_2,
seed=seed,
correct_drift=inputs.get("correct_drift", True),
progress=progress,
)
else:
frames = _generate_single(
pipeline,
operation=operation,
inputs=inputs,
prompt=prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
num_frames=snap_frames(total_frames, temporal_scale)
if total_frames > 1
else 1,
steps=steps,
guidance_scale=guidance_scale,
guidance_scale_2=guidance_scale_2,
seed=seed,
strength=strength,
)
segment_log = [{"index": 0, "frames": len(frames), "operation": operation}]
except Exception as exc:
return _oom_aware_error(exc, variant_key, precision, offload_mode, width, height, total_frames)
if operation == "text_to_image":
output_path.parent.mkdir(parents=True, exist_ok=True)
frames[0].save(output_path)
artifact_kind = "image"
else:
frames = frames[:total_frames]
export_frames(frames, output_path, fps)
artifact_kind = "video"
from tools.video._shared import probe_output
data = {
"provider": tool_name,
"model_variant": variant_key,
"provider_name": meta["name"],
"mode": "local",
"operation": operation,
"prompt": prompt,
"negative_prompt": negative_prompt,
"model_id": model_id,
"precision": precision,
"offload_mode": offload_mode if enable_offload else "none",
"width": width,
"height": height,
"fps": fps,
"num_frames": len(frames),
"duration_seconds": round(len(frames) / fps, 2),
"segments": segment_count,
"segment_frames": segment_frames,
"clamped_from_frames": clamped_from,
"segment_log": segment_log,
"num_inference_steps": steps,
"guidance_scale": guidance_scale,
"output": str(output_path),
"format": "png" if artifact_kind == "image" else "mp4",
"license": meta["license"],
}
if artifact_kind == "video":
data.update(probe_output(output_path))
return ToolResult(
success=True,
data=data,
artifacts=[str(output_path)],
seed=seed,
model=model_id,
)
def derive_pipeline(base, target_operation: str):
"""Build a sibling Wan pipeline that shares ``base``'s loaded weights.
Chained generation starts in text-to-video and continues in image-to-video.
Both tasks run the same checkpoint, so re-reading tens of GB from disk just
to change pipeline class would dominate the runtime. Instead the modules
are handed to the other class directly; components that class declares but
this checkpoint lacks (Wan 2.2 TI2V has no CLIP image encoder) are optional
and resolve to ``None``. Falls back to a plain reload if anything about the
checkpoint makes the shortcut invalid.
"""
import inspect
import diffusers
target_name = _PIPELINE_FOR_OPERATION[target_operation]
if type(base).__name__ == target_name:
return base
target_class = getattr(diffusers, target_name)
components = dict(base.components)
config = dict(base.config)
kwargs: dict[str, Any] = {}
for name in list(inspect.signature(target_class.__init__).parameters)[1:]:
if name in components:
kwargs[name] = components[name]
elif name in config:
kwargs[name] = config[name]
else:
kwargs[name] = None
# Offload hooks belong to whichever pipeline installed them; move them over
# rather than leaving two pipelines fighting over the same modules.
offloaded = getattr(base, "_all_hooks", None)
sequential = getattr(base, "_is_sequentially_offloaded", False)
if offloaded:
base.remove_all_hooks()
derived = target_class(**kwargs)
if sequential:
derived.enable_sequential_cpu_offload()
elif offloaded:
derived.enable_model_cpu_offload()
vae = getattr(derived, "vae", None)
if vae is not None:
if hasattr(vae, "enable_tiling"):
vae.enable_tiling()
if hasattr(vae, "enable_slicing"):
vae.enable_slicing()
return derived
def _generate_single(
pipeline,
*,
operation: str,
inputs: dict[str, Any],
prompt: str,
negative_prompt: str,
width: int,
height: int,
num_frames: int,
steps: int,
guidance_scale: float,
guidance_scale_2: float | None,
seed: int | None,
strength: float,
) -> list:
image = last_image = video = None
if operation in {"image_to_video", "first_last_frame"}:
image = load_image(
inputs.get("reference_image_path"), inputs.get("reference_image_url"), width, height
)
if operation == "first_last_frame":
last_image = load_image(
inputs.get("last_image_path"), inputs.get("last_image_url"), width, height
)
if operation == "video_to_video":
source = inputs.get("source_video_path")
if not source:
raise ValueError("video_to_video requires source_video_path")
video = load_video_frames(source, width, height, num_frames)
# This pipeline takes its length from the clip rather than num_frames,
# so the clip itself must land on a latent boundary.
usable = snap_frames(len(video))
if usable > len(video):
usable -= _DEFAULT_TEMPORAL_SCALE
video = video[: max(1, usable)]
return _run_pass(
pipeline,
operation=operation,
prompt=prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
num_frames=num_frames,
steps=steps,
guidance_scale=guidance_scale,
guidance_scale_2=guidance_scale_2,
seed=seed,
image=image,
last_image=last_image,
video=video,
strength=strength,
)
def _generate_chained(
pipeline,
*,
operation: str,
inputs: dict[str, Any],
prompt: str,
negative_prompt: str,
width: int,
height: int,
segment_frames: int,
total_frames: int,
segment_count: int,
steps: int,
guidance_scale: float,
guidance_scale_2: float | None,
seed: int | None,
correct_drift: bool,
progress,
) -> tuple[list, list[dict[str, Any]]]:
"""Produce a long clip by chaining image-to-video off each segment's last frame.
Wan models are trained for roughly five seconds per pass; anything longer is
built by continuation. Each segment after the first is seeded with the
previous segment's final frame, which it regenerates as its own frame 0 —
that duplicate is dropped when the segments are joined so the seam lands on
a single shared frame.
``segment_prompts`` lets a caller drive the clip through a shot list; with a
single prompt every segment is asked for the same thing, which reads as one
continuous take.
"""
segment_prompts = inputs.get("segment_prompts") or []
frames: list = []
log: list[dict[str, Any]] = []
reference_stats = None
anchor = None
for index in range(segment_count):
segment_prompt = (
segment_prompts[index] if index < len(segment_prompts) else prompt
)
segment_seed = None if seed is None else seed + index
if index == 0:
segment_operation = operation
if operation == "image_to_video":
anchor = load_image(
inputs.get("reference_image_path"),
inputs.get("reference_image_url"),
width,
height,
)
else:
segment_operation = "image_to_video"
if index > 0:
# No-op once the chain is already running on the I2V pipeline.
pipeline = derive_pipeline(pipeline, "image_to_video")
if progress:
progress(index + 1, segment_count, segment_prompt)
produced = _run_pass(
pipeline,
operation=segment_operation,
prompt=segment_prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
num_frames=segment_frames,
steps=steps,
guidance_scale=guidance_scale,
guidance_scale_2=guidance_scale_2,
seed=segment_seed,
image=anchor if segment_operation == "image_to_video" else None,
)
if index == 0:
reference_stats = channel_stats(produced)
kept = produced
else:
if correct_drift and reference_stats is not None:
produced = match_channel_stats(produced, reference_stats)
# Frame 0 is the anchor the model was handed; keep the original.
kept = produced[1:]
frames.extend(kept)
anchor = frames[-1]
log.append(
{
"index": index,
"operation": segment_operation,
"prompt": segment_prompt,
"seed": segment_seed,
"frames_generated": len(produced),
"frames_kept": len(kept),
"cumulative_frames": len(frames),
}
)
if len(frames) >= total_frames:
break
return frames, log
# When the installed NVIDIA userspace libraries do not match the loaded kernel
# module, nvmlInit fails. PyTorch calls it while composing an out-of-memory
# report, so on such a machine every OOM — and several unrelated allocator
# paths — surface as this assert instead of the real error.
_NVML_ASSERT_MARKER = "nvmlInit"
def _oom_aware_error(
exc: Exception,
variant: str,
precision: str,
offload_mode: str,
width: int,
height: int,
frames: int,
):
"""Turn an allocator failure into advice the caller can act on."""
from tools.base_tool import ToolResult
free_wan_pipelines()
message = str(exc)
if _NVML_ASSERT_MARKER in message:
return ToolResult(
success=False,
error=(
"The NVIDIA driver on this machine is in a mismatched state: the loaded "
"kernel module and the installed userspace libraries are different versions, "
"so nvmlInit fails. CUDA compute still runs, but PyTorch calls NVML while "
"reporting an out-of-memory condition, so real OOMs surface as an internal "
"assert and bitsandbytes quantization cannot load at all. Reboot to load the "
"matching kernel module (check with 'cat /proc/driver/nvidia/version' against "
"'nvidia-smi'). Until then, stay on precision=\"bf16\" and reduce resolution "
"or segment_frames so generation never reaches the OOM path."
),
)
if "out of memory" not in message.lower():
return ToolResult(success=False, error=f"Wan generation failed: {exc}")
smaller = f"{max(320, (width * 2) // 3)}x{max(320, (height * 2) // 3)}"
hints = []
if offload_mode != "sequential":
hints.append('set offload_mode="sequential" to free the card for activations')
next_precision = {"bf16": "int8", "int8": "int4"}.get(precision)
if next_precision:
hints.append(f'set precision="{next_precision}"')
hints.append(f"lower the resolution (try around {smaller})")
hints.append("reduce segment_frames")
return ToolResult(
success=False,
error=(
f"Ran out of GPU memory generating {width}x{height} x{frames} frames with "
f"{variant} at {precision} (offload_mode={offload_mode}). "
"Try one of: " + "; ".join(hints) + "."
),
)

View File

@@ -386,6 +386,9 @@ class VideoSelector(BaseTool):
env_hint = os.environ.get("VIDEO_GEN_LOCAL_MODEL", "").lower()
env_map = {
"wan2.2-ti2v-5b": "wan",
"wan2.2-t2v-a14b": "wan",
"wan2.2-i2v-a14b": "wan",
"wan2.1-1.3b": "wan",
"wan2.1-14b": "wan",
"hunyuan-1.5": "hunyuan",

View File

@@ -1,4 +1,10 @@
"""Wan local video generation."""
"""Wan local video generation.
Wraps the Wan 2.x family running on the machine's own GPU: text-to-video,
image-to-video, video-to-video, first/last-frame interpolation, and single-frame
text-to-image. Clips longer than a model's native ~5s pass are produced by
chaining image-to-video segments — see ``tools/video/_wan_engine.py``.
"""
from __future__ import annotations
@@ -17,12 +23,32 @@ from tools.base_tool import (
ToolStatus,
ToolTier,
)
from tools.video._shared import WAN_VARIANTS, estimate_local_runtime, generate_local_video, local_generation_status, local_install_instructions
from tools.video._shared import WAN_VARIANTS, local_generation_status, local_install_instructions
from tools.video._wan_engine import (
DEFAULT_NEGATIVE_PROMPT,
generate_wan,
plan_segments,
snap_frames,
)
DEFAULT_VARIANT = "wan2.2-ti2v-5b"
# Runtime model fitted to measurements on an Ada-class laptop GPU (11.6GB) with
# the 5B TI2V checkpoint in bf16 at 704x480:
#
# 49 frames, 20 steps -> 11.9 s/step 121 frames, 20 steps -> 23.3 s/step
#
# which separates into a per-step cost that does not depend on the clip (under
# sequential offload every step streams the whole transformer across PCIe) and a
# per-step cost proportional to the number of pixels being denoised.
_SEQUENTIAL_STREAM_SECONDS_PER_STEP = 4.1
_SECONDS_PER_STEP_PER_MPX_FRAME = 0.47
_MODEL_LOAD_SECONDS = 15.0
class WanVideo(BaseTool):
name = "wan_video"
version = "0.1.0"
version = "0.2.0"
tier = ToolTier.GENERATE
capability = "video_generation"
provider = "wan"
@@ -36,18 +62,32 @@ class WanVideo(BaseTool):
fallback_tools = ["hunyuan_video", "ltx_video_local", "cogvideo_video", "image_selector"]
agent_skills = ["ltx2"]
capabilities = ["text_to_video", "image_to_video", "model_selection"]
capabilities = [
"text_to_video",
"image_to_video",
"video_to_video",
"first_last_frame",
"text_to_image",
"long_form_chaining",
"model_selection",
]
supports = {
"reference_image": True,
"offline": True,
"native_audio": False,
"local_gpu": True,
"long_form": True,
}
best_for = [
"best quality-to-VRAM ratio for local generation",
"local pipelines that still want image-to-video support",
"long local clips (20s+) assembled by image-to-video chaining",
"local pipelines that need image-to-video and video-to-video",
]
not_good_for = [
"CPU-only machines",
"instant iteration on low-end hardware",
"single-pass shots longer than ~5s (Wan's trained clip length)",
]
not_good_for = ["CPU-only machines", "instant iteration on low-end hardware"]
provider_matrix = {key: {"tool": "wan_video", **value, "mode": "local_gpu"} for key, value in WAN_VARIANTS.items()}
input_schema = {
@@ -55,25 +95,80 @@ class WanVideo(BaseTool):
"required": ["prompt"],
"properties": {
"prompt": {"type": "string"},
"operation": {"type": "string", "enum": ["text_to_video", "image_to_video"], "default": "text_to_video"},
"model_variant": {"type": "string", "enum": sorted(WAN_VARIANTS), "default": "wan2.1-1.3b"},
"negative_prompt": {"type": "string", "default": DEFAULT_NEGATIVE_PROMPT},
"operation": {
"type": "string",
"enum": [
"text_to_video",
"image_to_video",
"video_to_video",
"first_last_frame",
"text_to_image",
],
"default": "text_to_video",
},
"model_variant": {"type": "string", "enum": sorted(WAN_VARIANTS), "default": DEFAULT_VARIANT},
"reference_image_url": {"type": "string"},
"reference_image_path": {"type": "string"},
"last_image_url": {"type": "string", "description": "Target final frame for first_last_frame"},
"last_image_path": {"type": "string", "description": "Target final frame for first_last_frame"},
"source_video_path": {"type": "string", "description": "Input clip for video_to_video"},
"strength": {"type": "number", "default": 0.8, "description": "video_to_video denoise strength"},
"width": {"type": "integer"},
"height": {"type": "integer"},
"num_frames": {"type": "integer"},
"fps": {"type": "integer"},
"duration_seconds": {
"type": "number",
"description": "Target length. Beyond one segment the clip is chained image-to-video.",
},
"num_frames": {"type": "integer", "description": "Exact frame count; overrides duration_seconds"},
"segment_frames": {
"type": "integer",
"description": "Frames per chained segment. Defaults to the model's native clip length.",
},
"segment_prompts": {
"type": "array",
"items": {"type": "string"},
"description": "Per-segment prompts for long clips — a shot list. Falls back to prompt.",
},
"correct_drift": {
"type": "boolean",
"default": True,
"description": "Re-grade chained segments to the opening segment's colour statistics.",
},
"num_inference_steps": {"type": "integer"},
"guidance_scale": {"type": "number"},
"guidance_scale_2": {"type": "number", "description": "Second-expert guidance on MoE (A14B) variants"},
"precision": {
"type": "string",
"enum": ["auto", "bf16", "int8", "int4"],
"default": "auto",
"description": "Weight precision. auto picks the best fit for the visible VRAM.",
},
"enable_model_offload": {"type": "boolean", "default": True},
"offload_mode": {
"type": "string",
"enum": ["auto", "model", "sequential"],
"default": "auto",
"description": (
"How weights are staged onto the GPU. 'model' keeps a whole component "
"resident (fastest); 'sequential' streams one submodule at a time, which "
"is what lets a 12GB card render above 512x320."
),
},
"seed": {"type": "integer"},
"output_path": {"type": "string"},
},
}
resource_profile = ResourceProfile(cpu_cores=2, ram_mb=16000, vram_mb=8000, disk_mb=4000, network_required=False)
resource_profile = ResourceProfile(cpu_cores=2, ram_mb=16000, vram_mb=8000, disk_mb=40000, network_required=False)
retry_policy = RetryPolicy(max_retries=1)
idempotency_key_fields = ["prompt", "model_variant", "operation", "seed"]
idempotency_key_fields = ["prompt", "model_variant", "operation", "seed", "duration_seconds"]
side_effects = ["writes video file to output_path", "may download model weights"]
user_visible_verification = ["Watch generated clip for motion coherence and artifacts"]
user_visible_verification = [
"Watch generated clip for motion coherence and artifacts",
"On chained clips, check the segment seams for a jump in colour or subject pose",
]
def get_status(self) -> ToolStatus:
return local_generation_status()
@@ -81,18 +176,69 @@ class WanVideo(BaseTool):
def estimate_cost(self, inputs: dict[str, Any]) -> float:
return 0.0
def _plan(self, inputs: dict[str, Any]) -> dict[str, Any]:
"""Resolve the frame/segment plan without loading any weights."""
meta = WAN_VARIANTS.get(inputs.get("model_variant") or DEFAULT_VARIANT, WAN_VARIANTS[DEFAULT_VARIANT])
fps = int(inputs.get("fps") or meta["fps"])
segment_frames = snap_frames(int(inputs.get("segment_frames") or meta["default_num_frames"]))
if inputs.get("num_frames"):
total_frames = int(inputs["num_frames"])
elif inputs.get("duration_seconds"):
total_frames = max(1, round(float(inputs["duration_seconds"]) * fps))
else:
total_frames = segment_frames
if inputs.get("operation") == "text_to_image":
total_frames = segment_frames = 1
# A clip shorter than one native segment is generated at its own length,
# not padded out to the model's default.
segment_frames = min(segment_frames, max(1, total_frames))
from tools.video._wan_engine import resolve_offload_mode, resolve_precision
precision = resolve_precision(inputs.get("precision", "auto"), meta["params_b"])
return {
"meta": meta,
"precision": precision,
"offload_mode": resolve_offload_mode(
inputs.get("offload_mode", "auto"), precision, meta["params_b"]
),
"fps": fps,
"segment_frames": segment_frames,
"total_frames": total_frames,
"segments": plan_segments(total_frames, segment_frames),
"steps": int(inputs.get("num_inference_steps") or meta["default_steps"]),
"width": int(inputs.get("width") or meta["default_width"]),
"height": int(inputs.get("height") or meta["default_height"]),
}
def estimate_runtime(self, inputs: dict[str, Any]) -> float:
variant = WAN_VARIANTS.get(inputs.get("model_variant", "wan2.1-1.3b"), WAN_VARIANTS["wan2.1-1.3b"])
return estimate_local_runtime(variant["speed"])
"""Estimated wall-clock seconds, including weight loading.
Every chained segment pays the full per-step cost again, so a 30s clip
costs roughly six times a 5s one rather than a little more.
"""
plan = self._plan(inputs)
megapixels = (plan["width"] * plan["height"]) / 1_000_000
stream = (
_SEQUENTIAL_STREAM_SECONDS_PER_STEP
if plan["offload_mode"] == "sequential"
else 0.0
)
per_step = stream + plan["segment_frames"] * megapixels * _SECONDS_PER_STEP_PER_MPX_FRAME
return round(plan["segments"] * plan["steps"] * per_step + _MODEL_LOAD_SECONDS, 1)
def execute(self, inputs: dict[str, Any]) -> ToolResult:
if self.get_status() != ToolStatus.AVAILABLE:
return ToolResult(success=False, error="Wan local video generation is unavailable. " + self.install_instructions)
start = time.time()
try:
result = generate_local_video(tool_name=self.name, variants=WAN_VARIANTS, default_variant="wan2.1-1.3b", inputs=inputs)
result = generate_wan(
tool_name=self.name,
variants=WAN_VARIANTS,
default_variant=DEFAULT_VARIANT,
inputs=inputs,
)
except Exception as exc:
return ToolResult(success=False, error=f"Wan video generation failed: {exc}")
result.duration_seconds = round(time.time() - start, 2)
return result