multimodalart HF Staff commited on
Commit
240c0d3
·
verified ·
1 Parent(s): 457acaf

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/back_01.png filter=lfs diff=lfs merge=lfs -text
37
+ examples/back_264.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/bamboo_L.png filter=lfs diff=lfs merge=lfs -text
39
+ examples/watercolor_L.png filter=lfs diff=lfs merge=lfs -text
LICENSE_ACTIONROPE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 The ActionRoPE Authors
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,13 +1,62 @@
1
  ---
2
- title: Actionrope
3
- emoji: 📚
4
- colorFrom: gray
5
  colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.30.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ActionRoPE
3
+ emoji: 🎮
4
+ colorFrom: indigo
5
  colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.30.0
 
8
  app_file: app.py
9
+ short_description: Action-controlled game world model video generation
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
  ---
13
 
14
+ # ActionRoPE — action as coordinates for planar-camera game world models
15
+
16
+ Demo of [**teawhite/ActionRoPE**](https://huggingface.co/teawhite/ActionRoPE) —
17
+ *ActionRoPE: action as coordinates for spatially consistent control in planar-camera
18
+ game world models* ([paper 2610.25861](https://huggingface.co/papers/2610.25861),
19
+ [GitHub](https://github.com/TeaWhiteBro/ActionRoPE)).
20
+
21
+ ActionRoPE turns a video-diffusion world model into a **controllable** one without
22
+ touching its weights: the camera displacement of each frame is written directly into
23
+ the rotary positions of the DiT's tokens (world coordinates), so tokens that look at
24
+ the same world location share the same positional address across time. Two
25
+ companions make it work: **CNS** (coordinate-conditioned noise scheduling), which
26
+ noises each latent cell according to how "known" its world region is, and a tiny
27
+ zero-initialized **known/new input channel**.
28
+
29
+ ## How to drive it
30
+
31
+ - **First frame** — any image; it is centre-cropped to 26:15 and resized to 832×480
32
+ (the paper's resolution).
33
+ - **Actions** — a comma-separated plan over the 80 frames after the first one, each
34
+ segment `<direction>[*speed]:<frames>`:
35
+ - directions: `idle`, `up`, `down`, `left`, `right`, `up-left`, `up-right`,
36
+ `down-left`, `down-right` (aliases `u/d/l/r/ul/ur/dl/dr`)
37
+ - speed `1.0` is the game's nominal walking speed: **69.2 px/s horizontally,
38
+ 46.0 px/s vertically** at 832×480; `*1.5` walks 50% faster.
39
+ - e.g. `right:40,left:40` (walk right 2.5 s, then back), or
40
+ `up-right*1.5:32,left:40,down-left:8`.
41
+ - The output is an **81-frame / 5 s / 16 fps / 832×480** clip.
42
+
43
+ Advanced: seed, sampling steps (paper: 50), and the CNS on/off switch.
44
+
45
+ ## Notes
46
+
47
+ - Runs on ZeroGPU; the Wan2.2-TI2V-5B backbone (5 B DiT, ~10 GB fp16) and the
48
+ Wan2.2 VAE are downloaded once at startup (~13 GB total). The ActionRoPE
49
+ checkpoint is a *full* DiT state dict, so the base model's shards are never
50
+ fetched.
51
+ - Sampling uses the paper's evaluation path verbatim: 50 Euler steps of the shift-5
52
+ flow-matching schedule, no guidance, the shipped NULL-prompt UMT5 embedding, VAE
53
+ run in fp32.
54
+
55
+ ## Credits
56
+
57
+ - Code: [TeaWhiteBro/ActionRoPE](https://github.com/TeaWhiteBro/ActionRoPE)
58
+ (Apache-2.0), vendored under `actionrope/` with only the training modules removed.
59
+ - Weights: [teawhite/ActionRoPE](https://huggingface.co/teawhite/ActionRoPE)
60
+ (Wan2.2-TI2V-5B backbone: Apache-2.0) and [Wan-AI/Wan2.2-TI2V-5B](https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B) (VAE).
61
+ - The example first frames in `examples/` are extracted from the authors' own
62
+ released videos on the paper's project page.
actionrope/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """ActionRoPE: Action as Coordinates for Spatially Consistent Control in Planar-Camera Game World Models."""
2
+ __version__ = "1.0.0"
actionrope/checkpoint.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """safetensors checkpoints with a small, uniform metadata header.
2
+
3
+ Every ActionRoPE checkpoint stores (as strings):
4
+
5
+ model "wan2.2-ti2v-5b" | "skyreels-v2-df-1.3b" | "ltx-2.5-22b" | "minimax-h3-33b"
6
+ format "full" (complete DiT state dict) or "lora" (LoRA factors + new modules only)
7
+ mask_channel "true" / "false" known/new input channel
8
+ cns "true" / "false" Coordinate-Conditioned Noise Scheduling
9
+ cns_shift base shift S of the sampling schedule
10
+ cns_shift_known shift S_known of known tokens
11
+ cns_alpha exponent alpha (1 in the paper)
12
+ step training step
13
+ lora_rank, lora_alpha, lora_targets (format = "lora" only)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+
20
+ import torch
21
+
22
+
23
+ def save_checkpoint(path: str, state_dict: dict[str, torch.Tensor], metadata: dict) -> None:
24
+ from safetensors.torch import save_file
25
+ meta = {k: (json.dumps(v) if isinstance(v, (list, dict)) else str(v).lower() if isinstance(v, bool) else str(v))
26
+ for k, v in metadata.items()}
27
+ save_file({k: v.detach().contiguous().cpu() for k, v in state_dict.items()}, path, metadata=meta)
28
+ os.chmod(path, 0o644) # safetensors follows the umask, which may make the file private
29
+
30
+
31
+ def read_metadata(path: str) -> dict[str, str]:
32
+ from safetensors import safe_open
33
+ with safe_open(path, "pt") as f:
34
+ return dict(f.metadata() or {})
35
+
36
+
37
+ def meta_bool(meta: dict, key: str, default: bool = False) -> bool:
38
+ v = meta.get(key)
39
+ return default if v is None else str(v).strip().lower() in ("1", "true", "yes")
40
+
41
+
42
+ def meta_float(meta: dict, key: str, default: float | None = None) -> float | None:
43
+ v = meta.get(key)
44
+ return default if v is None or v == "" else float(v)
actionrope/cns.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Coordinate-Conditioned Noise Scheduling (CNS).
2
+
3
+ Every backbone samples with a shifted flow-matching schedule sigma = S u / (1 + (S - 1) u), u in [0, 1]. CNS gives each
4
+ token its own shift according to its knownness kappa (1 = inside the first-frame footprint, 0 = newly revealed):
5
+
6
+ u = sigma / (S - (S - 1) sigma) (invert the base schedule)
7
+ S_tok = S * (S_known / S) ** (alpha * kappa)
8
+ sigma_t = S_tok u / (1 + (S_tok - 1) u)
9
+
10
+ Known tokens follow a schedule with the smaller shift S_known, so they reach low noise earlier during sampling;
11
+ newly revealed tokens keep the base schedule (sigma_t = sigma for kappa = 0). sigma_t(1) = 1 and sigma_t(0) = 0 for
12
+ every kappa. The same function is used to noise the training samples and to integrate the sampler.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+
18
+
19
+ def cns_sigma(sigma, kappa: torch.Tensor, shift: float, shift_known: float, alpha: float = 1.0) -> torch.Tensor:
20
+ """Per-token noise level for the global noise level ``sigma`` (float or tensor) and knownness ``kappa``."""
21
+ S = float(shift)
22
+ u = sigma / (S - (S - 1.0) * sigma)
23
+ S_tok = S * (float(shift_known) / S) ** (alpha * kappa)
24
+ return S_tok * u / (1.0 + (S_tok - 1.0) * u)
actionrope/coords.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Known / newly revealed regions in world coordinates (backbone-agnostic).
2
+
3
+ A latent (or token) grid cell (i, j) of latent frame k looks at the world position (i + dy_k / p, j + dx_k / p), where
4
+ (dx_k, dy_k) is the camera offset of that latent frame in pixels and p the number of pixels per grid cell. The cell is
5
+ *known* when this position lies inside the footprint of the first frame, [-0.5, h - 0.5] x [-0.5, w - 0.5] (closed),
6
+ and *newly revealed* otherwise. All functions here are pure geometry and work on any grid.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+
13
+ def footprint_distance(offset_px: torch.Tensor, h: int, w: int, px_per_cell: float) -> torch.Tensor:
14
+ """Distance (in grid cells) of every cell's world position to the first-frame footprint; 0 inside.
15
+
16
+ offset_px: [b, f, 2] camera offset (dx, dy) of each latent frame in pixels. Returns float64 [b, f, h, w].
17
+ """
18
+ off = offset_px.to(torch.float64) / px_per_cell
19
+ b, f = off.shape[:2]
20
+ dx, dy = off[..., 0].view(b, f, 1, 1), off[..., 1].view(b, f, 1, 1)
21
+ wi = torch.arange(h, device=off.device, dtype=torch.float64).view(1, 1, h, 1) + dy
22
+ wj = torch.arange(w, device=off.device, dtype=torch.float64).view(1, 1, 1, w) + dx
23
+ dh = torch.clamp(torch.maximum(-0.5 - wi, wi - (h - 0.5)), min=0.0)
24
+ dw = torch.clamp(torch.maximum(-0.5 - wj, wj - (w - 0.5)), min=0.0)
25
+ return torch.sqrt(dh * dh + dw * dw)
26
+
27
+
28
+ def known_mask(offset_px: torch.Tensor, h: int, w: int, px_per_cell: float) -> torch.Tensor:
29
+ """Boolean [b, f, h, w]: True where the cell's world position lies inside the first-frame footprint."""
30
+ return footprint_distance(offset_px, h, w, px_per_cell) <= 0
31
+
32
+
33
+ def knownness(offset_px: torch.Tensor, h: int, w: int, px_per_cell: float) -> torch.Tensor:
34
+ """kappa as float32 [b, 1, f, h, w]: 1 for known cells, 0 for newly revealed cells."""
35
+ return known_mask(offset_px, h, w, px_per_cell).to(torch.float32).unsqueeze(1)
36
+
37
+
38
+ def loss_weight_map(offset_px: torch.Tensor, h: int, w: int, px_per_cell: float, new_weight: float = 2.0) -> torch.Tensor:
39
+ """Per-cell loss weight, float32 [b, 1, f, h, w]: 1 for known cells, ``new_weight`` for newly revealed cells."""
40
+ known = known_mask(offset_px, h, w, px_per_cell)
41
+ return torch.where(known, 1.0, float(new_weight)).to(torch.float32).unsqueeze(1)
actionrope/data.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset index shared by all backbones.
2
+
3
+ The dataset is described by one JSON-lines annotation file, one row per 81-frame clip:
4
+
5
+ {"clip": "000123", # clip id = file stem of the video and of every latent file
6
+ "video": "clips/000123.mp4", # path relative to the annotation file
7
+ "split": "train", # "train" or "val"
8
+ "kind": "plain", # "plain", or "burn" / "hardcut" for scene-transition clips
9
+ "valid": true, # false if the camera track of the clip is unreliable
10
+ "rt_ratio": 0.98, # real-time ratio of the recording (plain clips)
11
+ "sources": ["000045", "000981"], # transition clips: the two clips they were composed from
12
+ "frame_offset_px": [[0.0, 0.0], ...], # 81 x (dx, dy) camera offsets in px (see actionrope.geometry)
13
+ "prompts": ["In a 2.5D top down view, the player is in ...", ...]} # 21 per-cell scene prompts (no actions)
14
+
15
+ The 21 prompts follow the Wan-family latent frames (``geometry.cell_frames``); they only differ inside transition
16
+ clips. Latents are pre-computed per backbone with ``scripts/encode_latents.py`` into ``<latent_dir>/<clip>.pt``.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from dataclasses import dataclass
23
+
24
+ import numpy as np
25
+ import torch
26
+
27
+ TRANSITION_KINDS = ("burn", "hardcut")
28
+
29
+
30
+ @dataclass
31
+ class Clip:
32
+ clip: str
33
+ split: str
34
+ kind: str
35
+ valid: bool
36
+ rt_ratio: float | None
37
+ sources: tuple[str, ...]
38
+ frame_offset_px: np.ndarray # float64 [81, 2]
39
+ prompts: list[str]
40
+ video: str | None = None
41
+
42
+
43
+ def load_annotations(path: str) -> list[Clip]:
44
+ root = os.path.dirname(os.path.abspath(path))
45
+ clips = []
46
+ with open(path, encoding="utf-8") as fh:
47
+ for line in fh:
48
+ if not line.strip():
49
+ continue
50
+ r = json.loads(line)
51
+ clips.append(Clip(
52
+ clip=r["clip"], split=r["split"], kind=r.get("kind", "plain"), valid=bool(r.get("valid", True)),
53
+ rt_ratio=r.get("rt_ratio"), sources=tuple(r.get("sources") or ()),
54
+ frame_offset_px=np.asarray(r["frame_offset_px"], np.float64), prompts=list(r["prompts"]),
55
+ video=os.path.join(root, r["video"]) if r.get("video") else None))
56
+ return clips
57
+
58
+
59
+ def training_clips(clips: list[Clip], min_rt_ratio: float = 0.8, exclude_val_sources: bool = True,
60
+ verbose: bool = True) -> list[Clip]:
61
+ """Training split after the filters of the paper:
62
+
63
+ * drop clips whose camera track is invalid;
64
+ * drop transition clips composed from a validation clip (their frames would leak into training);
65
+ * drop plain clips recorded below ``min_rt_ratio`` of real time (the game stuttered, so motion per frame drops).
66
+ """
67
+ val = {c.clip for c in clips if c.split == "val"}
68
+ out, n_invalid, n_leak, n_slow = [], 0, 0, 0
69
+ for c in clips:
70
+ if c.split != "train":
71
+ continue
72
+ if not c.valid:
73
+ n_invalid += 1
74
+ elif c.kind in TRANSITION_KINDS:
75
+ if exclude_val_sources and any(s in val for s in c.sources):
76
+ n_leak += 1
77
+ else:
78
+ out.append(c)
79
+ elif min_rt_ratio > 0 and float(1.0 if c.rt_ratio is None else c.rt_ratio) < min_rt_ratio:
80
+ n_slow += 1
81
+ else:
82
+ out.append(c)
83
+ if verbose:
84
+ n_train = sum(c.split == "train" for c in clips)
85
+ print(f"[data] train clips: {n_train} - invalid {n_invalid} - leaking transitions {n_leak} - "
86
+ f"rt_ratio < {min_rt_ratio} {n_slow} = {len(out)}", flush=True)
87
+ return out
88
+
89
+
90
+ def validation_clips(clips: list[Clip], min_rt_ratio: float = 0.9, n: int | None = 16, seed: int = 0) -> list[Clip]:
91
+ """Validation clips used to monitor the training loss (``n`` random clips with rt_ratio >= ``min_rt_ratio``)."""
92
+ import random
93
+ pool = [c for c in clips if c.split == "val" and c.valid and c.kind not in TRANSITION_KINDS
94
+ and float(1.0 if c.rt_ratio is None else c.rt_ratio) >= min_rt_ratio]
95
+ pool.sort(key=lambda c: c.clip)
96
+ if n is not None and n < len(pool):
97
+ random.Random(seed).shuffle(pool)
98
+ pool = sorted(pool[:n], key=lambda c: c.clip)
99
+ return pool
100
+
101
+
102
+ def load_text_table(path: str) -> dict[str, torch.Tensor]:
103
+ """{prompt: embedding} written by ``scripts/build_text_table.py``."""
104
+ d = torch.load(path, map_location="cpu", weights_only=False)
105
+ return d["table"] if isinstance(d, dict) and "table" in d and isinstance(d["table"], dict) else d
actionrope/geometry.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clip geometry shared by all backbones.
2
+
3
+ Conventions
4
+ -----------
5
+ * A clip has 81 frames at 16 fps and 832x480 pixels (5 s).
6
+ * Screen axes: x to the right, y downwards, in pixels of the 832x480 frame.
7
+ * ``frame_offset_px[i] = (dx, dy)`` is the on-screen displacement of the player (= of the camera, which follows the
8
+ player) at frame i relative to frame 0, so ``frame_offset_px[0] == (0, 0)``. The static background moves by
9
+ ``-frame_offset_px``.
10
+ * Every backbone maps the 81 per-frame offsets to its own latent frames (see ``frames_to_cells`` for the Wan-family
11
+ causal VAE; LTX-2.5 and MiniMax-H3 have their own grouping in their model files).
12
+
13
+ Actions
14
+ -------
15
+ In a planar-camera game an action moves the camera by a known amount. We describe an action sequence as segments
16
+ ``<direction>[*<speed>]:<n_frames>``, e.g. ``"right:40,left:40"`` (walk right for 2.5 s, then back). ``speed`` is a
17
+ multiplier of the nominal walking speed of the training data. Segments start at frame 1; frames not covered by any
18
+ segment are idle.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ import numpy as np
25
+
26
+ FRAMES = 81
27
+ FPS = 16
28
+ WIDTH, HEIGHT = 832, 480
29
+
30
+ # Nominal walking speed of the player in the training data, in px/s at 832x480 and 16 fps (x, y). It is the median
31
+ # speed over straight-walk training clips; the evaluation commands are multiples of the horizontal value.
32
+ WALK_SPEED_PX_S = (69.24106392992304, 46.012630371093756)
33
+
34
+ _S = math.sqrt(0.5)
35
+ DIRECTIONS = {
36
+ "idle": (0.0, 0.0),
37
+ "up": (0.0, -1.0),
38
+ "up-left": (-_S, -_S),
39
+ "left": (-1.0, 0.0),
40
+ "down-left": (-_S, _S),
41
+ "down": (0.0, 1.0),
42
+ "down-right": (_S, _S),
43
+ "right": (1.0, 0.0),
44
+ "up-right": (_S, -_S),
45
+ }
46
+ _ALIASES = {"u": "up", "d": "down", "l": "left", "r": "right", "ul": "up-left", "ur": "up-right",
47
+ "dl": "down-left", "dr": "down-right", "stop": "idle", "none": "idle"}
48
+
49
+
50
+ def parse_actions(spec: str, n_frames: int = FRAMES) -> np.ndarray:
51
+ """Action string -> per-frame velocity [n_frames, 2] in px/frame (frame 0 has no velocity).
52
+
53
+ ``spec`` is a comma-separated list of ``<direction>[*<speed>]:<n_frames>`` segments, e.g. ``"right:40,left:40"``
54
+ or ``"up-right*1.5:24,idle:8,left:48"``. Directions: idle, up, down, left, right, up-left, up-right, down-left,
55
+ down-right (or u, d, l, r, ul, ur, dl, dr). The segments may cover at most ``n_frames - 1`` frames.
56
+ """
57
+ vel = np.zeros((n_frames, 2), np.float64)
58
+ frame = 1
59
+ for seg in filter(None, (s.strip() for s in spec.split(","))):
60
+ try:
61
+ head, count = seg.rsplit(":", 1)
62
+ name, _, speed = head.partition("*")
63
+ name = _ALIASES.get(name.strip().lower(), name.strip().lower())
64
+ d = np.array(DIRECTIONS[name], np.float64)
65
+ mult, count = float(speed) if speed else 1.0, int(count)
66
+ except (KeyError, ValueError) as e:
67
+ raise ValueError(f"cannot parse action segment {seg!r} (expected <direction>[*<speed>]:<n_frames>)") from e
68
+ if count < 0 or frame + count > n_frames:
69
+ raise ValueError(f"action segments cover more than {n_frames - 1} frames: {spec!r}")
70
+ vel[frame:frame + count] = d * np.array(WALK_SPEED_PX_S) * mult / FPS
71
+ frame += count
72
+ return vel
73
+
74
+
75
+ def offsets_from_velocity(vel: np.ndarray) -> np.ndarray:
76
+ """Per-frame velocity [F, 2] (px/frame) -> per-frame camera offset [F, 2] with offset[0] = 0."""
77
+ vel = np.asarray(vel, np.float64).copy()
78
+ vel[0] = 0.0
79
+ return np.cumsum(vel, axis=0)
80
+
81
+
82
+ def offsets_from_actions(spec: str, n_frames: int = FRAMES) -> np.ndarray:
83
+ """Action string -> per-frame camera offset ``frame_offset_px`` [n_frames, 2]."""
84
+ return offsets_from_velocity(parse_actions(spec, n_frames))
85
+
86
+
87
+ def cell_frames(k: int) -> list[int]:
88
+ """Frames covered by latent frame (cell) k of the Wan-family causal VAE: cell 0 = frame 0, cell k = frames
89
+ 4k-3 .. 4k."""
90
+ return [0] if k == 0 else list(range(4 * k - 3, 4 * k + 1))
91
+
92
+
93
+ def frames_to_cells(frame_offset_px: np.ndarray) -> np.ndarray:
94
+ """Per-frame offsets [81, 2] -> per-cell offsets [21, 2]: the mean over the frames of each cell."""
95
+ off = np.asarray(frame_offset_px, np.float64)
96
+ if off.shape != (FRAMES, 2):
97
+ raise ValueError(f"frame_offset_px must have shape ({FRAMES}, 2), got {off.shape}")
98
+ return np.stack([off[cell_frames(k)].mean(0) for k in range((FRAMES - 1) // 4 + 1)], 0)
actionrope/media.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image / video input and output."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+ from .geometry import FPS, HEIGHT, WIDTH
7
+
8
+
9
+ def read_frames(path: str, n: int | None = None) -> np.ndarray:
10
+ """Decode a video into uint8 RGB frames [F, H, W, 3] (PyAV)."""
11
+ import av
12
+ out = []
13
+ with av.open(path) as container:
14
+ for frame in container.decode(video=0):
15
+ out.append(frame.to_ndarray(format="rgb24"))
16
+ if n is not None and len(out) >= n:
17
+ break
18
+ return np.stack(out, 0)
19
+
20
+
21
+ def load_first_frame(path: str) -> np.ndarray:
22
+ """Image (or the first frame of a video) -> uint8 [480, 832, 3].
23
+
24
+ Inputs of another size are centre-cropped to the 26:15 aspect ratio and resized (bicubic), so the pixel scale of
25
+ the scene - and therefore the walking speed in pixels - is not distorted."""
26
+ from PIL import Image
27
+ if path.lower().endswith((".mp4", ".mov", ".mkv", ".webm", ".avi")):
28
+ img = read_frames(path, 1)[0]
29
+ else:
30
+ img = np.array(Image.open(path).convert("RGB"))
31
+ if img.shape[:2] != (HEIGHT, WIDTH):
32
+ h, w = img.shape[:2]
33
+ tw, th = (w, round(w * HEIGHT / WIDTH)) if w * HEIGHT <= h * WIDTH else (round(h * WIDTH / HEIGHT), h)
34
+ x0, y0 = (w - tw) // 2, (h - th) // 2
35
+ img = np.array(Image.fromarray(img[y0:y0 + th, x0:x0 + tw]).resize((WIDTH, HEIGHT), Image.BICUBIC))
36
+ return img
37
+
38
+
39
+ def write_video(path: str, frames, fps: float = FPS, crf: int | None = 16, quality: float | None = None) -> None:
40
+ """Write uint8 RGB frames with libx264. ``quality`` selects imageio's quality scale instead of a CRF (the Wan2.2
41
+ evaluation videos of the paper were written with quality=9, the other backbones with crf=16)."""
42
+ import imageio
43
+ frames = [np.asarray(f) for f in frames]
44
+ if quality is not None:
45
+ writer = imageio.get_writer(path, fps=fps, quality=quality)
46
+ for f in frames:
47
+ writer.append_data(f)
48
+ writer.close()
49
+ else:
50
+ import imageio.v3 as iio
51
+ iio.imwrite(path, np.stack(frames, 0), fps=fps, codec="libx264", output_params=["-crf", str(int(crf))])
actionrope/models/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Backbone integrations (Space build: wan only)."""
2
+ MODELS = ("wan",)
actionrope/models/umt5.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UMT5-XXL prompt encoder of the Wan family (Wan2.2-TI2V-5B and SkyReels-V2 use the same encoder).
2
+
3
+ Prompts are tokenised to 512 tokens and the embeddings after the true sequence length are zeroed, exactly as
4
+ DiffSynth's ``WanVideoUnit_PromptEmbedder`` does. Training reads pre-computed embeddings from a text table
5
+ (``scripts/build_text_table.py``) so the 11 GB encoder does not have to be loaded.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ import torch
12
+
13
+ T5_WEIGHTS = "models_t5_umt5-xxl-enc-bf16.pth"
14
+ T5_TOKENIZER = os.path.join("google", "umt5-xxl")
15
+
16
+
17
+ class UMT5Encoder:
18
+ def __init__(self, model_dir: str, device: str = "cuda"):
19
+ from diffsynth.pipelines.wan_video import ModelConfig, WanVideoPipeline
20
+ self.device = device
21
+ self.pipe = WanVideoPipeline.from_pretrained(
22
+ torch_dtype=torch.bfloat16, device=device, redirect_common_files=False,
23
+ model_configs=[ModelConfig(path=os.path.join(model_dir, T5_WEIGHTS))],
24
+ tokenizer_config=ModelConfig(path=os.path.join(model_dir, T5_TOKENIZER)))
25
+ self.pipe.load_models_to_device(["text_encoder"])
26
+
27
+ @torch.no_grad()
28
+ def __call__(self, prompts: list[str]) -> list[torch.Tensor]:
29
+ """-> one bf16 [512, 4096] CPU tensor per prompt."""
30
+ ids, mask = self.pipe.tokenizer(prompts, return_mask=True, add_special_tokens=True)
31
+ ids, mask = ids.to(self.device), mask.to(self.device)
32
+ seq_lens = mask.gt(0).sum(dim=1).long()
33
+ emb = self.pipe.text_encoder(ids, mask)
34
+ for j, n in enumerate(seq_lens):
35
+ emb[j, n:] = 0
36
+ return [e.to(torch.bfloat16).cpu().clone() for e in emb]
37
+
38
+
39
+ def build_text_table(prompts: list[str], model_dir: str, device: str = "cuda", batch_size: int = 32) -> dict:
40
+ """{prompt: bf16 [512, 4096]} for the sorted, de-duplicated prompts (batches of ``batch_size``)."""
41
+ enc = UMT5Encoder(model_dir, device)
42
+ keys = sorted(set(prompts))
43
+ table = {}
44
+ for i in range(0, len(keys), batch_size):
45
+ chunk = keys[i:i + batch_size]
46
+ table.update(zip(chunk, enc(chunk)))
47
+ return table
actionrope/models/wan.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wan2.2-TI2V-5B with ActionRoPE + CNS (the main backbone of the paper).
2
+
3
+ Geometry: the Wan2.2 VAE compresses 16x in space and 4x in time (latent frame 0 = video frame 0, latent frame k =
4
+ frames 4k-3 .. 4k), so an 81-frame 832x480 clip becomes 21 latent frames of 48 x 30 x 52. The DiT patch is (1, 2, 2):
5
+ one token covers 32 x 32 pixels, one latent cell 16 x 16 pixels.
6
+
7
+ * Camera offsets. The offset of a latent frame is the mean of the per-frame offsets of the frames it covers
8
+ (``geometry.frames_to_cells``); the token positions are shifted by offset / 32 (ActionRoPE, Sec. 3.1), the known/new
9
+ regions are evaluated on the 30 x 52 latent grid (offset / 16).
10
+ * Known/new input channel. A zero-initialised Conv3d(1, 3072) adds the known map to the patch embedding (15,360
11
+ parameters).
12
+ * Training (Sec. 4.1). Full fine-tuning of the DiT with the flow-matching loss of Wan2.2: one timestep index per
13
+ sample from the 1000-step training schedule (shift 5), loss weighted by DiffSynth's Wan timestep weighting. Latent
14
+ frame 0 is the clean first frame (timestep 0, no loss); newly revealed latent cells have loss weight ``new_weight``.
15
+ With CNS every latent cell is noised to its own level sigma_tok(sigma, kappa) and the DiT receives 1000 sigma_tok
16
+ as per-token timestep (kappa average-pooled to the 2 x 2 token grid). Each latent frame cross-attends to its own
17
+ scene prompt (prompts change inside scene-transition clips); with probability ``scene_dropout`` the whole clip
18
+ uses NULL_PROMPT.
19
+ * Sampling. 50 Euler steps of the shift-5 schedule, no classifier-free guidance, NULL_PROMPT. Latent frame 0 is
20
+ written back to the encoded first frame after every step. With CNS each latent cell is integrated along its own
21
+ sigma_tok axis.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import random
27
+
28
+ import numpy as np
29
+ import torch
30
+ import torch.nn as nn
31
+
32
+ from ..checkpoint import meta_bool, meta_float, read_metadata
33
+ from ..cns import cns_sigma
34
+ from ..coords import knownness, loss_weight_map
35
+ from ..data import load_annotations, load_text_table, training_clips, validation_clips
36
+ from ..geometry import FPS, frames_to_cells
37
+ from ..prompts import NULL_PROMPT
38
+ from .wan_dit import MASK_EMBEDDING, arope_forward, install_mask_channel
39
+
40
+ MODEL_NAME = "wan2.2-ti2v-5b"
41
+ DIT_FILES = [f"diffusion_pytorch_model-0000{i}-of-00003.safetensors" for i in (1, 2, 3)]
42
+ VAE_FILE = "Wan2.2_VAE.pth"
43
+
44
+ LATENT_SHAPE = (48, 21, 30, 52) # (channels, latent frames, height, width) of an 81-frame 832x480 clip
45
+ LAT_H, LAT_W = LATENT_SHAPE[2], LATENT_SHAPE[3]
46
+ PX_PER_LATENT = 16 # VAE stride
47
+ PX_PER_TOKEN = 32 # VAE stride x DiT patch size
48
+ SHIFT = 5.0 # Wan2.2 flow-matching shift (training and sampling)
49
+ STEPS = 50
50
+ VAE_TILE = dict(tile_size=(30, 52), tile_stride=(15, 26)) # only used if tiled=True; frames are encoded whole
51
+
52
+ # Evaluation videos of the paper were written with imageio's quality scale 9 (libx264, crf 5).
53
+ WRITE_KW = {"quality": 9}
54
+ NULL_PROMPT_FILE = "umt5_null_prompt.pt" # {NULL_PROMPT: UMT5 embedding}, shipped with the checkpoint
55
+
56
+
57
+ # ----------------------------------------------------------------------------- helpers
58
+ def cell_offsets(frame_offset_px: np.ndarray) -> torch.Tensor:
59
+ """Per-frame camera offsets [81, 2] -> per-latent-frame offsets float32 [21, 2] (mean over the frames of a cell)."""
60
+ return torch.from_numpy(frames_to_cells(frame_offset_px)).to(torch.float32)
61
+
62
+
63
+ def known_input(offset_px: torch.Tensor) -> torch.Tensor:
64
+ """Input of the known/new channel: float32 [b, 1, 21, 30, 52], 1 = known, 0 = newly revealed."""
65
+ return knownness(offset_px, LAT_H, LAT_W, PX_PER_LATENT)
66
+
67
+
68
+ def cns_kappa(offset_px: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
69
+ """Knownness on the latent grid [b, 1, f, 30, 52] (noise levels) and on the token grid [b, f, 15, 26]
70
+ (timestep embedding; the mean over the 2 x 2 latent cells of a token)."""
71
+ kappa = knownness(offset_px, LAT_H, LAT_W, PX_PER_LATENT)
72
+ return kappa, torch.nn.functional.avg_pool2d(kappa[:, 0], kernel_size=2)
73
+
74
+
75
+ def load_pipeline(model_dir: str, device: str = "cuda", vae: bool = True, torch_dtype=torch.bfloat16):
76
+ """DiffSynth WanVideoPipeline with the DiT (and the VAE); no text encoder."""
77
+ from diffsynth.pipelines.wan_video import ModelConfig, WanVideoPipeline
78
+ configs = [ModelConfig(path=[os.path.join(model_dir, f) for f in DIT_FILES])]
79
+ if vae:
80
+ configs.append(ModelConfig(path=os.path.join(model_dir, VAE_FILE)))
81
+ return WanVideoPipeline.from_pretrained(torch_dtype=torch_dtype, device=device, model_configs=configs,
82
+ tokenizer_config=None, redirect_common_files=False)
83
+
84
+
85
+ def load_checkpoint(dit: nn.Module, path: str) -> dict:
86
+ """Load a full ActionRoPE checkpoint into ``dit`` (strict); installs the known/new channel if the checkpoint has
87
+ one. Returns the checkpoint metadata."""
88
+ from safetensors.torch import load_file
89
+ meta = read_metadata(path)
90
+ sd = load_file(path)
91
+ has_mask = any(k.startswith(MASK_EMBEDDING + ".") for k in sd)
92
+ if meta.get("mask_channel") is not None and meta_bool(meta, "mask_channel") != has_mask:
93
+ raise ValueError(f"{path}: metadata mask_channel={meta['mask_channel']} but the tensors say {has_mask}")
94
+ if has_mask:
95
+ install_mask_channel(dit)
96
+ dit.load_state_dict(sd, strict=True)
97
+ return meta
98
+
99
+
100
+ def build_text_table(prompts: list[str], model_dir: str, device: str = "cuda") -> dict[str, torch.Tensor]:
101
+ """{prompt: bf16 [512, 4096]} UMT5 embeddings (the encoder of the Wan family)."""
102
+ from .umt5 import build_text_table as _build
103
+ return _build(prompts, model_dir, device)
104
+
105
+
106
+ class PromptEmbeddings:
107
+ """Prompt -> UMT5 embedding. Looks the prompt up in an optional embedding file ({prompt: tensor}, e.g. the text
108
+ table of ``scripts/build_text_table.py``) and otherwise encodes it with UMT5 from ``model_dir`` (loaded lazily)."""
109
+
110
+ def __init__(self, model_dir: str, device: str, table: str | dict | None = None):
111
+ self.model_dir, self.device = model_dir, device
112
+ self.table = dict(load_text_table(table) if isinstance(table, str) else (table or {}))
113
+ self.encoder = None
114
+
115
+ def __call__(self, prompt: str) -> torch.Tensor:
116
+ if prompt not in self.table:
117
+ if self.encoder is None:
118
+ from .umt5 import UMT5Encoder
119
+ self.encoder = UMT5Encoder(self.model_dir, self.device)
120
+ self.table[prompt] = self.encoder([prompt])[0]
121
+ return self.table[prompt].unsqueeze(0).to(device=self.device, dtype=torch.bfloat16) # [1, 512, 4096]
122
+
123
+
124
+ # ----------------------------------------------------------------------------- VAE
125
+ class _vae_fp32:
126
+ """Run the VAE in fp32 inside the block."""
127
+
128
+ def __init__(self, pipe):
129
+ self.pipe = pipe
130
+
131
+ def __enter__(self):
132
+ self.pipe.vae.model.float()
133
+
134
+ def __exit__(self, *exc):
135
+ self.pipe.vae.model.to(self.pipe.torch_dtype)
136
+
137
+
138
+ @torch.no_grad()
139
+ def encode_first_frame(pipe, img: np.ndarray) -> torch.Tensor:
140
+ """uint8 [480, 832, 3] -> latent [1, 48, 1, 30, 52]: the pipeline's (bf16-loaded) VAE run in fp32, not tiled, as in
141
+ the paper's evaluation. ``LatentEncoder`` loads the VAE weights in full fp32 instead (closer to the training data)."""
142
+ from PIL import Image
143
+ x = pipe.preprocess_image(Image.fromarray(img), torch_dtype=torch.float32).transpose(0, 1) # [3, 1, H, W]
144
+ with _vae_fp32(pipe):
145
+ z = pipe.vae.encode([x], device=pipe.device, tiled=False, **VAE_TILE)
146
+ return z.to(dtype=pipe.torch_dtype, device=pipe.device)
147
+
148
+
149
+ @torch.no_grad()
150
+ def decode(pipe, latents: torch.Tensor) -> np.ndarray:
151
+ """latent [1, 48, 21, 30, 52] -> uint8 frames [81, 480, 832, 3] (not tiled)."""
152
+ video = pipe.vae.decode(latents, device=pipe.device, tiled=False, **VAE_TILE)
153
+ return np.stack([np.array(f) for f in pipe.vae_output_to_video(video)], 0)
154
+
155
+
156
+ # ----------------------------------------------------------------------------- sampling
157
+ @torch.no_grad()
158
+ def sample(dit, z0: torch.Tensor, context: torch.Tensor, offset_px: torch.Tensor, mask_input: torch.Tensor | None, *,
159
+ steps: int = STEPS, shift: float = SHIFT, seed: int = 0, cns: dict | None = None) -> torch.Tensor:
160
+ """Flow-matching Euler sampler (no guidance). z0 [1, 48, 1, 30, 52] first-frame latent, context [1, L, 4096],
161
+ offset_px [1, 21, 2]. ``cns`` = {"shift_known", "alpha"} enables CNS with base shift ``shift``."""
162
+ from diffsynth.diffusion.flow_match import FlowMatchScheduler
163
+ sched = FlowMatchScheduler("Wan")
164
+ sched.set_timesteps(steps, shift=shift)
165
+ gen = torch.Generator("cpu").manual_seed(seed)
166
+ latents = torch.randn((1, z0.shape[1], LATENT_SHAPE[1], LAT_H, LAT_W), generator=gen, dtype=torch.float32)
167
+ latents = latents.to(dtype=z0.dtype, device=z0.device)
168
+ latents[:, :, 0:1] = z0
169
+ if cns is not None:
170
+ kappa, kappa_tok = cns_kappa(offset_px)
171
+ kappa, kappa_tok = kappa.to(latents.device), kappa_tok.to(latents.device)
172
+
173
+ def sigma_tok(s, k):
174
+ return cns_sigma(s, k, shift, cns["shift_known"], cns["alpha"])
175
+ for i, t in enumerate(sched.timesteps):
176
+ timestep = t.unsqueeze(0).to(dtype=latents.dtype, device=latents.device)
177
+ tmap = None
178
+ if cns is not None:
179
+ tmap = (1000.0 * sigma_tok(float(sched.sigmas[i]), kappa_tok)).to(latents.dtype)
180
+ pred = arope_forward(dit, latents, timestep, context, offset_px=offset_px, px_per_token=PX_PER_TOKEN,
181
+ mask_input=mask_input, timestep_map=tmap, first_frame_cond=True)
182
+ if cns is not None:
183
+ s_i = float(sched.sigmas[i])
184
+ s_n = float(sched.sigmas[i + 1]) if i + 1 < steps else 0.0
185
+ latents = latents + pred * (sigma_tok(s_n, kappa) - sigma_tok(s_i, kappa)).to(latents.dtype)
186
+ else:
187
+ latents = sched.step(pred, sched.timesteps[i], latents)
188
+ latents[:, :, 0:1] = z0
189
+ return latents
190
+
191
+
192
+ class Generator:
193
+ """First frame + per-frame camera offsets -> 81-frame clip with a full ActionRoPE checkpoint.
194
+
195
+ Prompt embeddings are looked up in ``text_table`` (a {prompt: embedding} file such as the training text table;
196
+ default: ``umt5_null_prompt.pt`` next to the checkpoint, which holds NULL_PROMPT) and otherwise computed with the
197
+ UMT5 encoder of ``model_dir`` (loaded on first use). The lookup is the path of the paper's evaluation: it avoids
198
+ the 11 GB encoder, and the evaluation clips are reproduced bit-exactly only if no other model has run on the GPU
199
+ in the same process before the first sampling call (e.g. running UMT5 first changes cuDNN's attention kernel
200
+ choice, a bf16-rounding-level difference).
201
+ """
202
+
203
+ def __init__(self, ckpt: str, model_dir: str, device: str = "cuda", text_table: str | dict | None = None, **kw):
204
+ self.device = device
205
+ self.pipe = load_pipeline(model_dir, device, vae=True)
206
+ self.dit = self.pipe.dit
207
+ self.meta = load_checkpoint(self.dit, ckpt)
208
+ self.dit.eval().requires_grad_(False)
209
+ self.mask_channel = hasattr(self.dit, MASK_EMBEDDING)
210
+ self.shift = meta_float(self.meta, "cns_shift", SHIFT)
211
+ self.cns = ({"shift_known": meta_float(self.meta, "cns_shift_known"), "alpha": meta_float(self.meta, "cns_alpha", 1.0)}
212
+ if meta_bool(self.meta, "cns") else None)
213
+ if text_table is None:
214
+ shipped = os.path.join(os.path.dirname(os.path.abspath(ckpt)), NULL_PROMPT_FILE)
215
+ text_table = shipped if os.path.isfile(shipped) else None
216
+ self.prompts = PromptEmbeddings(model_dir, device, text_table)
217
+
218
+ @torch.no_grad()
219
+ def __call__(self, first_frame: np.ndarray, frame_offset_px: np.ndarray, prompt: str = NULL_PROMPT, seed: int = 0,
220
+ steps: int = STEPS, shift: float | None = None, cns: bool | None = None) -> tuple[np.ndarray, float]:
221
+ z0 = encode_first_frame(self.pipe, first_frame)
222
+ context = self.prompts(prompt)
223
+ offset = cell_offsets(frame_offset_px).unsqueeze(0).to(self.device)
224
+ mask = known_input(offset) if self.mask_channel else None
225
+ use_cns = self.cns is not None if cns is None else bool(cns)
226
+ if use_cns and self.cns is None:
227
+ raise ValueError("cns=True needs a checkpoint trained with CNS (no cns_shift_known in the metadata)")
228
+ latents = sample(self.dit, z0, context, offset, mask, steps=steps, shift=self.shift if shift is None else shift,
229
+ seed=seed, cns=self.cns if use_cns else None)
230
+ return decode(self.pipe, latents), float(FPS)
231
+
232
+ def eval_frames(self, frames: np.ndarray) -> np.ndarray:
233
+ return frames
234
+
235
+
236
+ # ----------------------------------------------------------------------------- latents
237
+ class LatentEncoder:
238
+ """81 uint8 frames -> {"latent": bf16 [48, 21, 30, 52]} with the Wan2.2 VAE in fp32 (not tiled)."""
239
+
240
+ def __init__(self, model_dir: str, device: str = "cuda"):
241
+ from diffsynth.pipelines.wan_video import ModelConfig, WanVideoPipeline
242
+ self.device = device
243
+ self.pipe = WanVideoPipeline.from_pretrained(torch_dtype=torch.float32, device=device, tokenizer_config=None,
244
+ model_configs=[ModelConfig(path=os.path.join(model_dir, VAE_FILE))],
245
+ redirect_common_files=False)
246
+
247
+ @torch.no_grad()
248
+ def __call__(self, frames: np.ndarray, clip=None) -> dict:
249
+ x = torch.from_numpy(np.array(frames, dtype=np.uint8)).permute(3, 0, 1, 2).float().div_(127.5).sub_(1.0)
250
+ z = self.pipe.vae.encode([x], device=self.device, tiled=False, **VAE_TILE)[0]
251
+ if tuple(z.shape) != LATENT_SHAPE:
252
+ raise ValueError(f"latent shape {tuple(z.shape)}, expected {LATENT_SHAPE} (frames must be 81 x 480 x 832)")
253
+ return {"latent": z.to(torch.bfloat16).cpu()}
254
+
255
+
256
+ # ----------------------------------------------------------------------------- training
257
+ class LatentDataset(torch.utils.data.Dataset):
258
+ """Pre-computed latents + per-latent-frame scene prompts (looked up in the text table) + camera offsets."""
259
+
260
+ def __init__(self, clips, latent_dir: str, table: dict, scene_dropout: float = 0.0):
261
+ self.clips, self.latent_dir, self.table = clips, latent_dir, table
262
+ self.scene_dropout = float(scene_dropout)
263
+ # prompt identity -> id: latent frames with equal ids share one text embedding in the forward pass
264
+ self.prompt_id = {k: i for i, k in enumerate(sorted(table))}
265
+ missing = sorted({p for c in clips for p in c.prompts} - set(table))
266
+ if missing:
267
+ raise KeyError(f"{len(missing)} prompts are missing from the text table, e.g. {missing[0]!r}")
268
+ if self.scene_dropout > 0 and NULL_PROMPT not in table:
269
+ raise KeyError(f"scene dropout needs {NULL_PROMPT!r} in the text table")
270
+
271
+ def __len__(self):
272
+ return len(self.clips)
273
+
274
+ def __getitem__(self, i):
275
+ c = self.clips[i]
276
+ latent = torch.load(os.path.join(self.latent_dir, c.clip + ".pt"), map_location="cpu", weights_only=False)["latent"]
277
+ if tuple(latent.shape) != LATENT_SHAPE:
278
+ raise ValueError(f"{c.clip}: latent shape {tuple(latent.shape)}, expected {LATENT_SHAPE}")
279
+ drop = self.scene_dropout > 0 and random.random() < self.scene_dropout # scene dropout: whole clip
280
+ prompts = [NULL_PROMPT] * len(c.prompts) if drop else c.prompts
281
+ return {"clip": c.clip,
282
+ "latent": latent.to(torch.bfloat16).contiguous(),
283
+ "context": torch.stack([self.table[p] for p in prompts], 0).to(torch.bfloat16),
284
+ "context_ids": torch.tensor([self.prompt_id[p] for p in prompts], dtype=torch.int64),
285
+ "offset_px": cell_offsets(c.frame_offset_px)}
286
+
287
+
288
+ def collate(samples: list[dict]) -> dict:
289
+ return {k: torch.stack([s[k] for s in samples], 0) if torch.is_tensor(samples[0][k]) else [s[k] for s in samples]
290
+ for k in samples[0]}
291
+
292
+
293
+ class TrainModule(nn.Module):
294
+ """DiT + ActionRoPE/CNS flow-matching loss; ``forward`` returns (loss, stats) as required by DeepSpeed."""
295
+
296
+ def __init__(self, dit, mask_channel: bool = True, new_weight: float = 2.0, cns: bool = True, cns_shift: float = SHIFT,
297
+ cns_shift_known: float = 2.0, cns_alpha: float = 1.0, gradient_checkpointing: bool = True):
298
+ super().__init__()
299
+ from diffsynth.diffusion.flow_match import FlowMatchScheduler
300
+ self.dit = dit
301
+ self.mask_channel, self.new_weight = bool(mask_channel), float(new_weight)
302
+ self.cns, self.cns_shift = bool(cns), float(cns_shift)
303
+ self.cns_shift_known, self.cns_alpha = float(cns_shift_known), float(cns_alpha)
304
+ self.gradient_checkpointing = bool(gradient_checkpointing)
305
+ self.scheduler = FlowMatchScheduler("Wan") # sigma = 5 u / (1 + 4 u) over 1000 training steps
306
+ self.scheduler.set_timesteps(1000, training=True)
307
+
308
+ def forward(self, batch: dict, timestep_id: int, generator: torch.Generator | None = None, train: bool = True):
309
+ x0 = batch["latent"] # bf16 [b, 48, 21, 30, 52]
310
+ b, c = x0.shape[:2]
311
+ offset_px = batch["offset_px"].to(x0.device) # float32 [b, 21, 2]
312
+ timestep = self.scheduler.timesteps[timestep_id].view(1)
313
+ noise = (torch.randn_like(x0) if generator is None
314
+ else torch.randn(x0.shape, generator=generator, device=x0.device, dtype=x0.dtype))
315
+ timestep_map = None
316
+ if self.cns:
317
+ # CNS: every latent cell is noised to sigma_tok(sigma, kappa); the target stays eps - x0
318
+ sigma = float(self.scheduler.sigmas[timestep_id])
319
+ kappa, kappa_tok = cns_kappa(offset_px)
320
+ sig = cns_sigma(sigma, kappa, self.cns_shift, self.cns_shift_known, self.cns_alpha).to(x0.dtype)
321
+ x_t = (1.0 - sig) * x0 + sig * noise
322
+ timestep_map = (1000.0 * cns_sigma(sigma, kappa_tok, self.cns_shift, self.cns_shift_known,
323
+ self.cns_alpha)).to(x0.dtype)
324
+ else:
325
+ x_t = self.scheduler.add_noise(x0, noise, timestep)
326
+ target = self.scheduler.training_target(x0, noise, timestep)
327
+ x_t[:, :, 0:1] = x0[:, :, 0:1] # latent frame 0 = clean first frame
328
+ weight = loss_weight_map(offset_px, LAT_H, LAT_W, PX_PER_LATENT, self.new_weight)
329
+ mask = known_input(offset_px) if self.mask_channel else None
330
+ pred = arope_forward(self.dit, x_t, timestep.to(device=x0.device, dtype=x0.dtype), batch["context"],
331
+ offset_px=offset_px, px_per_token=PX_PER_TOKEN, mask_input=mask, timestep_map=timestep_map,
332
+ context_ids=batch["context_ids"], first_frame_cond=True,
333
+ use_gradient_checkpointing=self.gradient_checkpointing and train)
334
+ pred, target, weight = pred[:, :, 1:], target[:, :, 1:], weight[:, :, 1:] # no loss on the first frame
335
+ se = (pred.float() - target.float()) ** 2
336
+ loss_w = (se * weight).sum() / (weight.sum() * c) # weight broadcasts over channels
337
+ loss = loss_w * self.scheduler.training_weight(timestep).to(loss_w.device)
338
+ return loss, {"loss_w": loss_w.detach(), "mse": se.mean().detach(), "timestep": float(timestep)}
339
+
340
+
341
+ class TrainTask:
342
+ """Training task for ``actionrope.trainer.train`` (see ``configs/train/wan.yaml``)."""
343
+
344
+ def __init__(self, cfg: dict):
345
+ self.cfg = cfg
346
+ self._table = None
347
+ from diffsynth.diffusion.flow_match import FlowMatchScheduler
348
+ self._sched = FlowMatchScheduler("Wan")
349
+ self._sched.set_timesteps(1000, training=True)
350
+
351
+ def _method(self) -> dict:
352
+ c = self.cfg
353
+ return dict(mask_channel=bool(c.get("mask_channel", True)), new_weight=float(c.get("new_weight", 2.0)),
354
+ cns=bool(c.get("cns", True)), cns_shift=float(c.get("cns_shift", SHIFT)),
355
+ cns_shift_known=float(c.get("cns_shift_known", 2.0)), cns_alpha=float(c.get("cns_alpha", 1.0)))
356
+
357
+ def build_module(self) -> nn.Module:
358
+ m = self._method()
359
+ dit = load_pipeline(self.cfg["model_dir"], device="cpu", vae=False).dit
360
+ if m["mask_channel"]:
361
+ install_mask_channel(dit)
362
+ dit.train().requires_grad_(True)
363
+ return TrainModule(dit, gradient_checkpointing=bool(self.cfg.get("gradient_checkpointing", True)), **m)
364
+
365
+ def datasets(self):
366
+ c = self.cfg
367
+ if self._table is None:
368
+ self._table = load_text_table(c["text_table"])
369
+ clips = load_annotations(c["annotations"])
370
+ train = training_clips(clips, min_rt_ratio=float(c.get("min_rt_ratio", 0.8)))
371
+ train_ds = LatentDataset(train, c["latent_dir"], self._table, scene_dropout=float(c.get("scene_dropout", 0.1)))
372
+ val_ds = None
373
+ if int(c.get("val_every", 0)) > 0 and int(c.get("val_clips", 16)) > 0:
374
+ val = validation_clips(clips, min_rt_ratio=float(c.get("val_min_rt_ratio", 0.9)), n=int(c.get("val_clips", 16)),
375
+ seed=int(c["seed"]))
376
+ val_ds = LatentDataset(val, c["latent_dir"], self._table, scene_dropout=0.0)
377
+ return train_ds, val_ds
378
+
379
+ collate = staticmethod(collate)
380
+
381
+ def on_prepared(self, module, device):
382
+ # the RoPE tables are plain tensors (not buffers): move them once instead of on every step
383
+ module.dit.freqs = tuple(t.to(device) for t in module.dit.freqs)
384
+
385
+ def export(self, state_dict: dict, step: int):
386
+ tensors = {k[len("dit."):]: v for k, v in state_dict.items() if k.startswith("dit.")}
387
+ m = self._method()
388
+ meta = {"model": MODEL_NAME, "format": "full", "mask_channel": m["mask_channel"], "cns": m["cns"],
389
+ "cns_shift": m["cns_shift"], "cns_shift_known": m["cns_shift_known"], "cns_alpha": m["cns_alpha"]}
390
+ return tensors, meta
391
+
392
+ def timestep_value(self, tid: int) -> float:
393
+ return float(self._sched.timesteps[int(tid)])
actionrope/models/wan_dit.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ActionRoPE forward pass for the Wan-family video DiT (``diffsynth.models.wan_video_dit.WanModel``).
2
+
3
+ Used by Wan2.2-TI2V-5B (``wan.py``) and SkyReels-V2-DF-1.3B (``skyreels.py``), which share the DiT architecture.
4
+
5
+ ActionRoPE (Sec. 3.1): token (i, j) of latent frame k gets the rotary position (k, i + dy_k / s, j + dx_k / s) instead
6
+ of its screen position (k, i, j), where (dx_k, dy_k) is the camera offset of latent frame k in pixels and s the number
7
+ of pixels per token (32 for Wan2.2, 16 for SkyReels-V2). Tokens that see the same world location therefore share the
8
+ same positional address across time. The rotary frequencies are the DiT's own; only the positions change, so no
9
+ parameters are added on the control path.
10
+
11
+ ``arope_forward`` re-implements the DiT forward of DiffSynth's ``model_fn_wan_video`` for image-to-video with a
12
+ clean first frame (latent frame 0 at timestep 0) and adds:
13
+ * world-coordinate rotary positions (``offset_px``),
14
+ * the optional known/new input channel (``mask_input``, a zero-initialised Conv3d added to the patch embedding),
15
+ * per-token timesteps (``timestep_map``) for CNS,
16
+ * per-latent-frame text (context [b, f, L, C]: every latent frame cross-attends only to its own prompt).
17
+ With ``offset_px=None``, no mask, no timestep map and one prompt it equals the original forward.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import functools
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+ from einops import rearrange
26
+
27
+ from diffsynth.core.gradient import gradient_checkpoint_forward
28
+ from diffsynth.models.wan_video_dit import WanModel, modulate, sinusoidal_embedding_1d
29
+
30
+ ROPE_THETA = 10000.0
31
+ MASK_EMBEDDING = "arope_mask_embedding"
32
+
33
+
34
+ # ----------------------------------------------------------------------------- world-coordinate RoPE
35
+ def _inv_freq(dim: int, device=None) -> torch.Tensor:
36
+ """Inverse frequencies of the DiT's 1-D RoPE (identical to DiffSynth's ``precompute_freqs_cis(dim)``)."""
37
+ return 1.0 / (ROPE_THETA ** (torch.arange(0, dim, 2, device=device)[: (dim // 2)].double() / dim))
38
+
39
+
40
+ def _axis_freqs(pos: torch.Tensor, dim: int) -> torch.Tensor:
41
+ """1-D RoPE at arbitrary (fractional) positions, complex128 [..., dim // 2]; equals the table at integers."""
42
+ angle = pos.double().unsqueeze(-1) * _inv_freq(dim, pos.device)
43
+ return torch.polar(torch.ones_like(angle), angle)
44
+
45
+
46
+ def build_world_freqs(dit: WanModel, f: int, h: int, w: int, offset_tok: torch.Tensor | None, device=None) -> torch.Tensor:
47
+ """Rotary frequencies of all f*h*w tokens at positions (k, i + dy_k, j + dx_k).
48
+
49
+ offset_tok: [b, f, 2] = (dx, dy) in token units, or None for the standard screen grid.
50
+ Returns [f*h*w, 1, d] (standard) or [b, f*h*w, 1, d] (world coordinates), complex like ``dit.freqs``.
51
+ """
52
+ f_table, h_table, w_table = dit.freqs
53
+ if device is None:
54
+ device = offset_tok.device if offset_tok is not None else f_table.device
55
+ d_h, d_w = h_table.shape[1] * 2, w_table.shape[1] * 2
56
+ if offset_tok is None:
57
+ return torch.cat([
58
+ f_table[:f].view(f, 1, 1, -1).expand(f, h, w, -1),
59
+ h_table[:h].view(1, h, 1, -1).expand(f, h, w, -1),
60
+ w_table[:w].view(1, 1, w, -1).expand(f, h, w, -1),
61
+ ], dim=-1).reshape(f * h * w, 1, -1).to(device)
62
+ offset_tok = offset_tok.to(device=device, dtype=torch.float64)
63
+ b = offset_tok.shape[0]
64
+ assert offset_tok.shape == (b, f, 2), f"offset_tok must be [b, {f}, 2], got {tuple(offset_tok.shape)}"
65
+ dx, dy = offset_tok[..., 0], offset_tok[..., 1]
66
+ pos_h = torch.arange(h, device=device, dtype=torch.float64).view(1, 1, h) + dy.view(b, f, 1)
67
+ pos_w = torch.arange(w, device=device, dtype=torch.float64).view(1, 1, w) + dx.view(b, f, 1)
68
+ fh, fw = _axis_freqs(pos_h, d_h), _axis_freqs(pos_w, d_w)
69
+ freqs = torch.cat([
70
+ f_table[:f].to(device).view(1, f, 1, 1, -1).expand(b, f, h, w, -1),
71
+ fh.view(b, f, h, 1, -1).expand(b, f, h, w, -1),
72
+ fw.view(b, f, 1, w, -1).expand(b, f, h, w, -1),
73
+ ], dim=-1)
74
+ return freqs.reshape(b, f * h * w, 1, -1).to(f_table.dtype)
75
+
76
+
77
+ # ----------------------------------------------------------------------------- known/new input channel
78
+ def install_mask_channel(dit: WanModel) -> WanModel:
79
+ """Add the zero-initialised known/new input channel ``dit.arope_mask_embedding`` = Conv3d(1, dim, patch, patch).
80
+ The model output is unchanged at installation time."""
81
+ if not hasattr(dit, MASK_EMBEDDING):
82
+ ref = dit.patch_embedding
83
+ conv = nn.Conv3d(1, dit.dim, kernel_size=dit.patch_size, stride=dit.patch_size)
84
+ nn.init.zeros_(conv.weight)
85
+ nn.init.zeros_(conv.bias)
86
+ dit.add_module(MASK_EMBEDDING, conv.to(device=ref.weight.device, dtype=ref.weight.dtype))
87
+ return dit
88
+
89
+
90
+ # ----------------------------------------------------------------------------- forward
91
+ def _per_token_timestep(latents, timestep, n_tok: int, first_frame_cond: bool, timestep_map=None) -> torch.Tensor:
92
+ """[b, f * n_tok] timesteps (0..1000); latent frame 0 is the clean condition frame (t = 0)."""
93
+ b, f = latents.shape[0], latents.shape[2]
94
+ if timestep_map is not None:
95
+ t_tok = timestep_map.to(device=latents.device, dtype=latents.dtype).reshape(b, f, n_tok).clone()
96
+ else:
97
+ timestep = torch.as_tensor(timestep, device=latents.device).to(latents.dtype).reshape(-1)
98
+ timestep = timestep.expand(b) if timestep.numel() == 1 else timestep
99
+ t_tok = timestep.view(b, 1, 1).expand(b, f, n_tok).clone()
100
+ if first_frame_cond:
101
+ t_tok[:, 0] = 0
102
+ return t_tok.reshape(b, f * n_tok)
103
+
104
+
105
+ def _embed_context(dit: WanModel, context: torch.Tensor, context_ids=None) -> torch.Tensor:
106
+ """[b, L, C] -> [b, L, dim]; per-frame context [b, f, L, C] -> [(b f), L, dim]. Frames with equal
107
+ ``context_ids`` [b, f] share a prompt and are embedded once."""
108
+ if context.dim() == 3:
109
+ return dit.text_embedding(context)
110
+ b, f, L, c = context.shape
111
+ rows = context.reshape(b * f, L, c)
112
+ if context_ids is None:
113
+ return dit.text_embedding(rows)
114
+ ids = torch.as_tensor(context_ids).reshape(-1).tolist()
115
+ first = {}
116
+ for i, k in enumerate(ids):
117
+ first.setdefault(k, i)
118
+ uniq = list(first)
119
+ emb = dit.text_embedding(rows[torch.tensor([first[k] for k in uniq], device=rows.device)])
120
+ return emb[torch.tensor([uniq.index(k) for k in ids], device=rows.device)]
121
+
122
+
123
+ def _block_forward(block, x, context, t_mod, freqs, *, f: int):
124
+ """``DiTBlock.forward`` with per-frame text cross-attention when context is [(b f), L, dim]."""
125
+ has_seq = len(t_mod.shape) == 4
126
+ chunk_dim = 2 if has_seq else 1
127
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
128
+ block.modulation.to(dtype=t_mod.dtype, device=t_mod.device) + t_mod).chunk(6, dim=chunk_dim)
129
+ if has_seq:
130
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
131
+ shift_msa.squeeze(2), scale_msa.squeeze(2), gate_msa.squeeze(2),
132
+ shift_mlp.squeeze(2), scale_mlp.squeeze(2), gate_mlp.squeeze(2))
133
+ x = block.gate(x, gate_msa, block.self_attn(modulate(block.norm1(x), shift_msa, scale_msa), freqs))
134
+ q_in = block.norm3(x)
135
+ if context.shape[0] == x.shape[0]:
136
+ x = x + block.cross_attn(q_in, context)
137
+ else:
138
+ q_in = rearrange(q_in, "b (f n) c -> (b f) n c", f=f)
139
+ x = x + rearrange(block.cross_attn(q_in, context), "(b f) n c -> b (f n) c", f=f)
140
+ return block.gate(x, gate_mlp, block.ffn(modulate(block.norm2(x), shift_mlp, scale_mlp)))
141
+
142
+
143
+ def arope_forward(dit: WanModel, latents: torch.Tensor, timestep, context: torch.Tensor, *,
144
+ offset_px: torch.Tensor | None = None, px_per_token: float = 32, mask_input: torch.Tensor | None = None,
145
+ timestep_map: torch.Tensor | None = None, context_ids=None, first_frame_cond: bool = True,
146
+ t_mod_fn=None, use_gradient_checkpointing: bool = False) -> torch.Tensor:
147
+ """Velocity prediction for latents [b, c, f, H, W].
148
+
149
+ offset_px [b, f, 2] camera offset of every latent frame in pixels (None: standard RoPE)
150
+ px_per_token pixels per DiT token (VAE stride x patch size)
151
+ mask_input [b, 1, f, H, W] known = 1 / new = 0 (requires ``install_mask_channel``)
152
+ timestep_map [b, f, H / 2, W / 2] per-token timesteps for CNS (None: ``timestep`` for every token)
153
+ context [b, L, C] (one prompt) or [b, f, L, C] (one prompt per latent frame, see ``context_ids``)
154
+ t_mod_fn optional callable applied to the timestep modulation [b, n, 6, dim]
155
+ """
156
+ b, _, f, lat_h, lat_w = latents.shape
157
+ _, p_h, p_w = dit.patch_size
158
+ n_tok = (lat_h // p_h) * (lat_w // p_w)
159
+
160
+ t_tok = _per_token_timestep(latents, timestep, n_tok, first_frame_cond, timestep_map)
161
+ t = dit.time_embedding(sinusoidal_embedding_1d(dit.freq_dim, t_tok.reshape(-1)).view(b, t_tok.shape[1], -1))
162
+ t_mod = dit.time_projection(t).unflatten(2, (6, dit.dim))
163
+ if t_mod_fn is not None:
164
+ t_mod = t_mod_fn(t_mod)
165
+ context = _embed_context(dit, context, context_ids)
166
+
167
+ x = dit.patch_embedding(latents)
168
+ if mask_input is not None:
169
+ if not hasattr(dit, MASK_EMBEDDING):
170
+ raise ValueError("mask_input given but the known/new input channel is not installed")
171
+ x = x + getattr(dit, MASK_EMBEDDING)(mask_input.to(device=x.device, dtype=x.dtype))
172
+ f, h, w = x.shape[2:]
173
+ x = rearrange(x, "b c f h w -> b (f h w) c").contiguous()
174
+
175
+ offset_tok = None if offset_px is None else offset_px.to(torch.float64) / px_per_token
176
+ freqs = build_world_freqs(dit, f, h, w, offset_tok, device=x.device)
177
+ for block in dit.blocks:
178
+ x = gradient_checkpoint_forward(functools.partial(_block_forward, block, f=f),
179
+ use_gradient_checkpointing, False, x, context, t_mod, freqs)
180
+ x = dit.head(x, t)
181
+ return dit.unpatchify(x, (f, h, w))
actionrope/prompts.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text prompts.
2
+
3
+ ActionRoPE never puts the action into the text: the prompt only describes the scene,
4
+
5
+ "In a 2.5D top down view, the player is in a high rocky mountain pass."
6
+
7
+ and ``NULL_PROMPT`` (no scene) is used for scene dropout during training and for all evaluation clips.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ NULL_PROMPT = "In a 2.5D top down view."
14
+ SCENE_TEMPLATE = "In a 2.5D top down view, the player is {scene}."
15
+
16
+ _WITH_ACTION = re.compile(r"^In a 2\.5D top down view, (?P<scene>.+), the player is (?P<action>.+)\.$")
17
+ _ACTION_ONLY = re.compile(r"^In a 2\.5D top down view, the player is (?P<action>.+)\.$")
18
+
19
+
20
+ def scene_prompt(scene: str | None) -> str:
21
+ """Scene description (e.g. "in a dark cave lit by blue crystal glow") -> prompt; None -> NULL_PROMPT."""
22
+ return NULL_PROMPT if not scene else SCENE_TEMPLATE.format(scene=scene)
23
+
24
+
25
+ def strip_action(prompt: str) -> str:
26
+ """"In a 2.5D top down view, {scene}, the player is {action}." -> scene_prompt(scene).
27
+
28
+ Captions that carry an action but no scene collapse to NULL_PROMPT."""
29
+ m = _WITH_ACTION.match(prompt)
30
+ if m:
31
+ return scene_prompt(m.group("scene"))
32
+ if _ACTION_ONLY.match(prompt):
33
+ return NULL_PROMPT
34
+ raise ValueError(f"prompt does not follow the caption template: {prompt!r}")
app.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ActionRoPE: action as coordinates for spatially consistent control in planar-camera game world models.
2
+
3
+ Gradio demo of the paper's Wan2.2-TI2V-5B backbone: a first frame (832x480) plus an
4
+ action string produce an 81-frame (5 s, 16 fps) video of the game world, with the
5
+ camera displacement injected through ActionRoPE (world-coordinate rotary positions)
6
+ and Coordinate-Conditioned Noise Scheduling (CNS).
7
+ """
8
+ import os
9
+
10
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
11
+
12
+ import spaces # noqa: E402 must come before torch
13
+ import tempfile # noqa: E402
14
+ import time # noqa: E402
15
+
16
+ import gradio as gr # noqa: E402
17
+ import numpy as np # noqa: E402
18
+
19
+ from actionrope.geometry import FRAMES as N_FRAMES # noqa: E402
20
+ from actionrope.geometry import offsets_from_actions # noqa: E402
21
+ from actionrope.media import load_first_frame # noqa: E402
22
+ from actionrope.media import write_video # noqa: E402
23
+ from actionrope.prompts import NULL_PROMPT # noqa: E402
24
+ from arope_loader import Generator, download_space_weights # noqa: E402
25
+
26
+ CKPT, NULL_PROMPT_PT, VAE_PATH = download_space_weights()
27
+ print("[app] weights cached; building model...")
28
+ GEN = Generator(CKPT, VAE_PATH, NULL_PROMPT_PT, device="cuda", torch_dtype=__import__("torch").bfloat16)
29
+ print("[app] model ready")
30
+
31
+
32
+ @spaces.GPU(duration=180)
33
+ def play(first_frame, actions: str, seed: int, steps: int, use_cns: bool = True):
34
+ """Render an 81-frame game-world video from a first frame and an action plan.
35
+
36
+ Args:
37
+ first_frame: initial observation frame (any size; centre-cropped to 26:15 and resized to 832x480).
38
+ actions: comma-separated move plan, each segment "<direction>[*speed]:<frames>",
39
+ e.g. "right:40,left:40". Directions: idle, up, down, left, right, and the four
40
+ diagonals (aliases u/d/l/r/ul/ur/dl/dr). Speed 1.0 is the game's walking speed
41
+ (69.2 px/s horizontally, 46.0 px/s vertically at 832x480).
42
+ seed: RNG seed for the initial noise.
43
+ steps: number of Euler flow-matching steps (paper setting: 50).
44
+ use_cns: use Coordinate-Conditioned Noise Scheduling (the paper's method).
45
+
46
+ Returns:
47
+ The rendered 832x480 16 fps mp4 of the 5-second rollout.
48
+ """
49
+ t0 = time.perf_counter()
50
+ offsets = offsets_from_actions(actions) # validates segment count/spec
51
+ if np.abs(offsets[N_FRAMES - 1]).max() == 0 and "idle" not in actions:
52
+ pass # a valid all-idle plan is fine
53
+ frames, fps = GEN(load_first_frame(first_frame), offsets, prompt=NULL_PROMPT,
54
+ seed=int(seed), steps=int(steps), cns=bool(use_cns))
55
+ out = os.path.join(tempfile.gettempdir(), f"arope_{int(time.time() * 1000) % 100000}.mp4")
56
+ write_video(out, frames, fps=fps, quality=9)
57
+ print(f"[app] generated {len(frames)} frames in {time.perf_counter() - t0:.1f}s -> {out}")
58
+ return out
59
+
60
+
61
+ def build_ui():
62
+ with gr.Blocks(theme=gr.themes.Citrus(), title="ActionRoPE") as demo:
63
+ gr.Markdown(
64
+ """
65
+ # 🎮 ActionRoPE — action as coordinates for game world models
66
+ Drive a video-diffusion world model with plain **actions** instead of text.
67
+ The first frame pins the scene; the action plan moves the camera through the world.
68
+ Backbones: Wan2.2-TI2V-5B (this demo) — from the paper
69
+ [ActionRoPE: action as coordinates for spatially consistent control in planar-camera game world models](https://huggingface.co/papers/2610.25861).
70
+ """
71
+ )
72
+ with gr.Row():
73
+ with gr.Column():
74
+ first_frame = gr.Image(label="First frame (832x480; other sizes are centre-cropped)", type="filepath")
75
+ actions = gr.Textbox(
76
+ label="Actions",
77
+ value="right:40,left:40",
78
+ info="comma list of <direction>[*speed]:<frames> over 80 frames; "
79
+ "speed 1.0 = walking speed (69.2 px/s horizontal / 46.0 px/s vertical)",
80
+ placeholder="right:40,left:40",
81
+ )
82
+ btn = gr.Button("Generate rollout", variant="primary")
83
+ with gr.Accordion("Advanced", open=False):
84
+ seed = gr.Number(0, label="Seed", precision=0)
85
+ steps = gr.Slider(10, 50, value=50, step=5, label="Sampling steps", info="paper setting = 50")
86
+ use_cns = gr.Checkbox(True, label="CNS (coordinate-conditioned noise scheduling)")
87
+ with gr.Column():
88
+ video = gr.Video(label="5-second rollout (81 frames, 16 fps)", autoplay=True)
89
+
90
+ gr.Examples(
91
+ examples=[
92
+ ["examples/back_01.png", "right:40,left:40"],
93
+ ["examples/back_264.png", "right:60,left:20"],
94
+ ["examples/bamboo_L.png", "up-right*1.5:32,left:40,down-left:8"],
95
+ ["examples/watercolor_L.png", "right:80"],
96
+ ],
97
+ inputs=[first_frame, actions],
98
+ fn=play,
99
+ outputs=[video],
100
+ cache_examples=True,
101
+ cache_mode="lazy",
102
+ label="Example first frames and action plans",
103
+ )
104
+
105
+ btn.click(play, inputs=[first_frame, actions, seed, steps, use_cns], outputs=[video])
106
+ return demo
107
+
108
+
109
+ if __name__ == "__main__":
110
+ build_ui().launch(mcp_server=True)
arope_loader.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weight plumbing for the ActionRoPE Space.
2
+
3
+ The upstream ``actionrope.models.wan.load_pipeline`` loads the *base* Wan2.2-TI2V-5B
4
+ DiT shards (3 files, ~20 GB) and then overwrites them with the ActionRoPE
5
+ checkpoint. The ActionRoPE checkpoint is itself a full DiffSynth-native DiT state
6
+ dict (827 tensors: blocks.* / head.* / patch_embedding / text_embedding /
7
+ time_embedding / time_projection / arope_mask_embedding), so the base shards are
8
+ redundant. This module builds ``diffsynth.models.wan_video_dit.WanModel`` directly
9
+ from the registered TI2V-5B config and strict-loads the ActionRoPE checkpoint
10
+ (exactly what upstream ``load_checkpoint`` + ``TrainTask.build_module`` do), loads
11
+ the Wan2.2 VAE the same way, and returns a minimal pipeline object plus a
12
+ ``Generator`` whose ``__call__`` is the upstream one — the paper's sampling path,
13
+ unchanged.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+
20
+ def download_space_weights() -> tuple[str, str, str]:
21
+ """Fetch the three runtime weight files (ActionRoPE DiT, its NULL-prompt table, the Wan2.2 VAE).
22
+
23
+ Returns absolute (ckpt_path, null_prompt_path, vae_path).
24
+ """
25
+ from huggingface_hub import hf_hub_download
26
+
27
+ ckpt_path = hf_hub_download(
28
+ "teawhite/ActionRoPE", "wan2.2-ti2v-5b/actionrope_cns.safetensors", repo_type="model"
29
+ )
30
+ null_prompt_path = hf_hub_download(
31
+ "teawhite/ActionRoPE", "wan2.2-ti2v-5b/umt5_null_prompt.pt", repo_type="model"
32
+ )
33
+ vae_path = hf_hub_download("Wan-AI/Wan2.2-TI2V-5B", "Wan2.2_VAE.pth", repo_type="model")
34
+ return ckpt_path, null_prompt_path, vae_path
35
+
36
+
37
+ class _VAEPipeline:
38
+ """Minimal stand-in for DiffSynth's WanVideoPipeline holding only the VAE.
39
+
40
+ Exposes exactly the three pipeline entry points ActionRoPE's ``wan.py`` uses:
41
+ ``preprocess_image``, ``vae.encode/decode`` and ``vae_output_to_video``.
42
+ """
43
+
44
+ def __init__(self, vae, device, torch_dtype):
45
+ from diffsynth.diffusion.base_pipeline import BasePipeline
46
+
47
+ self.vae = vae
48
+ self.device = device
49
+ self.torch_dtype = torch_dtype
50
+ self.preprocess_image = BasePipeline.preprocess_image.__get__(self)
51
+ self.vae_output_to_video = BasePipeline.vae_output_to_video.__get__(self)
52
+
53
+ def encode(self, videos, device, tiled=False, tile_size=(30, 52), tile_stride=(15, 26)):
54
+ return self.vae.encode(videos, device=device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride)
55
+
56
+ def decode(self, hidden_states, device, tiled=False, tile_size=(30, 52), tile_stride=(15, 26)):
57
+ return self.vae.decode(hidden_states, device=device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride)
58
+
59
+
60
+ def build_pipe(ckpt_path: str, vae_path: str, device, torch_dtype) -> tuple:
61
+ """Build (pipeline, dit, meta): DiT from the ActionRoPE ckpt, VAE from Wan2.2_VAE.pth.
62
+
63
+ No base-DiT download, no text encoder — the DiT state dict is complete in the
64
+ ActionRoPE checkpoint, and prompts come from the shipped ``umt5_null_prompt.pt``
65
+ embedding table.
66
+ """
67
+ from safetensors import safe_open
68
+
69
+ from diffsynth.core.loader.file import load_state_dict
70
+ from diffsynth.models.wan_video_dit import WanModel
71
+ from diffsynth.models.wan_video_vae import WanVideoVAE38
72
+ from diffsynth.utils.state_dict_converters.wan_video_vae import WanVideoVAEStateDictConverter
73
+
74
+ from actionrope.checkpoint import read_metadata
75
+ from actionrope.models.wan_dit import install_mask_channel
76
+
77
+ # Registered DiffSynth config for Wan-AI/Wan2.2-TI2V-5B
78
+ # (diffsynth/configs/model_configs.py, model_hash 1f5ab7703c6fc803fdded85ff040c316)
79
+ ti2v_5b_kwargs = {
80
+ "has_image_input": False, "patch_size": [1, 2, 2], "in_dim": 48, "dim": 3072, "ffn_dim": 14336,
81
+ "freq_dim": 256, "text_dim": 4096, "out_dim": 48, "num_heads": 24, "num_layers": 30, "eps": 1e-06,
82
+ "seperated_timestep": True, "require_clip_embedding": False, "require_vae_embedding": False,
83
+ "fuse_vae_embedding_in_latents": True,
84
+ }
85
+
86
+ meta = read_metadata(ckpt_path)
87
+ sd = {}
88
+ with safe_open(ckpt_path, framework="pt", device="cpu", strided=False) as f:
89
+ for k in f.keys():
90
+ sd[k] = f.get_tensor(k)
91
+ has_mask = any(k.startswith("arope_mask_embedding.") for k in sd)
92
+
93
+ dit = WanModel(**ti2v_5b_kwargs)
94
+ if has_mask:
95
+ install_mask_channel(dit)
96
+ dit.load_state_dict(sd, strict=True) # raises if the checkpoint is not the full TI2V-5B DiT
97
+ del sd
98
+ dit = dit.to(device=device, dtype=torch_dtype).eval().requires_grad_(False)
99
+ # RoPE tables are plain tensors (not buffers): move them once (mirrors TrainTask.on_prepared)
100
+ dit.freqs = tuple(t.to(device) for t in dit.freqs)
101
+
102
+ vae_sd = WanVideoVAEStateDictConverter(load_state_dict(vae_path, torch_dtype=torch_dtype, device="cpu"))
103
+ vae = WanVideoVAE38()
104
+ vae.load_state_dict(vae_sd, strict=True)
105
+ del vae_sd
106
+ vae = vae.to(device=device, dtype=torch_dtype).eval().requires_grad_(False)
107
+ vae.mean = vae.mean.to(device)
108
+ vae.std = vae.std.to(device)
109
+
110
+ print(f"[arope_loader] DiT {sum(p.numel() for p in dit.parameters()) / 1e9:.2f} B params, "
111
+ f"mask_channel={has_mask}, metadata={dict(sorted(meta.items()))}")
112
+ return _VAEPipeline(vae, device, torch_dtype), dit, meta
113
+
114
+
115
+ class Generator:
116
+ """Upstream ``actionrope.models.wan.Generator.__call__`` on a directly-built pipe.
117
+
118
+ The upstream ``__init__`` calls ``load_pipeline(model_dir)`` which needs the base
119
+ DiT shards; everything else in it is replicated here verbatim.
120
+ """
121
+
122
+ def __init__(self, ckpt_path: str, vae_path: str, null_prompt_path: str,
123
+ device="cuda", torch_dtype=None):
124
+ import torch
125
+
126
+ from actionrope.checkpoint import meta_bool, meta_float
127
+ from actionrope.models.wan import SHIFT, PromptEmbeddings
128
+ from actionrope.models.wan_dit import MASK_EMBEDDING
129
+
130
+ torch_dtype = torch_dtype or torch.bfloat16
131
+ self.device = device
132
+ self.pipe, self.dit, self.meta = build_pipe(ckpt_path, vae_path, device, torch_dtype)
133
+ self.mask_channel = hasattr(self.dit, MASK_EMBEDDING)
134
+ self.shift = meta_float(self.meta, "cns_shift", SHIFT)
135
+ self.cns = (
136
+ {
137
+ "shift_known": meta_float(self.meta, "cns_shift_known"),
138
+ "alpha": meta_float(self.meta, "cns_alpha", 1.0),
139
+ }
140
+ if meta_bool(self.meta, "cns")
141
+ else None
142
+ )
143
+ self.prompts = PromptEmbeddings("", device, null_prompt_path)
144
+
145
+ # The full paper sampling path, verbatim from actionrope.models.wan.Generator.
146
+ from actionrope.models.wan import Generator as _Upstream # noqa: F401 (readability marker)
147
+
148
+ def __call__(self, first_frame, frame_offset_px, prompt=None, seed=0, steps=50, shift=None, cns=None):
149
+ import numpy as np
150
+
151
+ from actionrope.models import wan as wan_module
152
+ from actionrope.prompts import NULL_PROMPT
153
+
154
+ z0 = wan_module.encode_first_frame(self.pipe, first_frame)
155
+ context = self.prompts(prompt if prompt is not None else NULL_PROMPT)
156
+ offset = wan_module.cell_offsets(frame_offset_px).unsqueeze(0).to(self.device)
157
+ mask = wan_module.known_input(offset) if self.mask_channel else None
158
+ use_cns = self.cns is not None if cns is None else bool(cns)
159
+ if use_cns and self.cns is None:
160
+ raise ValueError("cns=True needs a checkpoint trained with CNS")
161
+ latents = wan_module.sample(
162
+ self.dit, z0, context, offset, mask,
163
+ steps=steps, shift=self.shift if shift is None else shift, seed=seed,
164
+ cns=self.cns if use_cns else None,
165
+ )
166
+ return wan_module.decode(self.pipe, latents), float(wan_module.FPS)
checkpoints/PLACEHOLDER.md ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Weights are downloaded at runtime by app.py from:
2
+ - teawhite/ActionRoPE : wan2.2-ti2v-5b/actionrope_cns.safetensors (10 GB, full ActionRoPE DiT)
3
+ - teawhite/ActionRoPE : wan2.2-ti2v-5b/umt5_null_prompt.pt
4
+ - Wan-AI/Wan2.2-TI2V-5B : Wan2.2_VAE.pth (2.8 GB)
examples/back_01.png ADDED

Git LFS Details

  • SHA256: 34a5430cd611a66e4884b2804820ca4b2bd380875fe0a933b451f77239f99131
  • Pointer size: 131 Bytes
  • Size of remote file: 623 kB
examples/back_264.png ADDED

Git LFS Details

  • SHA256: 0b41525ba69c8ae88d6fc95f921d1f3f3a6db840b9c7a58383fa039e5c2e3fd1
  • Pointer size: 131 Bytes
  • Size of remote file: 289 kB
examples/bamboo_L.png ADDED

Git LFS Details

  • SHA256: a6f559c85babe01263030c0ea5e26b08fc99bcd7b6da12d89428d87b841aea9a
  • Pointer size: 131 Bytes
  • Size of remote file: 448 kB
examples/watercolor_L.png ADDED

Git LFS Details

  • SHA256: 1714d62c0cc10017039f27f7404e9ce0d374d0cfaa6c85c3ff9cd8e5a65424c1
  • Pointer size: 131 Bytes
  • Size of remote file: 431 kB
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diffsynth @ git+https://github.com/modelscope/DiffSynth-Studio@7686e54d41d25c0e8ed5f1318acc23b6bb832654
2
+ torchvision
3
+ torchaudio
4
+ transformers
5
+ accelerate
6
+ safetensors
7
+ einops
8
+ ftfy
9
+ pandas
10
+ modelscope
11
+ regex
12
+ imageio[ffmpeg]
13
+ av
14
+ numpy