Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- .gitattributes +4 -0
- LICENSE_ACTIONROPE +201 -0
- README.md +55 -6
- actionrope/__init__.py +2 -0
- actionrope/checkpoint.py +44 -0
- actionrope/cns.py +24 -0
- actionrope/coords.py +41 -0
- actionrope/data.py +105 -0
- actionrope/geometry.py +98 -0
- actionrope/media.py +51 -0
- actionrope/models/__init__.py +2 -0
- actionrope/models/umt5.py +47 -0
- actionrope/models/wan.py +393 -0
- actionrope/models/wan_dit.py +181 -0
- actionrope/prompts.py +34 -0
- app.py +110 -0
- arope_loader.py +166 -0
- checkpoints/PLACEHOLDER.md +4 -0
- examples/back_01.png +3 -0
- examples/back_264.png +3 -0
- examples/bamboo_L.png +3 -0
- examples/watercolor_L.png +3 -0
- requirements.txt +14 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
examples/back_264.png
ADDED
|
Git LFS Details
|
examples/bamboo_L.png
ADDED
|
Git LFS Details
|
examples/watercolor_L.png
ADDED
|
Git LFS Details
|
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
|