Skip to content

Commit 9f5b0c3

Browse files
committed
[Pipelines] Add WanTokenizer and WanContext
Signed-off-by: jglee-sqbits <jingu.lee@squeezebits.com>
1 parent 4ebbf94 commit 9f5b0c3

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# ===----------------------------------------------------------------------=== #
2+
# Copyright (c) 2026, Modular Inc. All rights reserved.
3+
#
4+
# Licensed under the Apache License v2.0 with LLVM Exceptions:
5+
# https://llvm.org/LICENSE.txt
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
# ===----------------------------------------------------------------------=== #
13+
"""Wan-specific pixel generation context."""
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass, field
18+
19+
import numpy as np
20+
import numpy.typing as npt
21+
22+
from max.pipelines.core import PixelContext
23+
24+
25+
@dataclass(kw_only=True)
26+
class WanContext(PixelContext):
27+
"""Pixel generation context with Wan-specific video/MoE fields."""
28+
29+
num_frames: int | None = field(default=None)
30+
"""Number of frames for video generation."""
31+
32+
guidance_scale_2: float | None = field(default=None)
33+
"""Secondary guidance scale for low-noise expert (MoE models)."""
34+
35+
step_coefficients: npt.NDArray[np.float32] | None = field(default=None)
36+
"""Pre-computed scheduler step coefficients."""
37+
38+
boundary_timestep: float | None = field(default=None)
39+
"""Timestep threshold for switching between high/low noise experts."""
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# ===----------------------------------------------------------------------=== #
2+
# Copyright (c) 2026, Modular Inc. All rights reserved.
3+
#
4+
# Licensed under the Apache License v2.0 with LLVM Exceptions:
5+
# https://llvm.org/LICENSE.txt
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
# ===----------------------------------------------------------------------=== #
13+
"""Wan-specific pixel generation tokenizer."""
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
from typing import TYPE_CHECKING
19+
20+
import numpy as np
21+
import numpy.typing as npt
22+
import PIL.Image
23+
from max.interfaces.request import OpenResponsesRequest
24+
from max.pipelines.lib.pixel_tokenizer import PixelGenerationTokenizer
25+
26+
if TYPE_CHECKING:
27+
from max.pipelines.lib.config import PipelineConfig
28+
29+
from .context import WanContext
30+
31+
logger = logging.getLogger("max.pipelines")
32+
33+
34+
class WanTokenizer(PixelGenerationTokenizer):
35+
"""Wan-specific tokenizer that produces WanContext with video/MoE fields."""
36+
37+
def __init__(
38+
self,
39+
model_path: str,
40+
pipeline_config: "PipelineConfig",
41+
subfolder: str,
42+
**kwargs,
43+
) -> None:
44+
# The parent __init__ validates _class_name against PipelineClassName
45+
# enum which no longer contains Wan entries. Temporarily patch the
46+
# diffusers config so the parent sees a generic class name, then
47+
# restore it and apply Wan-specific overrides.
48+
diffusers_config = pipeline_config.model.diffusers_config
49+
original_class_name = diffusers_config.get("_class_name")
50+
diffusers_config["_class_name"] = "FluxPipeline"
51+
try:
52+
super().__init__(
53+
model_path, pipeline_config, subfolder, **kwargs
54+
)
55+
finally:
56+
diffusers_config["_class_name"] = original_class_name
57+
58+
# Override latent channel count: Wan uses out_channels (16) for noise
59+
# latents, not in_channels which may be 36 for I2V variants
60+
# (16 noise + 4 mask + 16 image).
61+
components = self.diffusers_config.get("components", {})
62+
transformer_config = components.get("transformer", {}).get(
63+
"config_dict", {}
64+
)
65+
self._num_channels_latents = transformer_config.get(
66+
"out_channels", transformer_config["in_channels"]
67+
)
68+
69+
def _select_wan_flow_shift(self, height: int, width: int) -> float:
70+
scheduler_cfg = (
71+
self.diffusers_config.get("components", {})
72+
.get("scheduler", {})
73+
.get("config_dict", {})
74+
)
75+
# Use explicit flow_shift from scheduler config if set (user override).
76+
cfg_shift = scheduler_cfg.get("flow_shift")
77+
if cfg_shift is not None and float(cfg_shift) != 1.0:
78+
return float(cfg_shift)
79+
# Default: interpolate based on pixel count.
80+
# 480p (480*832 = 399 360) → 3.0, 720p (720*1280 = 921 600) → 5.0
81+
pixels = height * width
82+
lo_px, hi_px = 399_360, 921_600
83+
lo_shift, hi_shift = 3.0, 5.0
84+
t = max(0.0, min(1.0, (pixels - lo_px) / (hi_px - lo_px)))
85+
return lo_shift + t * (hi_shift - lo_shift)
86+
87+
async def new_context( # type: ignore[override]
88+
self,
89+
request: OpenResponsesRequest,
90+
input_image: PIL.Image.Image | None = None,
91+
) -> WanContext:
92+
base = await super().new_context(request, input_image=input_image)
93+
94+
video_options = request.body.provider_options.video
95+
image_options = request.body.provider_options.image
96+
97+
num_frames: int | None = (
98+
video_options.num_frames if video_options else None
99+
)
100+
guidance_scale_2: float | None = (
101+
video_options.guidance_scale_2 if video_options else None
102+
)
103+
104+
height = base.height
105+
width = base.width
106+
timesteps: npt.NDArray[np.float32] = base.timesteps
107+
sigmas: npt.NDArray[np.float32] = base.sigmas
108+
109+
if getattr(self._scheduler, "use_flow_sigmas", False):
110+
self._scheduler.flow_shift = self._select_wan_flow_shift(
111+
height, width
112+
)
113+
latent_height = 2 * (int(height) // (self._vae_scale_factor * 2))
114+
latent_width = 2 * (int(width) // (self._vae_scale_factor * 2))
115+
image_seq_len = (latent_height // 2) * (latent_width // 2)
116+
timesteps, sigmas = self._scheduler.retrieve_timesteps_and_sigmas(
117+
image_seq_len, base.num_inference_steps
118+
)
119+
120+
boundary_timestep: float | None = None
121+
boundary_ratio = self.diffusers_config.get("boundary_ratio")
122+
if boundary_ratio is not None:
123+
boundary_timestep = float(boundary_ratio) * float(
124+
getattr(self._scheduler, "num_train_timesteps", 1000)
125+
)
126+
127+
step_coefficients: npt.NDArray[np.float32] | None = None
128+
if hasattr(self._scheduler, "build_step_coefficients"):
129+
step_coefficients = self._scheduler.build_step_coefficients()
130+
131+
latents = base.latents
132+
if num_frames is not None:
133+
vae_scale_factor_temporal = 4
134+
latent_frames = (num_frames - 1) // vae_scale_factor_temporal + 1
135+
latent_height = 2 * (int(height) // (self._vae_scale_factor * 2))
136+
latent_width = 2 * (int(width) // (self._vae_scale_factor * 2))
137+
shape_5d = (
138+
image_options.num_images,
139+
self._num_channels_latents,
140+
latent_frames,
141+
latent_height,
142+
latent_width,
143+
)
144+
latents = self._randn_tensor(shape_5d, request.body.seed)
145+
146+
return WanContext(
147+
request_id=base.request_id,
148+
model_name=base.model_name,
149+
tokens=base.tokens,
150+
mask=base.mask,
151+
tokens_2=base.tokens_2,
152+
negative_tokens=base.negative_tokens,
153+
negative_mask=base.negative_mask,
154+
negative_tokens_2=base.negative_tokens_2,
155+
explicit_negative_prompt=base.explicit_negative_prompt,
156+
timesteps=timesteps,
157+
sigmas=sigmas,
158+
latents=latents,
159+
latent_image_ids=base.latent_image_ids,
160+
height=base.height,
161+
width=base.width,
162+
num_frames=num_frames,
163+
guidance_scale=base.guidance_scale,
164+
true_cfg_scale=base.true_cfg_scale,
165+
guidance_scale_2=guidance_scale_2,
166+
cfg_normalization=base.cfg_normalization,
167+
cfg_truncation=base.cfg_truncation,
168+
num_inference_steps=base.num_inference_steps,
169+
num_warmup_steps=base.num_warmup_steps,
170+
strength=base.strength,
171+
boundary_timestep=boundary_timestep,
172+
step_coefficients=step_coefficients,
173+
num_images_per_prompt=base.num_images_per_prompt,
174+
input_image=base.input_image,
175+
output_format=base.output_format,
176+
residual_threshold=base.residual_threshold,
177+
status=base.status,
178+
)

0 commit comments

Comments
 (0)