FrankFundel commited on
Commit
c0efff9
·
verified ·
1 Parent(s): 6ffba4b

Deploy WhatMoves app

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes CHANGED
@@ -33,3 +33,9 @@ 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
+ app/assets/sources/penguins.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ app/assets/sources/tiger.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ app/assets/targets/bear_and_dog.png filter=lfs diff=lfs merge=lfs -text
39
+ docs/images/scene_composition.png filter=lfs diff=lfs merge=lfs -text
40
+ docs/images/title.png filter=lfs diff=lfs merge=lfs -text
41
+ docs/videos/app_demo.mp4 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .DS_Store
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CompVis - Computer Vision and Learning LMU Munich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,14 +1,275 @@
1
  ---
2
- title: WhatMoves
3
- emoji: 👁
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- license: mit
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: What Moves?
3
+ emoji: 🎬
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 5.49.1
8
+ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
+ <p align="center">
14
+ <img src="docs/images/what_moves.png" width="35%" alt="WhatMoves logo">
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://compvis.github.io/WhatMoves/"><img src="https://img.shields.io/badge/Project-Page-blue" alt="Project Page"></a>
19
+ <a href="https://arxiv.org/"><img src="https://img.shields.io/badge/arXiv-paper-b31b1b" alt="Paper"></a>
20
+ <a href="https://huggingface.co/CompVis/WhatMoves"><img src="https://img.shields.io/badge/%F0%9F%A4%97-weights-yellow" alt="Weights"></a>
21
+ </p>
22
+
23
+ <h2 align="center">
24
+ Localized Motion Representations for Compositional Scene Control
25
+ </h2>
26
+ <div align="center">
27
+ <a href="https://ffundel.de/" target="_blank">Frank Fundel</a><sup>*</sup> ·
28
+ <a href="https://www.linkedin.com/in/malek-ben-alaya/" target="_blank">Malek Ben Alaya</a><sup>*</sup> ·
29
+ <a href="https://www.linkedin.com/in/thomas-ressler-494758133/" target="_blank">Thomas Ressler-Antal</a><sup>*</sup>
30
+ <br>
31
+ <a href="https://stefan-baumann.eu/" target="_blank">Stefan A. Baumann</a> ·
32
+ <a href="https://ommer-lab.com/people/ommer/" target="_blank">Björn Ommer</a>
33
+ </div>
34
+ <p align="center">
35
+ <b>CompVis @ LMU Munich, MCML</b>
36
+ <br/>
37
+ <i>* equal contribution</i>
38
+ <br/>
39
+ ECCV 2026
40
+ </p>
41
+
42
+
43
+ <p align="center">
44
+ <img src="docs/images/title.png" width="100%" alt="WhatMoves overview">
45
+ </p>
46
+
47
+
48
+ ## 💡 TL;DR
49
+
50
+ Existing motion representations often entangle the dynamics of multiple entities, while isolating objects through cropping removes important scene context. **WhatMoves learns promptable, localized motion representations directly from full videos**, capturing the motion of user-selected regions while preserving their surrounding context. These representations enable object-level motion transfer, compositional scene control, and localized action recognition.
51
+
52
+
53
+ ## 🛠️ Installation
54
+
55
+ The tested configuration is Python 3.11/3.12, PyTorch 2.8, and CUDA 12.8. Create an
56
+ environment, install the PyTorch build appropriate for your CUDA installation,
57
+ and then choose the smallest dependency set you need.
58
+
59
+ ```bash
60
+ git clone https://github.com/CompVis/WhatMoves.git
61
+ cd WhatMoves
62
+
63
+ python3.12 -m venv .venv
64
+ source .venv/bin/activate
65
+ python -m pip install --upgrade pip setuptools wheel packaging ninja
66
+
67
+ # Example for CUDA 12.8; use the matching official PyTorch index for your host.
68
+ pip install torch==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cu128
69
+ ```
70
+
71
+ Install one of the following:
72
+
73
+ ```bash
74
+ # WhatMoves encoder only
75
+ pip install -r requirements-core.txt
76
+
77
+ # Encoder + Wan motion transfer
78
+ pip install -r requirements-wan.txt --no-build-isolation
79
+
80
+ # Complete app, including the Hugging Face Spaces transport
81
+ SAM2_BUILD_CUDA=0 pip install -r requirements.txt --no-build-isolation
82
+
83
+ # Optional Wan acceleration (requires a compatible wheel or a CUDA toolkit)
84
+ pip install flash-attn==2.8.3 --no-build-isolation
85
+ ```
86
+
87
+ The SAM2 CUDA extension is not needed for the app's image-prompt workflow. The
88
+ Wan path runs without FlashAttention through PyTorch SDPA. FlashAttention is
89
+ recommended for speed and memory use, but is kept optional because building it
90
+ from source requires `nvcc` and a compatible compiler toolchain.
91
+
92
+
93
+ ## 🧩 Scene Composition
94
+ WhatMoves can be used to **compose the motion of an entire scene from individual source motions**. Select an object or region in one or more source videos, choose the corresponding regions in a target image, and transfer each motion independently to its target. This makes it possible to animate different entities with motions taken from different videos while preserving the appearance and composition of the target scene.
95
+
96
+ <p align="center">
97
+ <img src="docs/images/scene_composition.png" width="100%" alt="Scene composition with localized motion transfer">
98
+ </p>
99
+
100
+ Our released adapter integrates WhatMoves with [Wan2.2 I2V-A14B](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B). The required Wan runtime is included in `wan/`, so no separate Wan repository is needed, and the base model weights are downloaded automatically on first use.
101
+
102
+ ```python
103
+ import torch
104
+
105
+ model = torch.hub.load(
106
+ "CompVis/WhatMoves",
107
+ "wan",
108
+ device="cuda",
109
+ trust_repo=True,
110
+ )
111
+
112
+ # target_image: [B, H, W, 3], floating point in [-1, 1]
113
+ # source_video: [B, T, Hs, Ws, 3], floating point in [-1, 1]
114
+ # source_mask: [B, K, Hs, Ws], bool
115
+ # target_mask: [B, K, H, W], bool
116
+
117
+ video = model.sample(
118
+ "A concise description of the target video",
119
+ target_image,
120
+ source_videos=[source_video],
121
+ source_masks=[source_mask],
122
+ target_masks=[target_mask],
123
+ num_frames=25,
124
+ num_inference_steps=40,
125
+ seed=42,
126
+ )
127
+
128
+ # video: [B, 25, H, W, 3] in [-1, 1]
129
+ ```
130
+
131
+ Within each source video, the `K` selected source regions are matched positionally to the `K` regions in the corresponding target mask. To **compose a scene from multiple motions**, simply provide additional source videos and masks—their motions can then be transferred independently to different parts of the target scene.
132
+
133
+ Source masks may also be provided as temporal `[B, T, K, Hs, Ws]` mask sequences. If a static source mask was drawn on a frame other than the first one, you can provide the corresponding frame through `source_content_images` so that the content embedding is extracted from the image the mask actually refers to:
134
+
135
+ ```python
136
+ # If source_mask was drawn on source frame j:
137
+ video = model.sample(
138
+ prompt,
139
+ target_image,
140
+ source_videos=[source_video],
141
+ source_masks=[source_mask],
142
+ source_content_images=[source_video[:, j]],
143
+ target_masks=[target_mask],
144
+ )
145
+ ```
146
+
147
+ Source and output videos do not need to have the same duration: WhatMoves aligns the extracted motion representations to the requested output timeline.
148
+
149
+ Target dimensions must be divisible by 16, and the number of output frames must follow `4n + 1`, with at least nine frames for motion transfer. The released adapter was trained on **25-frame videos at 8 fps**, so we recommend staying close to this duration and frame rate for best results. The interactive app automatically resamples uploaded videos to 8 fps and previews the exact sequence used for inference.
150
+
151
+ If you already have a local copy of the Wan2.2 base model, you can load it directly:
152
+
153
+ ```python
154
+ model = torch.hub.load(
155
+ "CompVis/WhatMoves",
156
+ "wan",
157
+ wan_checkpoint="/path/to/Wan2.2-I2V-A14B",
158
+ device="cuda",
159
+ trust_repo=True,
160
+ )
161
+ ```
162
+
163
+ Prefer a visual, no-code workflow? You can try the same scene composition pipeline directly in the **interactive app** below.
164
+
165
+ ### 💾 Resource requirements
166
+
167
+ Wan2.2 I2V-A14B requires substantial GPU memory and disk space. The base model occupies about **112 GiB** on disk, with the released WhatMoves and adapter checkpoints adding roughly **3 GiB**. We recommend keeping at least **125 GiB** available for the Hugging Face cache.
168
+
169
+ The current single-GPU implementation keeps both 14B Wan experts in GPU memory. A one-source, 25-frame generation at 480 × 704 resolution uses approximately **67.6 GiB** of peak allocated memory on an 80 GB A100. A 40 GB GPU is therefore not sufficient for the current implementation. CPU offloading, multi-GPU inference, and sequential expert offloading are not yet supported.
170
+
171
+
172
+ ## 🖥️ Interactive App
173
+ Want to try WhatMoves without writing any code? The interactive app provides a simple visual workflow for **selecting motion from source videos and composing it into a target scene**.
174
+
175
+
176
+ https://github.com/user-attachments/assets/09ca005a-bc85-4eba-8f6c-c159c2611994
177
+
178
+
179
+ You can upload one or more source videos, select the entities whose motion you want to reuse, choose the corresponding regions in a target image, and generate the resulting video directly from the interface. SAM2-assisted prompting makes region selection fast and interactive, while color-coded mappings help keep track of which source motion is assigned to which target object.
180
+
181
+ The app also includes a ready-to-use example and supports multiple source videos and target images, frame-accurate source masking, temporal video previews, and the available Wan guidance settings.
182
+
183
+ Launch it with:
184
+
185
+ ```bash
186
+ python -m app
187
+ ```
188
+
189
+ By default, the app is available at `127.0.0.1:7860`. For remote machines, Slurm setups, SSH tunneling, and additional configuration details, see [app/README.md](app/README.md).
190
+
191
+
192
+ ## 🎯 Extract Localized Motion
193
+
194
+ Interested in the **motion representations themselves**? The scene composition model above uses the WhatMoves encoder internally, but you can also load and use the encoder independently—without running Wan or any generative model.
195
+
196
+ Given a video and one or more spatial masks, the encoder extracts a separate localized motion representation for each selected region while retaining the context of the full scene. Torch Hub automatically downloads the released checkpoint from [Hugging Face](https://huggingface.co/CompVis/WhatMoves), verifies its SHA-256 checksum, and caches it locally.
197
+
198
+
199
+ ```python
200
+ import torch
201
+
202
+ encoder = torch.hub.load(
203
+ "CompVis/WhatMoves",
204
+ "what_moves",
205
+ device="cuda",
206
+ dtype=torch.bfloat16,
207
+ trust_repo=True,
208
+ )
209
+
210
+ # video: [B, T, H, W, 3] floating-point RGB in [-1, 1]
211
+ # masks: [B, K, H, W] boolean regions in video's first frame
212
+ motion = encoder.encode_motion(video, masks) # [B, T-3, K, 384]
213
+ content = encoder.encode_content(video[:, 0], masks) # [B, K, 512]
214
+ ```
215
+
216
+ The encoder accepts arbitrary spatial input sizes and internally resizes them
217
+ to 256 × 256. It operates on eight-frame windows and emits five motion tokens
218
+ per window, producing `T - 3` aligned output steps for a `T`-frame video. The
219
+ released checkpoint was trained on video sampled at **6 fps**. While the input
220
+ frame rate can be chosen freely, we recommend using a sampling rate close to the training frame rate.
221
+
222
+ You can also omit the masks entirely to extract **global motion representations** that capture the dynamics of the full scene:
223
+
224
+
225
+ ```python
226
+ global_motion = encoder.encode_motion(video) # [B, T-3, 384]
227
+ ```
228
+
229
+ ### 🎞️ Temporal masks for long videos
230
+
231
+ For longer videos, you can also provide a **frame-aligned sequence of masks** with shape `[B, T, K, H, W]`. This allows WhatMoves to periodically update the content embeddings used to extract motion, instead of relying on a mask from a single reference frame throughout the entire video.
232
+
233
+ This can be useful when an entity changes substantially in appearance, pose, or structure over time, such that its representation in the first frame is no longer a good match later in the sequence.
234
+
235
+ ```python
236
+ motion, content = encoder.encode_motion(
237
+ video,
238
+ temporal_masks,
239
+ return_content=True,
240
+ )
241
+
242
+ # motion: [B, T-3, K, 384]
243
+ # content: [B, T-3, K, 512], aligned with motion
244
+ ```
245
+
246
+ For each extraction window, WhatMoves updates the content embedding using the first available mask at or after the start of that window. If no valid mask is available for a region within a window, the most recent content embedding is reused. Each region therefore needs at least one valid mask in the first extraction window. `frame_chunk_size` and `window_chunk_size` can additionally be used to reduce peak memory usage. Both specify per-video chunk sizes, resulting in an effective forward batch size of `B × chunk_size`.
247
+
248
+
249
+
250
+ ## 🙏 Acknowledgements
251
+
252
+ WhatMoves builds on a fantastic ecosystem of open-source research. We thank the **DINOv2**, **Wan2.2**, **Hugging Face Diffusers**, and **SAM2** teams for making their work publicly available and enabling projects like this one.
253
+
254
+ You can also check out our previous work, [DisMo](https://github.com/CompVis/DisMo), which introduced **global, content-disentangled motion representations for open-world motion transfer**.
255
+
256
+ This repository contains reduced and modified code from [DINOv2](https://github.com/facebookresearch/dinov2) and [Wan2.2](https://github.com/Wan-Video/Wan2.2), both released under the Apache License 2.0. The released model artifacts likewise contain a mixture of original and upstream-derived components.
257
+
258
+ For the exact licensing and attribution details, please see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), [wan/LICENSE.txt](wan/LICENSE.txt), and the Hugging Face model card. The root MIT license does not replace the applicable upstream terms.
259
+
260
+
261
+ ## 📝 Citation
262
+
263
+ If you find **WhatMoves** useful in your research, please consider citing our work:
264
+
265
+ ```bibtex
266
+ @inproceedings{fundel2026whatmoves,
267
+ title = {What Moves? Localized Motion Representations for Compositional Scene Control},
268
+ author = {Fundel, Frank and Ben Alaya, Malek and Ressler-Antal, Thomas and Baumann, Stefan Andreas and Ommer, Bjorn},
269
+ booktitle = {European Conference on Computer Vision (ECCV)},
270
+ year = {2026}
271
+ }
272
+ ```
273
+
274
+ We would also love to hear about projects that build on WhatMoves or use the learned representations in new settings.
275
+
THIRD_PARTY_NOTICES.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party notices
2
+
3
+ WhatMoves contains reduced or vendored components from the projects below.
4
+ The root MIT license applies to original WhatMoves code; it does not replace
5
+ the licenses or copyright notices of these components.
6
+
7
+ ## DINOv2
8
+
9
+ `what_moves/dinov2.py` is a reduced and modified implementation of
10
+ [DINOv2](https://github.com/facebookresearch/dinov2) by Meta Platforms, Inc.,
11
+ based on commit `7764ea0f912e53c92e82eb78a2a1631e92725fc8`.
12
+
13
+ DINOv2 code and the pretrained DINOv2 parameters incorporated into the
14
+ WhatMoves checkpoint are licensed under the Apache License 2.0. A copy of that
15
+ license is included at `wan/LICENSE.txt`. The reduced implementation identifies
16
+ its modifications in the source header.
17
+
18
+ ## Wan2.2
19
+
20
+ The `wan/` package is a reduced runtime from
21
+ [Wan2.2](https://github.com/Wan-Video/Wan2.2) by the Alibaba Wan Team, based on
22
+ commit `388807310646ed5f318a99f8e8d9ad28c5b65373`. It is licensed under the
23
+ Apache License 2.0; see `wan/LICENSE.txt`. Every modified vendored source file
24
+ carries an explicit modification notice.
25
+
26
+ The released checkpoints combine original WhatMoves parameters with components
27
+ derived from Apache-2.0-licensed DINOv2 or designed to operate as deltas over
28
+ Apache-2.0-licensed Wan2.2. See the WhatMoves Hugging Face model card for the
29
+ per-file breakdown. The repository's root MIT license applies only to original
30
+ WhatMoves material; all upstream notices and conditions remain in force.
31
+
32
+ ## Hugging Face Transformers and Diffusers
33
+
34
+ The upstream Wan runtime's `wan/modules/t5.py` is derived from
35
+ [Transformers](https://github.com/huggingface/transformers), and
36
+ `wan/utils/fm_solvers_unipc.py` is derived from
37
+ [Diffusers](https://github.com/huggingface/diffusers). Both projects are
38
+ licensed under Apache License 2.0. Their provenance comments are retained in
39
+ the corresponding files; the bundled license text is at `wan/LICENSE.txt`.
40
+
41
+ ## SAM2
42
+
43
+ The optional interactive app installs
44
+ [SAM2](https://github.com/facebookresearch/sam2) as an external dependency at a
45
+ pinned revision. SAM2 is not copied into this repository and is licensed under
46
+ Apache License 2.0.
app.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Spaces entrypoint for the WhatMoves demo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import uvicorn
8
+
9
+ from app.gradio_app import create_gradio_application
10
+
11
+
12
+ application = create_gradio_application()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ uvicorn.run(
17
+ application,
18
+ host=os.environ.get("WHATMOVES_APP_HOST", "0.0.0.0"),
19
+ port=int(os.environ.get("PORT", os.environ.get("WHATMOVES_APP_PORT", "7860"))),
20
+ )
app/README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WhatMoves interactive app
2
+
3
+ The app is a build-free browser interface for localized motion transfer.
4
+ FastAPI hosts the unchanged custom HTML, CSS, JavaScript, and streaming media;
5
+ named Gradio endpoints carry browser operations and integrate GPU calls with
6
+ Hugging Face ZeroGPU. One in-process scheduler still serializes SAM2 and Wan
7
+ work.
8
+
9
+ Install the complete application from the repository root using Python 3.12. Model weights are downloaded on first use and are not stored in this repository:
10
+
11
+ ```bash
12
+ SAM2_BUILD_CUDA=0 pip install -r requirements.txt --no-build-isolation
13
+ ```
14
+
15
+ ## Run locally
16
+
17
+ From the repository root:
18
+
19
+ ```bash
20
+ source .venv/bin/activate
21
+ python -m app
22
+ ```
23
+
24
+ Open <http://localhost:7860>. The default bind address is loopback-only. Use
25
+ `--port PORT` to choose another port.
26
+
27
+ ## Run on Slurm and connect over SSH
28
+
29
+ Allocate one sufficiently large GPU. Substitute your cluster's partition and
30
+ GPU resource syntax:
31
+
32
+ ```bash
33
+ srun -p GPU_PARTITION --gres=gpu:GPU_TYPE:1 --time=04:00:00 \
34
+ --job-name=whatmoves-app --pty bash
35
+ ```
36
+
37
+ On the allocated GPU node, run:
38
+
39
+ ```bash
40
+ source /path/to/WhatMoves/.venv/bin/activate
41
+ cd /path/to/WhatMoves
42
+
43
+ export PYTHONNOUSERSITE=1
44
+ export PYTHONPATH="$PWD"
45
+
46
+ # Optional: reuse an existing official Wan2.2 base-model directory.
47
+ export WHATMOVES_WAN_CHECKPOINT=/path/to/Wan2.2-I2V-A14B
48
+
49
+ export WHATMOVES_APP_PORT=$(python -c \
50
+ 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()')
51
+
52
+ echo "GPU node: $(hostname)"
53
+ echo "Remote port: $WHATMOVES_APP_PORT"
54
+ python -m app --host 0.0.0.0 --port "$WHATMOVES_APP_PORT"
55
+ ```
56
+
57
+ Leave that process running. On your laptop, substitute the printed values and
58
+ your normal SSH login host or alias:
59
+
60
+ ```bash
61
+ ssh -N -L 7860:GPU_NODE:REMOTE_PORT LOGIN_HOST
62
+ ```
63
+
64
+ Then open <http://localhost:7860>. If local port 7860 is occupied, change only
65
+ the first port, for example `-L 7861:GPU_NODE:REMOTE_PORT`, and open
66
+ <http://localhost:7861>.
67
+
68
+ Each user should request a separate GPU, start a separate app process, and use
69
+ their own remote and laptop ports. Do not add Uvicorn workers: model and session
70
+ state are intentionally process-local, and the Wan runtime is not reentrant.
71
+
72
+ ## Workflow
73
+
74
+ The app opens with a small, fully masked and mapped synthetic example. You can
75
+ generate from it immediately or remove it and upload your own media.
76
+
77
+ 1. Upload one or more source videos on the left and target images on the right.
78
+ Each rail keeps its uploads independent; generation uses only the selected
79
+ target image.
80
+ 2. Source videos are timestamp-resampled to the adapter's 8 fps training
81
+ cadence. Play them or scrub the filmstrip to inspect the exact frames used
82
+ by inference. The two handles select the source interval.
83
+ 3. Select any source frame, then choose **Start masking**. New uploads enter
84
+ masking mode automatically. Left-click adds a positive SAM2 point,
85
+ right-click adds a negative point, and left-drag adds a box. Hovering shows
86
+ a transient proposal without changing the committed prompt. Choose
87
+ **Add mask** to store the committed proposal at that exact frame.
88
+ 4. Colored source-mask previews remain visible at their timeline positions;
89
+ selecting one jumps back to its frame. Drag a colored source mask onto a
90
+ grey target mask, or click the target mask and choose a source from its menu.
91
+ 5. Choose a prompt and generate. Honest stage progress is shown, including the
92
+ exact sampling step.
93
+
94
+ On ordinary local and Slurm deployments, SAM2 image features are queued as soon
95
+ as media is uploaded. On ZeroGPU they are prepared lazily inside the first
96
+ decorated mask request, because CUDA exists only for the lifetime of that
97
+ request. Source features are cached per selected frame in a bounded LRU cache.
98
+ Wan loads lazily on the first generation. A
99
+ generation uses an immutable snapshot of the selected target, source intervals,
100
+ masks, and settings present when **Generate video** was pressed; you may
101
+ continue editing while it runs. The completed video is still shown and marked
102
+ **previous inputs** if those inputs changed meanwhile.
103
+
104
+ ## Lifecycle and privacy
105
+
106
+ - Runtime data is stored in a mode-`0700` directory under
107
+ `/tmp/whatmoves-app-<job>-<pid>` by default; media and output files use mode
108
+ `0600`.
109
+ - Target upload files are deleted immediately after decoding. Uploaded source
110
+ videos are replaced by their canonical 8 fps session copy. Removing a source
111
+ or target drops its RGB arrays, masks, drafts, pending work, mappings, cached
112
+ SAM2 features, and session files. Bundled app assets are copied into a
113
+ session and are never modified.
114
+ - Uncommitted mask drafts are cancelled when their source is left and after a
115
+ browser reload, because their point and box prompts exist only in that page.
116
+ - Only the latest successful generated video is retained. Generation input
117
+ links are removed as soon as their job finishes.
118
+ - Browser sessions send a lightweight heartbeat and expire after two idle
119
+ hours by default. Expiration or graceful server shutdown releases all
120
+ associated memory and files.
121
+ - The server has no authentication. Keep it on loopback or a trusted compute
122
+ network and access it through SSH; do not expose it directly to the internet.
123
+
124
+ The most useful environment overrides are:
125
+
126
+ | Variable | Default | Purpose |
127
+ |---|---:|---|
128
+ | `WHATMOVES_WAN_CHECKPOINT` | download through Hugging Face | Local Wan2.2 base directory |
129
+ | `WHATMOVES_APP_TMP_DIR` | `/tmp/whatmoves-app-<job>-<pid>` | Runtime-data root |
130
+ | `WHATMOVES_APP_SESSION_TTL` | `7200` | Idle-session lifetime in seconds |
131
+ | `WHATMOVES_APP_MAX_SOURCES` | `8` | Maximum source videos per session |
132
+ | `WHATMOVES_APP_MAX_TARGETS` | `8` | Maximum target images per session |
133
+ | `WHATMOVES_APP_MAX_SAM_FRAMES` | `8` | Maximum cached per-frame SAM2 predictors |
134
+ | `WHATMOVES_APP_MAX_GPU_TASKS` | `32` | Maximum queued/running GPU tasks |
135
+ | `WHATMOVES_APP_HOST` | `127.0.0.1` | Server bind address |
136
+ | `WHATMOVES_APP_PORT` | `7860` | Server port |
137
+
138
+ Use `--reload` only for frontend development. It is not intended for model
139
+ inference or production sessions.
app/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """WhatMoves interactive web application."""
2
+
3
+ from .server import create_app
4
+
5
+ __all__ = ["create_app"]
app/__main__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the application with ``python -m app``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+
8
+ import uvicorn
9
+
10
+ from .gradio_app import create_gradio_application
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser(description="WhatMoves interactive demo")
15
+ parser.add_argument(
16
+ "--host", default=os.environ.get("WHATMOVES_APP_HOST", "127.0.0.1")
17
+ )
18
+ parser.add_argument(
19
+ "--port",
20
+ type=int,
21
+ default=int(os.environ.get("WHATMOVES_APP_PORT", "7860")),
22
+ )
23
+ parser.add_argument(
24
+ "--reload", action="store_true", help="Reload after source changes"
25
+ )
26
+ args = parser.parse_args()
27
+ uvicorn.run(
28
+ create_gradio_application(),
29
+ host=args.host,
30
+ port=args.port,
31
+ reload=args.reload,
32
+ )
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
app/assets/examples.json ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "sources": [
4
+ {
5
+ "id": "tiger",
6
+ "name": "Tiger",
7
+ "video": "sources/tiger.mp4",
8
+ "trim": [0, 24],
9
+ "masks": [
10
+ {
11
+ "id": "tiger",
12
+ "frame": 0,
13
+ "mask": "masks/tiger_f00.png",
14
+ "color": "#E69F00"
15
+ }
16
+ ]
17
+ },
18
+ {
19
+ "id": "penguins",
20
+ "name": "Penguins",
21
+ "video": "sources/penguins.mp4",
22
+ "trim": [0, 24],
23
+ "masks": [
24
+ {
25
+ "id": "center-penguin",
26
+ "frame": 0,
27
+ "mask": "masks/center_penguin_f00.png",
28
+ "color": "#56B4E9"
29
+ }
30
+ ]
31
+ }
32
+ ],
33
+ "targets": [
34
+ {
35
+ "id": "bear-dog",
36
+ "name": "Bear and dog",
37
+ "image": "targets/bear_and_dog.png",
38
+ "masks": [
39
+ {
40
+ "id": "bear",
41
+ "mask": "masks/target_bear.png"
42
+ },
43
+ {
44
+ "id": "dog",
45
+ "mask": "masks/target_dog.png"
46
+ }
47
+ ]
48
+ }
49
+ ],
50
+ "mappings": [
51
+ {
52
+ "source": "tiger",
53
+ "source_mask": "tiger",
54
+ "target": "bear-dog",
55
+ "target_mask": "bear"
56
+ },
57
+ {
58
+ "source": "penguins",
59
+ "source_mask": "center-penguin",
60
+ "target": "bear-dog",
61
+ "target_mask": "dog"
62
+ }
63
+ ]
64
+ }
app/assets/masks/center_penguin_f00.png ADDED
app/assets/masks/target_bear.png ADDED
app/assets/masks/target_dog.png ADDED
app/assets/masks/tiger_f00.png ADDED
app/assets/sources/penguins.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ac0358344a10f49ced74cfe336e9f7c63c82acb423ff95bb802291da3e6c1482
3
+ size 452943
app/assets/sources/tiger.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f99394c930814dcc574409c42088852653bec39356d9a816ad94f7d23658e6f3
3
+ size 856651
app/assets/targets/bear_and_dog.png ADDED

Git LFS Details

  • SHA256: 87561df5706b52d75ae36697446d1369518a496054891de52aa7a733759dd9ed
  • Pointer size: 132 Bytes
  • Size of remote file: 1.83 MB
app/config.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ from pathlib import Path
8
+
9
+ DEFAULT_VARIANT = "gated_static_step600000"
10
+
11
+
12
+ def _optional_path(name: str) -> Path | None:
13
+ value = os.environ.get(name)
14
+ return Path(value).expanduser() if value else None
15
+
16
+
17
+ def _runtime_root() -> Path:
18
+ configured = os.environ.get("WHATMOVES_APP_TMP_DIR")
19
+ if configured:
20
+ return Path(configured).expanduser()
21
+ job = os.environ.get("SLURM_JOB_ID", "local")
22
+ suffix = f"{job}-{os.getpid()}"
23
+ return Path("/tmp") / f"whatmoves-app-{suffix}"
24
+
25
+
26
+ def _positive_int(name: str, default: int) -> int:
27
+ value = int(os.environ.get(name, default))
28
+ if value < 1:
29
+ raise ValueError(f"{name} must be positive")
30
+ return value
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AppConfig:
35
+ model_variant: str = DEFAULT_VARIANT
36
+ wan_checkpoint: Path | None = _optional_path("WHATMOVES_WAN_CHECKPOINT")
37
+ sam_model_id: str = "facebook/sam2-hiera-large"
38
+ device: str = "cuda"
39
+ runtime_root: Path = _runtime_root()
40
+ output_height: int = 480
41
+ output_width: int = 704
42
+ output_frames: int = 25
43
+ output_fps: int = 8
44
+ source_fps: int = 8
45
+ max_image_side: int = 1600
46
+ max_upload_bytes: int = 2 * 1024**3
47
+ max_sources: int = _positive_int("WHATMOVES_APP_MAX_SOURCES", 8)
48
+ max_targets: int = _positive_int("WHATMOVES_APP_MAX_TARGETS", 8)
49
+ max_sam_frames: int = _positive_int("WHATMOVES_APP_MAX_SAM_FRAMES", 8)
50
+ max_gpu_tasks: int = _positive_int("WHATMOVES_APP_MAX_GPU_TASKS", 32)
51
+ session_ttl_seconds: int = _positive_int("WHATMOVES_APP_SESSION_TTL", 7200)
52
+ cleanup_interval_seconds: int = _positive_int("WHATMOVES_APP_CLEANUP_INTERVAL", 60)
app/examples.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load the small, redistributable examples bundled with the release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import shutil
8
+
9
+ import numpy as np
10
+ from PIL import Image
11
+
12
+ from .media import decode_video_frame, probe_video, read_image
13
+ from .state import MaskRecord, Session, SourceAsset, TargetAsset, new_id
14
+
15
+
16
+ def _asset_path(root: Path, relative: str) -> Path:
17
+ path = (root / relative).resolve()
18
+ if root.resolve() not in path.parents or not path.is_file():
19
+ raise ValueError(f"Invalid example asset: {relative}")
20
+ return path
21
+
22
+
23
+ def _mask(path: Path, shape: tuple[int, int]) -> np.ndarray:
24
+ with Image.open(path) as opened:
25
+ image = opened.convert("L")
26
+ if image.size != (shape[1], shape[0]):
27
+ image = image.resize((shape[1], shape[0]), Image.Resampling.NEAREST)
28
+ mask = np.asarray(image) > 127
29
+ if not mask.any():
30
+ raise ValueError(f"Example mask is empty: {path.name}")
31
+ return mask
32
+
33
+
34
+ def load_examples(
35
+ session: Session,
36
+ root: Path,
37
+ *,
38
+ source_fps: int,
39
+ max_sources: int,
40
+ max_targets: int,
41
+ max_image_side: int,
42
+ ) -> None:
43
+ """Copy manifest-defined examples into one disposable browser session."""
44
+ manifest_path = root / "examples.json"
45
+ if not manifest_path.is_file():
46
+ return
47
+ manifest = json.loads(manifest_path.read_text())
48
+ if manifest.get("version") != 1:
49
+ raise ValueError("Unsupported examples manifest version")
50
+
51
+ source_refs: dict[str, tuple[SourceAsset, dict[str, str]]] = {}
52
+ for record in manifest.get("sources", [])[:max_sources]:
53
+ source_id = new_id("source")
54
+ source_path = _asset_path(root, record["video"])
55
+ runtime_path = session.directory / f"{source_id}.mp4"
56
+ shutil.copyfile(source_path, runtime_path)
57
+ runtime_path.chmod(0o600)
58
+ fps, frame_count = probe_video(runtime_path)
59
+ if frame_count is None or frame_count < 1:
60
+ raise ValueError(f"Example video has no known frames: {source_path.name}")
61
+ if abs(fps - source_fps) > 0.05:
62
+ raise ValueError(f"Example video must be encoded at {source_fps} fps")
63
+ trim = record.get("trim", [0, frame_count - 1])
64
+ trim_start = int(trim[0])
65
+ trim_end = int(trim[1])
66
+ if not 0 <= trim_start <= trim_end < frame_count:
67
+ raise ValueError(f"Invalid example trim for {source_path.name}")
68
+ frame = decode_video_frame(runtime_path, trim_start, max_image_side)
69
+ source = SourceAsset(
70
+ id=source_id,
71
+ name=str(record.get("name", source_path.stem)),
72
+ video_path=runtime_path,
73
+ frame=frame,
74
+ fps=float(source_fps),
75
+ frame_count=frame_count,
76
+ original_fps=float(source_fps),
77
+ current_frame=trim_start,
78
+ trim_start=trim_start,
79
+ trim_end=trim_end,
80
+ sam_status="idle",
81
+ example=True,
82
+ )
83
+ mask_refs = {}
84
+ for mask_record in record.get("masks", []):
85
+ frame_index = int(mask_record["frame"])
86
+ if not 0 <= frame_index < frame_count:
87
+ raise ValueError("Example source mask frame is out of range")
88
+ mask_id = new_id("mask")
89
+ allocated = session.allocate_color()
90
+ color = str(mask_record.get("color") or allocated)
91
+ source.masks[mask_id] = MaskRecord(
92
+ mask_id,
93
+ _mask(_asset_path(root, mask_record["mask"]), frame.shape[:2]),
94
+ color=color,
95
+ frame_index=frame_index,
96
+ )
97
+ mask_refs[str(mask_record["id"])] = mask_id
98
+ source.mask_revision += 1
99
+ session.sources[source.id] = source
100
+ source_refs[str(record["id"])] = (source, mask_refs)
101
+
102
+ target_refs: dict[str, tuple[TargetAsset, dict[str, str]]] = {}
103
+ for record in manifest.get("targets", [])[:max_targets]:
104
+ target_id = new_id("target")
105
+ image_path = _asset_path(root, record["image"])
106
+ image = read_image(image_path, max_image_side)
107
+ session.target_revision += 1
108
+ target = TargetAsset(
109
+ id=target_id,
110
+ name=str(record.get("name", image_path.stem)),
111
+ image=image,
112
+ revision=session.target_revision,
113
+ sam_status="idle",
114
+ example=True,
115
+ )
116
+ mask_refs = {}
117
+ for mask_record in record.get("masks", []):
118
+ mask_id = new_id("mask")
119
+ target.masks[mask_id] = MaskRecord(
120
+ mask_id,
121
+ _mask(_asset_path(root, mask_record["mask"]), image.shape[:2]),
122
+ )
123
+ mask_refs[str(mask_record["id"])] = mask_id
124
+ target.mask_revision += 1
125
+ session.targets[target.id] = target
126
+ target_refs[str(record["id"])] = (target, mask_refs)
127
+
128
+ for mapping in manifest.get("mappings", []):
129
+ source, source_masks = source_refs[str(mapping["source"])]
130
+ target, target_masks = target_refs[str(mapping["target"])]
131
+ target_mask = target.masks[target_masks[str(mapping["target_mask"])]]
132
+ target_mask.source_id = source.id
133
+ target_mask.source_mask_id = source_masks[str(mapping["source_mask"])]
134
+ target.mask_revision += 1
135
+ session.selected_target_id = next(iter(session.targets), None)
app/gradio_app.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio transport for the unchanged WhatMoves browser experience.
2
+
3
+ The public page and read-only media routes remain on the FastAPI application.
4
+ All browser mutations and GPU jobs travel through named Gradio endpoints, so
5
+ Hugging Face ZeroGPU can allocate hardware around the actual CUDA lifetime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import time
15
+ from typing import Any
16
+
17
+ # Gradio reads its temp root during import. Use a process-private directory so
18
+ # multi-user machines never collide on a shared /tmp/gradio directory.
19
+ if "GRADIO_TEMP_DIR" not in os.environ:
20
+ _runtime_temp = os.environ.get("WHATMOVES_APP_TMP_DIR")
21
+ _gradio_temp = (
22
+ Path(_runtime_temp) / "gradio"
23
+ if _runtime_temp
24
+ else Path("/tmp") / f"whatmoves-gradio-{os.getpid()}"
25
+ )
26
+ _gradio_temp.mkdir(parents=True, exist_ok=True, mode=0o700)
27
+ os.environ["GRADIO_TEMP_DIR"] = str(_gradio_temp)
28
+ os.environ.setdefault("MPLBACKEND", "Agg")
29
+
30
+ import gradio as gr
31
+ import httpx
32
+ import spaces
33
+
34
+ from .server import Runtime, create_app
35
+
36
+
37
+ def _upload_path(value: Any) -> Path | None:
38
+ if value is None:
39
+ return None
40
+ if isinstance(value, (str, os.PathLike)):
41
+ return Path(value)
42
+ if isinstance(value, dict):
43
+ candidate = value.get("path") or value.get("name")
44
+ return Path(candidate) if candidate else None
45
+ candidate = getattr(value, "name", None)
46
+ return Path(candidate) if candidate else None
47
+
48
+
49
+ class GradioBridge:
50
+ def __init__(self, service, runtime: Runtime) -> None:
51
+ self.service = service
52
+ self.runtime = runtime
53
+
54
+ async def _request(
55
+ self,
56
+ method: str,
57
+ path: str,
58
+ body: str | None,
59
+ upload: Any,
60
+ ) -> dict[str, Any]:
61
+ method = str(method or "GET").upper()
62
+ path = str(path or "")
63
+ if not path.startswith("/api/") or "://" in path:
64
+ return {"ok": False, "status": 400, "data": "Invalid API path"}
65
+
66
+ kwargs: dict[str, Any] = {}
67
+ upload_path = _upload_path(upload)
68
+ handle = None
69
+ try:
70
+ if upload_path is not None:
71
+ handle = upload_path.open("rb")
72
+ kwargs["files"] = {
73
+ "file": (upload_path.name, handle, "application/octet-stream")
74
+ }
75
+ elif body:
76
+ kwargs["content"] = body.encode("utf-8")
77
+ kwargs["headers"] = {"content-type": "application/json"}
78
+
79
+ transport = httpx.ASGITransport(app=self.service)
80
+ async with httpx.AsyncClient(
81
+ transport=transport,
82
+ base_url="http://whatmoves.internal",
83
+ ) as client:
84
+ response = await client.request(method, path, **kwargs)
85
+ except Exception as error:
86
+ return {"ok": False, "status": 500, "data": str(error)}
87
+ finally:
88
+ if handle is not None:
89
+ handle.close()
90
+
91
+ if not response.content:
92
+ data: Any = None
93
+ else:
94
+ try:
95
+ data = response.json()
96
+ except ValueError:
97
+ data = response.text
98
+ return {"ok": response.is_success, "status": response.status_code, "data": data}
99
+
100
+ def request(
101
+ self,
102
+ method: str,
103
+ path: str,
104
+ body: str | None = None,
105
+ upload: Any = None,
106
+ ) -> dict[str, Any]:
107
+ return asyncio.run(self._request(method, path, body, upload))
108
+
109
+ def request_json(
110
+ self,
111
+ method: str,
112
+ path: str,
113
+ body: str | None = None,
114
+ upload: Any = None,
115
+ ) -> str:
116
+ return json.dumps(self.request(method, path, body, upload), separators=(",", ":"))
117
+
118
+ def generate(
119
+ self,
120
+ method: str,
121
+ path: str,
122
+ body: str | None = None,
123
+ upload: Any = None,
124
+ progress=gr.Progress(),
125
+ ) -> dict[str, Any]:
126
+ result = self.request(method, path, body, upload)
127
+ if not result["ok"]:
128
+ return result
129
+ task_id = (result.get("data") or {}).get("task_id")
130
+ if not task_id:
131
+ return result
132
+
133
+ while True:
134
+ record = self.runtime.scheduler.get(task_id)
135
+ if record is None:
136
+ return {"ok": False, "status": 404, "data": "Generation task disappeared"}
137
+ if record.progress_total and record.progress_current is not None:
138
+ progress(
139
+ (record.progress_current, record.progress_total),
140
+ desc=record.stage,
141
+ )
142
+ else:
143
+ progress(0, desc=record.stage)
144
+ if record.status not in {"queued", "running"}:
145
+ return result
146
+ time.sleep(0.2)
147
+
148
+ def generate_json(
149
+ self,
150
+ method: str,
151
+ path: str,
152
+ body: str | None = None,
153
+ upload: Any = None,
154
+ progress=gr.Progress(),
155
+ ) -> str:
156
+ return json.dumps(
157
+ self.generate(method, path, body, upload, progress),
158
+ separators=(",", ":"),
159
+ )
160
+
161
+
162
+ def create_gradio_application():
163
+ runtime = Runtime(eager_embeddings=False)
164
+ service = create_app(runtime)
165
+ bridge = GradioBridge(service, runtime)
166
+
167
+ @spaces.GPU(size="large", duration=120)
168
+ def sam_request(method, path, body=None, upload=None):
169
+ return bridge.request_json(method, path, body, upload)
170
+
171
+ @spaces.GPU(size="xlarge", duration=300)
172
+ def generation_request(method, path, body=None, upload=None, progress=gr.Progress()):
173
+ return bridge.generate_json(method, path, body, upload, progress)
174
+
175
+ with gr.Blocks(title="What Moves?") as transport:
176
+ method = gr.Textbox(visible=False)
177
+ path = gr.Textbox(visible=False)
178
+ body = gr.Textbox(visible=False)
179
+ upload = gr.File(type="filepath", visible=False)
180
+ output = gr.Textbox(visible=False)
181
+ cpu_trigger = gr.Button(visible=False)
182
+ sam_trigger = gr.Button(visible=False)
183
+ generation_trigger = gr.Button(visible=False)
184
+
185
+ inputs = [method, path, body, upload]
186
+ cpu_trigger.click(
187
+ bridge.request_json,
188
+ inputs=inputs,
189
+ outputs=output,
190
+ api_name="api",
191
+ queue=False,
192
+ )
193
+ sam_trigger.click(
194
+ sam_request,
195
+ inputs=inputs,
196
+ outputs=output,
197
+ api_name="sam_api",
198
+ concurrency_limit=1,
199
+ )
200
+ generation_trigger.click(
201
+ generation_request,
202
+ inputs=inputs,
203
+ outputs=output,
204
+ api_name="generation_api",
205
+ concurrency_limit=1,
206
+ )
207
+
208
+ transport.queue()
209
+ return gr.mount_gradio_app(service, transport, path="/gradio")
app/inference.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wan input preparation and Torch Hub model loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import threading
8
+ from typing import Any, Callable
9
+
10
+ import numpy as np
11
+
12
+ from .config import AppConfig
13
+ from .media import decode_video_range, write_video
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SourceSnapshot:
18
+ video_path: Path
19
+ trim_start: int
20
+ trim_end: int
21
+ source_masks: tuple[np.ndarray, ...]
22
+ target_masks: tuple[np.ndarray, ...]
23
+ reference_frames: tuple[int, ...]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class GenerationSnapshot:
28
+ target_image: np.ndarray
29
+ sources: tuple[SourceSnapshot, ...]
30
+ prompt: str
31
+ negative_prompt: str | None
32
+ steps: int
33
+ seed: int
34
+ guidance_mode: str
35
+ text_guidance_scale: float
36
+ motion_guidance_scale: float
37
+ lora_scale: float
38
+ output_path: Path
39
+
40
+
41
+ class WanService:
42
+ def __init__(self, config: AppConfig) -> None:
43
+ self.config = config
44
+ self._model = None
45
+ self._status = "idle"
46
+ self._lock = threading.RLock()
47
+
48
+ @property
49
+ def status(self) -> str:
50
+ with self._lock:
51
+ return self._status
52
+
53
+ def _set_status(self, status: str) -> None:
54
+ with self._lock:
55
+ self._status = status
56
+
57
+ def _load_model(self):
58
+ """Load the release through the same Torch Hub API exposed to users."""
59
+ if self._model is not None:
60
+ return self._model
61
+ self._set_status("loading")
62
+ try:
63
+ import torch
64
+
65
+ kwargs = {
66
+ "variant": self.config.model_variant,
67
+ "device": self.config.device,
68
+ }
69
+ if self.config.wan_checkpoint is not None:
70
+ kwargs["wan_checkpoint"] = str(self.config.wan_checkpoint)
71
+ self._model = torch.hub.load(
72
+ str(Path(__file__).resolve().parents[1]),
73
+ "wan",
74
+ source="local",
75
+ **kwargs,
76
+ )
77
+ except Exception:
78
+ self._set_status("error")
79
+ raise
80
+ self._set_status("ready")
81
+ return self._model
82
+
83
+ def close(self) -> None:
84
+ with self._lock:
85
+ self._model = None
86
+ self._status = "idle"
87
+ if self.config.device.startswith("cuda"):
88
+ import torch
89
+
90
+ torch.cuda.empty_cache()
91
+
92
+ def generate(
93
+ self,
94
+ request: GenerationSnapshot,
95
+ progress_callback: Callable[[str, int | None, int | None], None] | None = None,
96
+ ) -> dict[str, Any]:
97
+ import torch
98
+ import torch.nn.functional as F
99
+
100
+ def report(stage, current=None, total=None):
101
+ if progress_callback is not None:
102
+ progress_callback(stage, current, total)
103
+
104
+ height = self.config.output_height
105
+ width = self.config.output_width
106
+ frames = self.config.output_frames
107
+
108
+ report("Preparation")
109
+ target = torch.from_numpy(request.target_image.copy())
110
+ target = (
111
+ F.interpolate(
112
+ target.permute(2, 0, 1)[None].float(),
113
+ size=(height, width),
114
+ mode="bilinear",
115
+ antialias=True,
116
+ )
117
+ .permute(0, 2, 3, 1)
118
+ .div(127.5)
119
+ .sub(1)
120
+ )
121
+
122
+ source_videos = []
123
+ source_masks = []
124
+ target_masks = []
125
+ source_content_images = []
126
+ for source in request.sources:
127
+ decoded = decode_video_range(
128
+ source.video_path, source.trim_start, source.trim_end
129
+ )
130
+ video = torch.from_numpy(decoded.copy()).permute(0, 3, 1, 2).float()
131
+ video = (
132
+ F.interpolate(
133
+ video,
134
+ size=(height, width),
135
+ mode="bilinear",
136
+ antialias=True,
137
+ )
138
+ .permute(0, 2, 3, 1)
139
+ .div(127.5)
140
+ .sub(1)[None]
141
+ )
142
+ grouped: dict[int, list[int]] = {}
143
+ for index, reference in enumerate(source.reference_frames):
144
+ grouped.setdefault(reference, []).append(index)
145
+ for reference, indices in grouped.items():
146
+ local_reference = reference - source.trim_start
147
+ if not 0 <= local_reference < decoded.shape[0]:
148
+ raise ValueError("A source mask lies outside its selected interval")
149
+ source_mask = torch.from_numpy(
150
+ np.stack([source.source_masks[index] for index in indices])
151
+ ).float()[None]
152
+ source_mask = F.interpolate(
153
+ source_mask, size=(height, width), mode="nearest"
154
+ ).bool()
155
+ target_mask = torch.from_numpy(
156
+ np.stack([source.target_masks[index] for index in indices])
157
+ ).float()[None]
158
+ target_mask = F.interpolate(
159
+ target_mask, size=(height, width), mode="nearest"
160
+ ).bool()
161
+ reference_image = video[:, local_reference]
162
+ source_videos.append(video)
163
+ source_masks.append(source_mask)
164
+ target_masks.append(target_mask)
165
+ source_content_images.append(reference_image)
166
+
167
+ # Decode and validate every user input before paying the cost of loading
168
+ # the two Wan experts. This also makes bad frame ranges fail quickly.
169
+ if self._model is None:
170
+ report("Model loading")
171
+ model = self._load_model()
172
+ kwargs = {
173
+ "prompt": request.prompt,
174
+ "target_image": target,
175
+ "negative_prompt": request.negative_prompt,
176
+ "num_frames": frames,
177
+ "num_inference_steps": request.steps,
178
+ "text_guidance_scale": request.text_guidance_scale,
179
+ "motion_guidance_scale": request.motion_guidance_scale,
180
+ "guidance_mode": request.guidance_mode,
181
+ "lora_scale": request.lora_scale,
182
+ "seed": request.seed,
183
+ }
184
+ if request.guidance_mode != "base_cfg":
185
+ kwargs.update(
186
+ source_videos=source_videos,
187
+ source_masks=source_masks,
188
+ target_masks=target_masks,
189
+ source_content_images=source_content_images,
190
+ )
191
+ self._set_status("generating")
192
+ try:
193
+ with torch.inference_mode():
194
+ generated = model.sample(
195
+ **kwargs,
196
+ progress_callback=progress_callback,
197
+ )
198
+ video = (
199
+ generated[0]
200
+ .detach()
201
+ .float()
202
+ .cpu()
203
+ .clamp(-1, 1)
204
+ .add(1)
205
+ .mul(127.5)
206
+ .round()
207
+ .byte()
208
+ .numpy()
209
+ )
210
+ report("Finishing")
211
+ write_video(request.output_path, video, fps=self.config.output_fps)
212
+ return {
213
+ "output_name": request.output_path.name,
214
+ "frames": int(video.shape[0]),
215
+ "width": int(video.shape[2]),
216
+ "height": int(video.shape[1]),
217
+ "fps": self.config.output_fps,
218
+ }
219
+ finally:
220
+ self._set_status("ready")
app/media.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small image, mask, and video I/O helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fractions import Fraction
6
+ from io import BytesIO
7
+ from pathlib import Path
8
+
9
+ import av
10
+ import numpy as np
11
+ from PIL import Image, ImageOps
12
+
13
+
14
+ def read_image(path: Path, max_side: int = 1600) -> np.ndarray:
15
+ with Image.open(path) as opened:
16
+ image = ImageOps.exif_transpose(opened).convert("RGB")
17
+ if max(image.size) > max_side:
18
+ image.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
19
+ return np.asarray(image).copy()
20
+
21
+
22
+ def png_bytes(image: np.ndarray) -> bytes:
23
+ buffer = BytesIO()
24
+ mode = "L" if image.ndim == 2 else "RGB"
25
+ Image.fromarray(image, mode=mode).save(buffer, format="PNG", optimize=True)
26
+ return buffer.getvalue()
27
+
28
+
29
+ def jpeg_bytes(image: np.ndarray, quality: int = 82) -> bytes:
30
+ buffer = BytesIO()
31
+ Image.fromarray(np.asarray(image, dtype=np.uint8), mode="RGB").save(
32
+ buffer,
33
+ format="JPEG",
34
+ quality=int(quality),
35
+ optimize=True,
36
+ )
37
+ return buffer.getvalue()
38
+
39
+
40
+ def mask_png_bytes(mask: np.ndarray) -> bytes:
41
+ return png_bytes(np.asarray(mask, dtype=np.uint8) * 255)
42
+
43
+
44
+ def _rate(stream) -> float:
45
+ value = stream.average_rate or stream.guessed_rate
46
+ try:
47
+ rate = float(value)
48
+ except (TypeError, ValueError, ZeroDivisionError):
49
+ rate = 0.0
50
+ return rate if np.isfinite(rate) and rate > 0 else 8.0
51
+
52
+
53
+ def probe_video(path: Path) -> tuple[float, int | None]:
54
+ with av.open(str(path)) as container:
55
+ stream = container.streams.video[0]
56
+ fps = _rate(stream)
57
+ count = int(stream.frames) if stream.frames else None
58
+ if (
59
+ count is None
60
+ and stream.duration is not None
61
+ and stream.time_base is not None
62
+ ):
63
+ count = max(1, int(round(float(stream.duration * stream.time_base) * fps)))
64
+ return fps, count
65
+
66
+
67
+ def decode_video_frame(path: Path, index: int, max_side: int = 1600) -> np.ndarray:
68
+ index = max(0, int(index))
69
+ with av.open(str(path)) as container:
70
+ stream = container.streams.video[0]
71
+ fps = _rate(stream)
72
+ if index and stream.time_base is not None:
73
+ timestamp = int((index / fps) / float(stream.time_base))
74
+ container.seek(timestamp, stream=stream, any_frame=False, backward=True)
75
+ closest = None
76
+ closest_distance = float("inf")
77
+ for decoded_index, frame in enumerate(container.decode(stream)):
78
+ if frame.pts is not None and stream.time_base is not None:
79
+ frame_index = round(float(frame.pts * stream.time_base) * fps)
80
+ else:
81
+ frame_index = decoded_index
82
+ distance = abs(frame_index - index)
83
+ if distance < closest_distance:
84
+ closest = frame
85
+ closest_distance = distance
86
+ if frame_index < index:
87
+ continue
88
+ frame = closest
89
+ image = Image.fromarray(frame.to_ndarray(format="rgb24"))
90
+ if max(image.size) > max_side:
91
+ image.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
92
+ return np.asarray(image).copy()
93
+ raise ValueError(f"Video has no frame {index}")
94
+
95
+
96
+ def _resized_rgb(array: np.ndarray, max_side: int, size=None) -> np.ndarray:
97
+ image = Image.fromarray(array, mode="RGB")
98
+ if size is None:
99
+ if max(image.size) > max_side:
100
+ image.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
101
+ width = max(2, image.width - image.width % 2)
102
+ height = max(2, image.height - image.height % 2)
103
+ size = (width, height)
104
+ if image.size != size:
105
+ image = image.resize(size, Image.Resampling.LANCZOS)
106
+ return np.asarray(image).copy()
107
+
108
+
109
+ def resample_video(
110
+ source_path: Path,
111
+ output_path: Path,
112
+ fps: int = 8,
113
+ max_side: int = 1600,
114
+ ) -> tuple[float, int, np.ndarray]:
115
+ """Create the exact, nearest-timestamp source sequence shown by the app."""
116
+ target_fps = int(fps)
117
+ if target_fps < 1:
118
+ raise ValueError("Target FPS must be positive")
119
+ output_path.parent.mkdir(parents=True, exist_ok=True)
120
+ first_output = None
121
+ output_count = 0
122
+ try:
123
+ with av.open(str(source_path)) as input_container:
124
+ input_stream = input_container.streams.video[0]
125
+ original_fps = _rate(input_stream)
126
+ with av.open(str(output_path), mode="w") as output_container:
127
+ try:
128
+ output_stream = output_container.add_stream(
129
+ "libx264", rate=Fraction(target_fps, 1)
130
+ )
131
+ except av.codec.codec.UnknownCodecError:
132
+ output_stream = output_container.add_stream(
133
+ "mpeg4", rate=Fraction(target_fps, 1)
134
+ )
135
+ output_stream.pix_fmt = "yuv420p"
136
+ output_stream.options = {"crf": "18", "preset": "veryfast"}
137
+ size = None
138
+ previous = None
139
+ previous_time = 0.0
140
+ first_time = None
141
+ next_time = 0.0
142
+ source_index = 0
143
+
144
+ def emit(array):
145
+ nonlocal first_output, output_count, size
146
+ resized = _resized_rgb(array, max_side, size)
147
+ if size is None:
148
+ size = (int(resized.shape[1]), int(resized.shape[0]))
149
+ output_stream.width, output_stream.height = size
150
+ if first_output is None:
151
+ first_output = resized.copy()
152
+ encoded = av.VideoFrame.from_ndarray(resized, format="rgb24")
153
+ for packet in output_stream.encode(encoded):
154
+ output_container.mux(packet)
155
+ output_count += 1
156
+
157
+ for frame in input_container.decode(input_stream):
158
+ raw_time = frame.time
159
+ if raw_time is None:
160
+ raw_time = source_index / original_fps
161
+ raw_time = float(raw_time)
162
+ if first_time is None:
163
+ first_time = raw_time
164
+ timestamp = max(0.0, raw_time - first_time)
165
+ if previous is not None:
166
+ timestamp = max(previous_time, timestamp)
167
+ array = frame.to_ndarray(format="rgb24")
168
+ if previous is not None:
169
+ boundary = (previous_time + timestamp) / 2
170
+ while next_time < boundary:
171
+ emit(previous)
172
+ next_time += 1 / target_fps
173
+ previous = array
174
+ previous_time = timestamp
175
+ source_index += 1
176
+ if previous is None:
177
+ raise ValueError("Video contains no decodable frames")
178
+ end_time = previous_time + 0.5 / original_fps
179
+ while next_time < end_time or output_count == 0:
180
+ emit(previous)
181
+ next_time += 1 / target_fps
182
+ for packet in output_stream.encode():
183
+ output_container.mux(packet)
184
+ output_path.chmod(0o600)
185
+ return original_fps, output_count, first_output
186
+ except Exception:
187
+ output_path.unlink(missing_ok=True)
188
+ raise
189
+
190
+
191
+ def decode_video_range(path: Path, start: int, end: int) -> np.ndarray:
192
+ start = max(0, int(start))
193
+ end = int(end)
194
+ if end < start:
195
+ raise ValueError("Video range end precedes its start")
196
+ wanted = end - start + 1
197
+ frames = []
198
+ with av.open(str(path)) as container:
199
+ stream = container.streams.video[0]
200
+ for index, frame in enumerate(container.decode(stream)):
201
+ if index < start:
202
+ continue
203
+ if index > end:
204
+ break
205
+ frames.append(frame.to_ndarray(format="rgb24"))
206
+ if len(frames) != wanted:
207
+ raise ValueError(
208
+ f"Source contains only {len(frames)} of frames {start} through {end}"
209
+ )
210
+ return np.stack(frames)
211
+
212
+
213
+ def decode_sampled_video(path: Path, start: int, stride: int, count: int) -> np.ndarray:
214
+ start = max(0, int(start))
215
+ stride = max(1, int(stride))
216
+ wanted = {start + stride * index: index for index in range(int(count))}
217
+ frames: list[np.ndarray | None] = [None] * int(count)
218
+ final_index = max(wanted)
219
+ with av.open(str(path)) as container:
220
+ stream = container.streams.video[0]
221
+ for frame_index, frame in enumerate(container.decode(stream)):
222
+ slot = wanted.get(frame_index)
223
+ if slot is not None:
224
+ frames[slot] = frame.to_ndarray(format="rgb24")
225
+ if frame_index >= final_index:
226
+ break
227
+ available = sum(frame is not None for frame in frames)
228
+ if available != count:
229
+ raise ValueError(
230
+ f"Source contains only {available} of the requested {count} sampled frames "
231
+ f"(start={start}, stride={stride})"
232
+ )
233
+ return np.stack(frames)
234
+
235
+
236
+ def _hex_rgb(color: str) -> tuple[int, int, int]:
237
+ value = color.lstrip("#")
238
+ return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
239
+
240
+
241
+ def composite_masks(
242
+ image: np.ndarray,
243
+ masks: list[tuple[np.ndarray, str]],
244
+ *,
245
+ max_size: tuple[int, int] | None = None,
246
+ ) -> np.ndarray:
247
+ output = image.astype(np.float32).copy()
248
+ for mask, color in masks:
249
+ mask = np.asarray(mask, dtype=bool)
250
+ if mask.shape != output.shape[:2]:
251
+ mask = np.asarray(
252
+ Image.fromarray(mask.astype(np.uint8), mode="L").resize(
253
+ (output.shape[1], output.shape[0]),
254
+ Image.Resampling.NEAREST,
255
+ ),
256
+ dtype=bool,
257
+ )
258
+ rgb = np.asarray(_hex_rgb(color), dtype=np.float32)
259
+ output[mask] = output[mask] * 0.67 + rgb * 0.33
260
+ edge = mask & ~(
261
+ np.roll(mask, 1, 0)
262
+ & np.roll(mask, -1, 0)
263
+ & np.roll(mask, 1, 1)
264
+ & np.roll(mask, -1, 1)
265
+ )
266
+ output[edge] = rgb
267
+ rendered = Image.fromarray(np.clip(output, 0, 255).astype(np.uint8))
268
+ if max_size is not None:
269
+ rendered.thumbnail(max_size, Image.Resampling.LANCZOS)
270
+ return np.asarray(rendered).copy()
271
+
272
+
273
+ def write_video(path: Path, frames: np.ndarray, fps: int = 8) -> None:
274
+ frames = np.asarray(frames, dtype=np.uint8)
275
+ if frames.ndim != 4 or frames.shape[-1] != 3:
276
+ raise ValueError("Video frames must be [T,H,W,3] uint8")
277
+ path.parent.mkdir(parents=True, exist_ok=True)
278
+ with av.open(str(path), mode="w") as container:
279
+ try:
280
+ stream = container.add_stream("libx264", rate=Fraction(int(fps), 1))
281
+ except av.codec.codec.UnknownCodecError:
282
+ stream = container.add_stream("mpeg4", rate=Fraction(int(fps), 1))
283
+ stream.width = int(frames.shape[2])
284
+ stream.height = int(frames.shape[1])
285
+ stream.pix_fmt = "yuv420p"
286
+ stream.options = {"crf": "18"}
287
+ for array in frames:
288
+ frame = av.VideoFrame.from_ndarray(array, format="rgb24")
289
+ for packet in stream.encode(frame):
290
+ container.mux(packet)
291
+ for packet in stream.encode():
292
+ container.mux(packet)
293
+ path.chmod(0o600)
app/requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -r ../requirements-wan.txt
2
+ torchvision==0.23.0
3
+ tqdm==4.67.1
4
+ hydra-core==1.3.2
5
+ iopath==0.1.10
6
+ fastapi==0.115.12
7
+ uvicorn[standard]==0.34.0
8
+ python-multipart==0.0.20
9
+ httpx==0.28.1
10
+ gradio==5.49.1
11
+ spaces==0.51.3
12
+ av==15.0.0
13
+ pillow==11.3.0
14
+ SAM-2 @ git+https://github.com/facebookresearch/sam2.git@2b90b9f5ceec907a1c18123530e92e794ad901a4
app/sam_service.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy shared SAM2 model with one cached predictor per uploaded asset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import nullcontext
6
+ from collections import OrderedDict
7
+ import threading
8
+
9
+ import numpy as np
10
+
11
+
12
+ class SamService:
13
+ def __init__(
14
+ self, model_id: str, device: str = "cuda", max_predictors: int = 8
15
+ ) -> None:
16
+ self.model_id = model_id
17
+ self.device = device
18
+ self._model = None
19
+ self.max_predictors = max(1, int(max_predictors))
20
+ self._predictors: OrderedDict[str, object] = OrderedDict()
21
+ self._lock = threading.RLock()
22
+
23
+ def has(self, key: str) -> bool:
24
+ with self._lock:
25
+ present = key in self._predictors
26
+ if present:
27
+ self._predictors.move_to_end(key)
28
+ return present
29
+
30
+ def prepare(self, key: str, image: np.ndarray) -> None:
31
+ with self._lock:
32
+ if key in self._predictors:
33
+ return
34
+ from sam2.sam2_image_predictor import SAM2ImagePredictor
35
+
36
+ if self._model is None:
37
+ predictor = SAM2ImagePredictor.from_pretrained(
38
+ self.model_id, device=self.device
39
+ )
40
+ self._model = predictor.model
41
+ else:
42
+ predictor = SAM2ImagePredictor(self._model)
43
+ with self._autocast():
44
+ predictor.set_image(np.asarray(image, dtype=np.uint8))
45
+ self._predictors[key] = predictor
46
+ self._predictors.move_to_end(key)
47
+ while len(self._predictors) > self.max_predictors:
48
+ _, evicted = self._predictors.popitem(last=False)
49
+ evicted.reset_predictor()
50
+
51
+ def predict(
52
+ self,
53
+ key: str,
54
+ image: np.ndarray,
55
+ positive: list[list[float]],
56
+ negative: list[list[float]],
57
+ box: list[float] | None,
58
+ ) -> tuple[np.ndarray, float]:
59
+ with self._lock:
60
+ self.prepare(key, image)
61
+ predictor = self._predictors[key]
62
+ self._predictors.move_to_end(key)
63
+ points = positive + negative
64
+ point_coords = None
65
+ point_labels = None
66
+ if points:
67
+ height, width = image.shape[:2]
68
+ point_coords = np.asarray(
69
+ [[point[0] * width, point[1] * height] for point in points],
70
+ dtype=np.float32,
71
+ )
72
+ point_labels = np.asarray(
73
+ [1] * len(positive) + [0] * len(negative), dtype=np.int32
74
+ )
75
+ pixel_box = None
76
+ if box is not None:
77
+ height, width = image.shape[:2]
78
+ pixel_box = np.asarray(
79
+ [
80
+ box[0] * width,
81
+ box[1] * height,
82
+ box[2] * width,
83
+ box[3] * height,
84
+ ],
85
+ dtype=np.float32,
86
+ )
87
+ with self._autocast():
88
+ masks, scores, _ = predictor.predict(
89
+ point_coords=point_coords,
90
+ point_labels=point_labels,
91
+ box=pixel_box,
92
+ multimask_output=True,
93
+ )
94
+ best = int(np.argmax(scores))
95
+ return np.asarray(masks[best], dtype=bool), float(scores[best])
96
+
97
+ def release(self, prefix: str) -> None:
98
+ with self._lock:
99
+ for key in [key for key in self._predictors if key.startswith(prefix)]:
100
+ predictor = self._predictors.pop(key)
101
+ predictor.reset_predictor()
102
+
103
+ def close(self) -> None:
104
+ with self._lock:
105
+ for predictor in self._predictors.values():
106
+ predictor.reset_predictor()
107
+ self._predictors.clear()
108
+ self._model = None
109
+
110
+ def _autocast(self):
111
+ if not self.device.startswith("cuda"):
112
+ return nullcontext()
113
+ import torch
114
+
115
+ return torch.autocast("cuda", dtype=torch.bfloat16)
app/scheduler.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A bounded serial scheduler for stateful GPU inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from concurrent.futures import Future
6
+ from dataclasses import dataclass, field, replace
7
+ import itertools
8
+ import queue
9
+ import threading
10
+ import time
11
+ import traceback
12
+ from typing import Any, Callable
13
+ from uuid import uuid4
14
+
15
+
16
+ class Superseded(RuntimeError):
17
+ """Raised when queued work is replaced or its owner is removed."""
18
+
19
+
20
+ class QueueFull(RuntimeError):
21
+ """Raised when the bounded GPU queue has no available slot."""
22
+
23
+
24
+ @dataclass(order=True)
25
+ class _QueuedTask:
26
+ priority: int
27
+ order: int
28
+ task_id: str = field(compare=False)
29
+ function: Callable[[], Any] | None = field(compare=False)
30
+ future: Future = field(compare=False)
31
+ key: str | None = field(compare=False, default=None)
32
+ owner: str | None = field(compare=False, default=None)
33
+ retain_record: bool = field(compare=False, default=False)
34
+
35
+
36
+ @dataclass
37
+ class TaskRecord:
38
+ id: str
39
+ label: str
40
+ status: str = "queued"
41
+ result: Any = None
42
+ error: str | None = None
43
+ created_at: float = field(default_factory=time.time)
44
+ started_at: float | None = None
45
+ finished_at: float | None = None
46
+ stage: str = "Queued"
47
+ progress_current: int | None = None
48
+ progress_total: int | None = None
49
+ message: str | None = None
50
+
51
+ def as_dict(self) -> dict[str, Any]:
52
+ return {
53
+ "id": self.id,
54
+ "label": self.label,
55
+ "status": self.status,
56
+ "result": self.result,
57
+ "error": self.error,
58
+ "created_at": self.created_at,
59
+ "started_at": self.started_at,
60
+ "finished_at": self.finished_at,
61
+ "stage": self.stage,
62
+ "progress_current": self.progress_current,
63
+ "progress_total": self.progress_total,
64
+ "message": self.message,
65
+ }
66
+
67
+
68
+ class GpuScheduler:
69
+ """Run GPU work serially and release task payloads as soon as possible."""
70
+
71
+ def __init__(self, max_pending: int = 32) -> None:
72
+ if max_pending < 1:
73
+ raise ValueError("max_pending must be positive")
74
+ self.max_pending = int(max_pending)
75
+ self._queue: queue.PriorityQueue[_QueuedTask] = queue.PriorityQueue()
76
+ self._counter = itertools.count()
77
+ self._lock = threading.RLock()
78
+ self._latest_by_key: dict[str, str] = {}
79
+ self._queued: dict[str, _QueuedTask] = {}
80
+ self._records: dict[str, TaskRecord] = {}
81
+ self._closed = False
82
+ self._thread = threading.Thread(
83
+ target=self._run,
84
+ name="whatmoves-gpu",
85
+ daemon=True,
86
+ )
87
+ self._thread.start()
88
+
89
+ def submit(
90
+ self,
91
+ function: Callable[[], Any],
92
+ *,
93
+ priority: int = 10,
94
+ key: str | None = None,
95
+ owner: str | None = None,
96
+ label: str = "GPU task",
97
+ retain_record: bool = False,
98
+ task_id: str | None = None,
99
+ ) -> tuple[str, Future]:
100
+ future: Future = Future()
101
+ task_id = task_id or uuid4().hex
102
+ cancelled: tuple[Future, str] | None = None
103
+ with self._lock:
104
+ if self._closed:
105
+ raise RuntimeError("GPU scheduler is closed")
106
+ if task_id in self._records:
107
+ raise ValueError(f"Duplicate GPU task id: {task_id}")
108
+ if key is not None:
109
+ previous_id = self._latest_by_key.get(key)
110
+ if previous_id is not None:
111
+ cancelled = self._cancel_locked(
112
+ previous_id,
113
+ f"Superseded by newer task: {label}",
114
+ )
115
+ pending = sum(
116
+ record.status in {"queued", "running"}
117
+ for record in self._records.values()
118
+ )
119
+ if pending >= self.max_pending:
120
+ raise QueueFull(f"GPU queue is full ({self.max_pending} pending tasks)")
121
+ record = TaskRecord(task_id, label)
122
+ task = _QueuedTask(
123
+ int(priority),
124
+ next(self._counter),
125
+ task_id,
126
+ function,
127
+ future,
128
+ key,
129
+ owner,
130
+ bool(retain_record),
131
+ )
132
+ self._records[task_id] = record
133
+ self._queued[task_id] = task
134
+ if key is not None:
135
+ self._latest_by_key[key] = task_id
136
+ self._queue.put(task)
137
+ if cancelled is not None:
138
+ cancelled_future, reason = cancelled
139
+ if not cancelled_future.done():
140
+ cancelled_future.set_exception(Superseded(reason))
141
+ return task_id, future
142
+
143
+ def update_progress(
144
+ self,
145
+ task_id: str,
146
+ stage: str,
147
+ current: int | None = None,
148
+ total: int | None = None,
149
+ message: str | None = None,
150
+ ) -> None:
151
+ """Publish honest stage progress from the worker that owns a task."""
152
+ with self._lock:
153
+ record = self._records.get(task_id)
154
+ if record is None or record.status not in {"queued", "running"}:
155
+ return
156
+ record.stage = str(stage)
157
+ record.progress_current = None if current is None else int(current)
158
+ record.progress_total = None if total is None else int(total)
159
+ record.message = message
160
+
161
+ def get(self, task_id: str) -> TaskRecord | None:
162
+ with self._lock:
163
+ record = self._records.get(task_id)
164
+ return replace(record) if record is not None else None
165
+
166
+ def discard(self, task_id: str) -> None:
167
+ """Forget a terminal retained record and its result."""
168
+ with self._lock:
169
+ record = self._records.get(task_id)
170
+ if record is not None and record.status not in {"queued", "running"}:
171
+ self._records.pop(task_id, None)
172
+
173
+ def cancel_prefix(self, prefix: str) -> None:
174
+ """Cancel queued tasks whose coalescing key starts with ``prefix``."""
175
+ cancelled: list[tuple[Future, str]] = []
176
+ with self._lock:
177
+ for task_id, task in tuple(self._queued.items()):
178
+ if task.key is not None and task.key.startswith(prefix):
179
+ item = self._cancel_locked(task_id, "Owning asset was removed")
180
+ if item is not None:
181
+ cancelled.append(item)
182
+ for future, reason in cancelled:
183
+ if not future.done():
184
+ future.set_exception(Superseded(reason))
185
+
186
+ def cancel_owner(self, owner: str) -> None:
187
+ """Cancel all queued tasks belonging to one browser session."""
188
+ cancelled: list[tuple[Future, str]] = []
189
+ with self._lock:
190
+ for task_id, task in tuple(self._queued.items()):
191
+ if task.owner == owner:
192
+ item = self._cancel_locked(task_id, "Owning session expired")
193
+ if item is not None:
194
+ cancelled.append(item)
195
+ for future, reason in cancelled:
196
+ if not future.done():
197
+ future.set_exception(Superseded(reason))
198
+
199
+ def close(self) -> None:
200
+ cancelled: list[tuple[Future, str]] = []
201
+ with self._lock:
202
+ if self._closed:
203
+ return
204
+ self._closed = True
205
+ for task_id in tuple(self._queued):
206
+ item = self._cancel_locked(task_id, "GPU scheduler is closing")
207
+ if item is not None:
208
+ cancelled.append(item)
209
+ sentinel = _QueuedTask(
210
+ -(10**9),
211
+ next(self._counter),
212
+ "__close__",
213
+ None,
214
+ Future(),
215
+ )
216
+ self._queue.put(sentinel)
217
+ for future, reason in cancelled:
218
+ if not future.done():
219
+ future.set_exception(Superseded(reason))
220
+ self._thread.join()
221
+ with self._lock:
222
+ with self._queue.mutex:
223
+ self._queue.queue.clear()
224
+ self._queued.clear()
225
+ self._latest_by_key.clear()
226
+ self._records.clear()
227
+
228
+ def _cancel_locked(
229
+ self,
230
+ task_id: str,
231
+ reason: str,
232
+ ) -> tuple[Future, str] | None:
233
+ record = self._records.get(task_id)
234
+ task = self._queued.get(task_id)
235
+ if record is None or task is None or record.status != "queued":
236
+ return None
237
+ task.function = None
238
+ record.status = "superseded"
239
+ record.error = reason
240
+ record.finished_at = time.time()
241
+ if task.key is not None and self._latest_by_key.get(task.key) == task_id:
242
+ self._latest_by_key.pop(task.key, None)
243
+ return task.future, reason
244
+
245
+ def _run(self) -> None:
246
+ while True:
247
+ task = self._queue.get()
248
+ if task.task_id == "__close__":
249
+ del task
250
+ return
251
+ with self._lock:
252
+ self._queued.pop(task.task_id, None)
253
+ record = self._records.get(task.task_id)
254
+ function = task.function
255
+ task.function = None
256
+ if record is None or function is None:
257
+ if not task.retain_record:
258
+ self._records.pop(task.task_id, None)
259
+ del task
260
+ continue
261
+ record.status = "running"
262
+ record.started_at = time.time()
263
+ if record.stage == "Queued":
264
+ record.stage = "Starting"
265
+ try:
266
+ result = function()
267
+ except Exception as error: # worker errors must never kill the queue
268
+ traceback.print_exc()
269
+ with self._lock:
270
+ record.status = "failed"
271
+ record.error = str(error)
272
+ record.finished_at = time.time()
273
+ if (
274
+ task.key is not None
275
+ and self._latest_by_key.get(task.key) == task.task_id
276
+ ):
277
+ self._latest_by_key.pop(task.key, None)
278
+ if not task.future.done():
279
+ task.future.set_exception(error)
280
+ else:
281
+ with self._lock:
282
+ record.status = "complete"
283
+ record.result = result
284
+ record.finished_at = time.time()
285
+ if (
286
+ task.key is not None
287
+ and self._latest_by_key.get(task.key) == task.task_id
288
+ ):
289
+ self._latest_by_key.pop(task.key, None)
290
+ if not task.future.done():
291
+ task.future.set_result(result)
292
+ del result
293
+ finally:
294
+ del function
295
+ with self._lock:
296
+ if not task.retain_record:
297
+ self._records.pop(task.task_id, None)
298
+ del task
app/server.py ADDED
@@ -0,0 +1,1275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI server for the WhatMoves annotation and generation workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from contextlib import asynccontextmanager
9
+ import os
10
+ from pathlib import Path
11
+ import shutil
12
+ import threading
13
+ from typing import Literal
14
+
15
+ from fastapi import FastAPI, File, HTTPException, UploadFile
16
+ from fastapi.responses import FileResponse, HTMLResponse, Response
17
+ from fastapi.staticfiles import StaticFiles
18
+ from pydantic import BaseModel, Field
19
+
20
+ from .config import AppConfig
21
+ from .examples import load_examples
22
+ from .inference import GenerationSnapshot, SourceSnapshot, WanService
23
+ from .media import (
24
+ composite_masks,
25
+ decode_video_frame,
26
+ jpeg_bytes,
27
+ mask_png_bytes,
28
+ png_bytes,
29
+ read_image,
30
+ resample_video,
31
+ )
32
+ from .sam_service import SamService
33
+ from .scheduler import GpuScheduler, QueueFull, Superseded
34
+ from .state import (
35
+ Draft,
36
+ MaskRecord,
37
+ Session,
38
+ SessionStore,
39
+ SourceAsset,
40
+ TargetAsset,
41
+ clear_excluded_source_mappings,
42
+ clear_source_mappings,
43
+ new_id,
44
+ session_payload,
45
+ source_mask_lookup,
46
+ )
47
+
48
+ STATIC_ROOT = Path(__file__).resolve().parent / "static"
49
+ ASSETS_ROOT = Path(__file__).resolve().parent / "assets"
50
+
51
+
52
+ class PromptRequest(BaseModel):
53
+ asset_revision: int
54
+ prompt_revision: int
55
+ positive: list[list[float]] = Field(default_factory=list)
56
+ negative: list[list[float]] = Field(default_factory=list)
57
+ box: list[float] | None = None
58
+ frame_index: int | None = Field(default=None, ge=0)
59
+ transient: bool = False
60
+
61
+
62
+ class CommitRequest(BaseModel):
63
+ asset_revision: int
64
+ prompt_revision: int
65
+ frame_index: int | None = Field(default=None, ge=0)
66
+
67
+
68
+ class SourceSettings(BaseModel):
69
+ current_frame: int | None = Field(default=None, ge=0)
70
+ trim_start: int | None = Field(default=None, ge=0)
71
+ trim_end: int | None = Field(default=None, ge=0)
72
+
73
+
74
+ class MappingRequest(BaseModel):
75
+ source_id: str | None = None
76
+ source_mask_id: str | None = None
77
+
78
+
79
+ class GenerateRequest(BaseModel):
80
+ prompt: str = Field(default="", max_length=4096)
81
+ negative_prompt: str | None = Field(default=None, max_length=4096)
82
+ steps: int = Field(default=40, ge=1, le=100)
83
+ seed: int = Field(default=42, ge=-(2**63), le=2**63 - 1)
84
+ guidance_mode: Literal[
85
+ "base_cfg",
86
+ "text_cfg",
87
+ "joint_cfg",
88
+ "motion_cfg",
89
+ "additive_cfg",
90
+ "factorized_cfg",
91
+ ] = "text_cfg"
92
+ text_guidance_scale: float = Field(default=3.5, allow_inf_nan=False)
93
+ motion_guidance_scale: float = Field(default=1.0, allow_inf_nan=False)
94
+ lora_scale: float = Field(default=1.0, allow_inf_nan=False)
95
+
96
+
97
+ class Runtime:
98
+ def __init__(
99
+ self,
100
+ config: AppConfig | None = None,
101
+ *,
102
+ eager_embeddings: bool = True,
103
+ ) -> None:
104
+ self.config = config or AppConfig()
105
+ self.eager_embeddings = eager_embeddings
106
+ self.store = SessionStore(self.config.runtime_root)
107
+ self.cpu = ThreadPoolExecutor(max_workers=4, thread_name_prefix="whatmoves-cpu")
108
+ self.scheduler = GpuScheduler(max_pending=self.config.max_gpu_tasks)
109
+ self.sam = SamService(
110
+ self.config.sam_model_id,
111
+ self.config.device,
112
+ max_predictors=self.config.max_sam_frames,
113
+ )
114
+ self.wan = WanService(self.config)
115
+ self._closing = threading.Event()
116
+ self._cleanup_thread = threading.Thread(
117
+ target=self._cleanup_sessions,
118
+ name="whatmoves-cleanup",
119
+ daemon=True,
120
+ )
121
+ self._cleanup_thread.start()
122
+
123
+ def close(self) -> None:
124
+ if self._closing.is_set():
125
+ return
126
+ self._closing.set()
127
+ self._cleanup_thread.join()
128
+ sessions = self.store.pop_all()
129
+ for session in sessions:
130
+ self.dispose_session(session)
131
+ self.scheduler.close()
132
+ for session in sessions:
133
+ _remove_session_directory(self.config.runtime_root, session.directory)
134
+ self.sam.close()
135
+ self.wan.close()
136
+ self.cpu.shutdown(wait=False, cancel_futures=True)
137
+ try:
138
+ self.config.runtime_root.rmdir()
139
+ except OSError:
140
+ pass
141
+
142
+ def dispose_session(self, session: Session) -> None:
143
+ """Release every live resource owned by one browser session."""
144
+ with session.lock:
145
+ if session.closed:
146
+ return
147
+ session.closed = True
148
+ targets = list(session.targets.values())
149
+ sources = list(session.sources.values())
150
+ latest_task = session.latest_generation
151
+ failed_task = session.failed_generation
152
+ latest_output = session.latest_output
153
+ active_task = session.active_generation
154
+ session.targets.clear()
155
+ session.selected_target_id = None
156
+ session.sources.clear()
157
+ session.latest_generation = None
158
+ session.failed_generation = None
159
+ session.latest_output = None
160
+ self.scheduler.cancel_owner(session.id)
161
+ self.sam.release(f"{session.id}:")
162
+ for target in targets:
163
+ _clear_asset(target)
164
+ for source in sources:
165
+ _clear_asset(source)
166
+ source.video_path.unlink(missing_ok=True)
167
+ if latest_output is not None:
168
+ latest_output.unlink(missing_ok=True)
169
+ if latest_task is not None and latest_task != active_task:
170
+ self.scheduler.discard(latest_task)
171
+ if failed_task is not None and failed_task != active_task:
172
+ self.scheduler.discard(failed_task)
173
+ active = self.scheduler.get(active_task) if active_task is not None else None
174
+ if active is None or active.status != "running":
175
+ if active_task is not None:
176
+ self.scheduler.discard(active_task)
177
+ _remove_session_directory(self.config.runtime_root, session.directory)
178
+
179
+ def _cleanup_sessions(self) -> None:
180
+ while not self._closing.wait(self.config.cleanup_interval_seconds):
181
+ for session in self.store.pop_expired(self.config.session_ttl_seconds):
182
+ self.dispose_session(session)
183
+
184
+
185
+ async def _run_cpu(runtime: Runtime, function, *args, **kwargs):
186
+ # CPU-only is the lightweight test/development mode; production CUDA runs
187
+ # media work in the dedicated pool so the HTTP event loop stays responsive.
188
+ if runtime.config.device == "cpu":
189
+ return function(*args, **kwargs)
190
+ return await asyncio.wrap_future(runtime.cpu.submit(function, *args, **kwargs))
191
+
192
+
193
+ def _session(runtime: Runtime, session_id: str) -> Session:
194
+ try:
195
+ return runtime.store.get(session_id)
196
+ except KeyError as error:
197
+ raise HTTPException(404, str(error)) from error
198
+
199
+
200
+ def _remove_session_directory(root: Path, directory: Path) -> None:
201
+ """Remove exactly one validated session directory."""
202
+ root = root.resolve()
203
+ directory = directory.resolve()
204
+ if directory.parent != root or not directory.name:
205
+ raise RuntimeError(f"Refusing to remove invalid session path: {directory}")
206
+ shutil.rmtree(directory, ignore_errors=True)
207
+
208
+
209
+ def _clear_asset(asset: TargetAsset | SourceAsset) -> None:
210
+ asset.draft = None
211
+ asset.prediction_token = None
212
+ asset.hover_prediction_token = None
213
+ if isinstance(asset, SourceAsset):
214
+ asset.frame_selection_token = None
215
+ asset.masks.clear()
216
+
217
+
218
+ def _sam_prefix(session_id: str, kind: str, asset_id: str) -> str:
219
+ return f"{session_id}:{kind}:{asset_id}:"
220
+
221
+
222
+ def _release_asset(runtime: Runtime, session_id: str, kind: str, asset_id: str) -> None:
223
+ prefix = _sam_prefix(session_id, kind, asset_id)
224
+ runtime.scheduler.cancel_prefix(f"sam:{prefix}")
225
+ runtime.sam.release(prefix)
226
+
227
+
228
+ def _asset(session: Session, kind: str, asset_id: str):
229
+ if kind == "target":
230
+ try:
231
+ return session.targets[asset_id]
232
+ except KeyError as error:
233
+ raise HTTPException(404, f"Unknown target {asset_id}") from error
234
+ if kind == "source":
235
+ try:
236
+ return session.sources[asset_id]
237
+ except KeyError as error:
238
+ raise HTTPException(404, f"Unknown source {asset_id}") from error
239
+ raise HTTPException(404, f"Unknown asset kind {kind}")
240
+
241
+
242
+ def _sam_key(
243
+ session_id: str,
244
+ kind: str,
245
+ asset_id: str,
246
+ revision: int,
247
+ frame_index: int | None = None,
248
+ ) -> str:
249
+ frame = "image" if frame_index is None else f"frame-{frame_index}"
250
+ return f"{session_id}:{kind}:{asset_id}:{revision}:{frame}"
251
+
252
+
253
+ def _store_upload_sync(upload: UploadFile, path: Path, limit: int) -> None:
254
+ written = 0
255
+ try:
256
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
257
+ with os.fdopen(descriptor, "wb") as output:
258
+ while chunk := upload.file.read(1024 * 1024):
259
+ written += len(chunk)
260
+ if written > limit:
261
+ raise HTTPException(413, "Upload is too large")
262
+ output.write(chunk)
263
+ except Exception:
264
+ path.unlink(missing_ok=True)
265
+ raise
266
+
267
+
268
+ def _queue_embedding(
269
+ runtime: Runtime,
270
+ session: Session,
271
+ kind: str,
272
+ asset_id: str,
273
+ revision: int,
274
+ frame_index: int | None = None,
275
+ ) -> None:
276
+ # ZeroGPU only exposes a real CUDA device while a @spaces.GPU function is
277
+ # active. In Gradio transport mode SAM prepares its embedding lazily inside
278
+ # the decorated prediction call instead of starting background CUDA work.
279
+ if not runtime.eager_embeddings:
280
+ with session.lock:
281
+ try:
282
+ asset = _asset(session, kind, asset_id)
283
+ except HTTPException:
284
+ return
285
+ if asset.revision == revision and (
286
+ kind == "target" or asset.current_frame == frame_index
287
+ ):
288
+ asset.sam_status = "ready"
289
+ asset.sam_error = None
290
+ return
291
+
292
+ key = _sam_key(session.id, kind, asset_id, revision, frame_index)
293
+
294
+ if runtime.sam.has(key):
295
+ with session.lock:
296
+ try:
297
+ asset = _asset(session, kind, asset_id)
298
+ except HTTPException:
299
+ return
300
+ if asset.revision == revision and (
301
+ kind == "target" or asset.current_frame == frame_index
302
+ ):
303
+ asset.sam_status = "ready"
304
+ asset.sam_error = None
305
+ return
306
+
307
+ def prepare():
308
+ with session.lock:
309
+ try:
310
+ current = _asset(session, kind, asset_id)
311
+ except HTTPException:
312
+ return None
313
+ if (
314
+ session.closed
315
+ or current.revision != revision
316
+ or (kind == "source" and current.current_frame != frame_index)
317
+ ):
318
+ return None
319
+ image = current.image if kind == "target" else current.frame
320
+ try:
321
+ runtime.sam.prepare(key, image)
322
+ except Exception as error:
323
+ with session.lock:
324
+ try:
325
+ asset = _asset(session, kind, asset_id)
326
+ except HTTPException:
327
+ return None
328
+ if asset.revision == revision and (
329
+ kind == "target" or asset.current_frame == frame_index
330
+ ):
331
+ asset.sam_status = "error"
332
+ asset.sam_error = str(error)
333
+ raise
334
+ keep = False
335
+ with session.lock:
336
+ try:
337
+ asset = _asset(session, kind, asset_id)
338
+ except HTTPException:
339
+ asset = None
340
+ if (
341
+ asset is not None
342
+ and asset.revision == revision
343
+ and (kind == "target" or asset.current_frame == frame_index)
344
+ ):
345
+ asset.sam_status = "ready"
346
+ asset.sam_error = None
347
+ keep = True
348
+ if not keep:
349
+ runtime.sam.release(key)
350
+ return None
351
+
352
+ try:
353
+ runtime.scheduler.submit(
354
+ prepare,
355
+ priority=20,
356
+ key=f"sam:{key}:embedding",
357
+ owner=session.id,
358
+ label=f"Prepare {kind} mask features",
359
+ )
360
+ except QueueFull as error:
361
+ with session.lock:
362
+ try:
363
+ asset = _asset(session, kind, asset_id)
364
+ except HTTPException:
365
+ return
366
+ if asset.revision == revision:
367
+ asset.sam_status = "error"
368
+ asset.sam_error = str(error)
369
+
370
+
371
+ def _state(runtime: Runtime, session: Session):
372
+ with session.lock:
373
+ payload = session_payload(session)
374
+ payload["wan_status"] = runtime.wan.status
375
+ payload["defaults"] = {
376
+ "frames": runtime.config.output_frames,
377
+ "width": runtime.config.output_width,
378
+ "height": runtime.config.output_height,
379
+ "fps": runtime.config.output_fps,
380
+ "source_fps": runtime.config.source_fps,
381
+ "minimum_source_frames": 8,
382
+ }
383
+ return payload
384
+
385
+
386
+ def _validate_prompts(request: PromptRequest) -> None:
387
+ if not request.positive and not request.negative and request.box is None:
388
+ raise HTTPException(400, "At least one point or box is required")
389
+ for point in request.positive + request.negative:
390
+ if len(point) != 2 or not all(0 <= float(value) <= 1 for value in point):
391
+ raise HTTPException(400, "Prompt points must be normalized [x,y] pairs")
392
+ if request.box is not None:
393
+ if len(request.box) != 4 or not all(
394
+ 0 <= float(value) <= 1 for value in request.box
395
+ ):
396
+ raise HTTPException(400, "Box must be normalized [x0,y0,x1,y1]")
397
+ if request.box[0] >= request.box[2] or request.box[1] >= request.box[3]:
398
+ raise HTTPException(400, "Box must have positive width and height")
399
+
400
+
401
+ def _generation_snapshot(
402
+ runtime: Runtime, session: Session, request: GenerateRequest
403
+ ) -> GenerationSnapshot:
404
+ target = session.selected_target
405
+ if target is None:
406
+ raise HTTPException(400, "Upload a target image first")
407
+ grouped: dict[str, list[tuple[MaskRecord, MaskRecord]]] = {}
408
+ for target_mask in target.masks.values():
409
+ if target_mask.source_id is None or target_mask.source_mask_id is None:
410
+ continue
411
+ try:
412
+ source_mask = source_mask_lookup(
413
+ session, target_mask.source_id, target_mask.source_mask_id
414
+ )
415
+ except KeyError:
416
+ continue
417
+ grouped.setdefault(target_mask.source_id, []).append((source_mask, target_mask))
418
+ if request.guidance_mode == "base_cfg":
419
+ grouped.clear()
420
+ elif not grouped:
421
+ raise HTTPException(400, "Map at least one source mask to a target mask")
422
+
423
+ run_id = new_id("run")
424
+ output_path = session.directory / f"generation_{run_id}.mp4"
425
+ sources = []
426
+ try:
427
+ for index, (source_id, pairs) in enumerate(grouped.items()):
428
+ source = session.sources[source_id]
429
+ selected_frames = source.trim_end - source.trim_start + 1
430
+ if selected_frames < 8:
431
+ raise HTTPException(
432
+ 400,
433
+ f"{source.name} needs at least 8 selected 8-fps frames",
434
+ )
435
+ if any(
436
+ mask.frame_index is None
437
+ or not source.trim_start <= mask.frame_index <= source.trim_end
438
+ for mask, _ in pairs
439
+ ):
440
+ raise HTTPException(409, "A mapped source mask lies outside its trim")
441
+ private_path = session.directory / (
442
+ f"input_{run_id}_{index}{source.video_path.suffix.lower()}"
443
+ )
444
+ os.link(source.video_path, private_path)
445
+ sources.append(
446
+ SourceSnapshot(
447
+ video_path=private_path,
448
+ trim_start=source.trim_start,
449
+ trim_end=source.trim_end,
450
+ source_masks=tuple(pair[0].data.copy() for pair in pairs),
451
+ target_masks=tuple(pair[1].data.copy() for pair in pairs),
452
+ reference_frames=tuple(pair[0].frame_index for pair in pairs),
453
+ )
454
+ )
455
+ except Exception:
456
+ for source in sources:
457
+ source.video_path.unlink(missing_ok=True)
458
+ raise
459
+ return GenerationSnapshot(
460
+ target_image=target.image.copy(),
461
+ sources=tuple(sources),
462
+ prompt=request.prompt.strip(),
463
+ negative_prompt=(request.negative_prompt or "").strip() or None,
464
+ steps=request.steps,
465
+ seed=request.seed,
466
+ guidance_mode=request.guidance_mode,
467
+ text_guidance_scale=request.text_guidance_scale,
468
+ motion_guidance_scale=request.motion_guidance_scale,
469
+ lora_scale=request.lora_scale,
470
+ output_path=output_path,
471
+ )
472
+
473
+
474
+ def create_app(runtime: Runtime | None = None) -> FastAPI:
475
+ runtime = runtime or Runtime()
476
+
477
+ @asynccontextmanager
478
+ async def lifespan(_: FastAPI):
479
+ yield
480
+ runtime.close()
481
+
482
+ application = FastAPI(title="WhatMoves", lifespan=lifespan)
483
+ application.state.runtime = runtime
484
+ application.mount("/static", StaticFiles(directory=STATIC_ROOT), name="static")
485
+
486
+ @application.get("/", response_class=HTMLResponse)
487
+ async def index():
488
+ return (STATIC_ROOT / "index.html").read_text()
489
+
490
+ @application.get("/api/health")
491
+ async def health():
492
+ return {"status": "ok", "wan_status": runtime.wan.status}
493
+
494
+ @application.post("/api/sessions")
495
+ async def create_session():
496
+ session = runtime.store.create()
497
+ try:
498
+ await _run_cpu(
499
+ runtime,
500
+ load_examples,
501
+ session,
502
+ ASSETS_ROOT,
503
+ source_fps=runtime.config.source_fps,
504
+ max_sources=runtime.config.max_sources,
505
+ max_targets=runtime.config.max_targets,
506
+ max_image_side=runtime.config.max_image_side,
507
+ )
508
+ except Exception:
509
+ runtime.store.pop(session.id)
510
+ runtime.dispose_session(session)
511
+ raise
512
+ source = next(iter(session.sources.values()), None)
513
+ target = session.selected_target
514
+ if source is not None:
515
+ source.sam_status = "queued"
516
+ _queue_embedding(
517
+ runtime,
518
+ session,
519
+ "source",
520
+ source.id,
521
+ source.revision,
522
+ source.current_frame,
523
+ )
524
+ if target is not None:
525
+ target.sam_status = "queued"
526
+ _queue_embedding(runtime, session, "target", target.id, target.revision)
527
+ return _state(runtime, session)
528
+
529
+ @application.get("/api/sessions/{session_id}")
530
+ async def get_state(session_id: str):
531
+ return _state(runtime, _session(runtime, session_id))
532
+
533
+ @application.post("/api/sessions/{session_id}/targets")
534
+ async def upload_target(session_id: str, file: UploadFile = File(...)):
535
+ session = _session(runtime, session_id)
536
+ with session.lock:
537
+ if len(session.targets) >= runtime.config.max_targets:
538
+ raise HTTPException(
539
+ 409,
540
+ f"A session may contain at most {runtime.config.max_targets} targets",
541
+ )
542
+ target_id = new_id("target")
543
+ suffix = Path(file.filename or "target.png").suffix.lower() or ".png"
544
+ path = session.directory / f"{target_id}{suffix}"
545
+ await _run_cpu(
546
+ runtime, _store_upload_sync, file, path, runtime.config.max_upload_bytes
547
+ )
548
+ try:
549
+ image = await _run_cpu(
550
+ runtime, read_image, path, runtime.config.max_image_side
551
+ )
552
+ except Exception as error:
553
+ raise HTTPException(
554
+ 400, f"Could not decode target image: {error}"
555
+ ) from error
556
+ finally:
557
+ path.unlink(missing_ok=True)
558
+ with session.lock:
559
+ if session.closed:
560
+ raise HTTPException(410, "The browser session has expired")
561
+ if len(session.targets) >= runtime.config.max_targets:
562
+ raise HTTPException(
563
+ 409,
564
+ f"A session may contain at most {runtime.config.max_targets} targets",
565
+ )
566
+ session.target_revision += 1
567
+ target = TargetAsset(
568
+ id=target_id,
569
+ name=Path(file.filename or "Target image").name,
570
+ image=image,
571
+ revision=session.target_revision,
572
+ )
573
+ session.targets[target.id] = target
574
+ session.selected_target_id = target.id
575
+ session.touch()
576
+ _queue_embedding(runtime, session, "target", target.id, target.revision)
577
+ return _state(runtime, session)
578
+
579
+ @application.put("/api/sessions/{session_id}/targets/{target_id}/selection")
580
+ async def select_target(session_id: str, target_id: str):
581
+ session = _session(runtime, session_id)
582
+ with session.lock:
583
+ target = _asset(session, "target", target_id)
584
+ session.selected_target_id = target.id
585
+ if not runtime.sam.has(
586
+ _sam_key(session.id, "target", target.id, target.revision)
587
+ ):
588
+ target.sam_status = "queued"
589
+ session.touch()
590
+ _queue_embedding(runtime, session, "target", target.id, target.revision)
591
+ return _state(runtime, session)
592
+
593
+ @application.delete("/api/sessions/{session_id}/targets/{target_id}")
594
+ async def remove_target(session_id: str, target_id: str):
595
+ session = _session(runtime, session_id)
596
+ with session.lock:
597
+ target = _asset(session, "target", target_id)
598
+ del session.targets[target_id]
599
+ if session.selected_target_id == target_id:
600
+ session.selected_target_id = next(iter(session.targets), None)
601
+ session.touch()
602
+ _release_asset(runtime, session.id, "target", target_id)
603
+ _clear_asset(target)
604
+ return _state(runtime, session)
605
+
606
+ @application.post("/api/sessions/{session_id}/sources")
607
+ async def upload_source(session_id: str, file: UploadFile = File(...)):
608
+ session = _session(runtime, session_id)
609
+ with session.lock:
610
+ if len(session.sources) >= runtime.config.max_sources:
611
+ raise HTTPException(
612
+ 409,
613
+ f"A session may contain at most {runtime.config.max_sources} sources",
614
+ )
615
+ suffix = Path(file.filename or "source.mp4").suffix.lower() or ".mp4"
616
+ source_id = new_id("source")
617
+ upload_path = session.directory / f"upload_{source_id}{suffix}"
618
+ path = session.directory / f"{source_id}.mp4"
619
+ await _run_cpu(
620
+ runtime,
621
+ _store_upload_sync,
622
+ file,
623
+ upload_path,
624
+ runtime.config.max_upload_bytes,
625
+ )
626
+ try:
627
+ original_fps, frame_count, frame = await _run_cpu(
628
+ runtime,
629
+ resample_video,
630
+ upload_path,
631
+ path,
632
+ runtime.config.source_fps,
633
+ runtime.config.max_image_side,
634
+ )
635
+ except Exception as error:
636
+ path.unlink(missing_ok=True)
637
+ raise HTTPException(
638
+ 400, f"Could not decode source video: {error}"
639
+ ) from error
640
+ finally:
641
+ upload_path.unlink(missing_ok=True)
642
+ source = SourceAsset(
643
+ id=source_id,
644
+ name=Path(file.filename or "Source video").name,
645
+ video_path=path,
646
+ frame=frame,
647
+ fps=float(runtime.config.source_fps),
648
+ frame_count=frame_count,
649
+ original_fps=original_fps,
650
+ trim_end=min(frame_count - 1, runtime.config.output_frames - 1),
651
+ )
652
+ with session.lock:
653
+ if session.closed:
654
+ path.unlink(missing_ok=True)
655
+ raise HTTPException(410, "The browser session has expired")
656
+ if len(session.sources) >= runtime.config.max_sources:
657
+ path.unlink(missing_ok=True)
658
+ raise HTTPException(
659
+ 409,
660
+ f"A session may contain at most {runtime.config.max_sources} sources",
661
+ )
662
+ session.sources[source.id] = source
663
+ session.touch()
664
+ _queue_embedding(
665
+ runtime,
666
+ session,
667
+ "source",
668
+ source.id,
669
+ source.revision,
670
+ source.current_frame,
671
+ )
672
+ return _state(runtime, session)
673
+
674
+ @application.patch("/api/sessions/{session_id}/sources/{source_id}")
675
+ async def update_source(session_id: str, source_id: str, settings: SourceSettings):
676
+ session = _session(runtime, session_id)
677
+ with session.lock:
678
+ source = _asset(session, "source", source_id)
679
+ trim_start = (
680
+ source.trim_start
681
+ if settings.trim_start is None
682
+ else settings.trim_start
683
+ )
684
+ trim_end = (
685
+ source.trim_end if settings.trim_end is None else settings.trim_end
686
+ )
687
+ if not 0 <= trim_start <= trim_end < source.frame_count:
688
+ raise HTTPException(400, "Invalid source trim interval")
689
+ if source.frame_count >= 8 and trim_end - trim_start + 1 < 8:
690
+ raise HTTPException(400, "Select at least eight 8-fps frames")
691
+ new_frame = (
692
+ source.current_frame
693
+ if settings.current_frame is None
694
+ else settings.current_frame
695
+ )
696
+ new_frame = min(source.frame_count - 1, max(0, new_frame))
697
+ path = source.video_path
698
+ frame_changed = new_frame != source.current_frame
699
+ trim_changed = (
700
+ trim_start != source.trim_start or trim_end != source.trim_end
701
+ )
702
+ trim_requested = (
703
+ settings.trim_start is not None or settings.trim_end is not None
704
+ )
705
+ selection_token = new_id("frame") if frame_changed else None
706
+ if frame_changed:
707
+ source.frame_selection_token = selection_token
708
+ frame = None
709
+ if frame_changed:
710
+ try:
711
+ frame = await _run_cpu(
712
+ runtime,
713
+ decode_video_frame,
714
+ path,
715
+ new_frame,
716
+ runtime.config.max_image_side,
717
+ )
718
+ except Exception as error:
719
+ raise HTTPException(400, str(error)) from error
720
+ with session.lock:
721
+ source = _asset(session, "source", source_id)
722
+ if frame_changed and source.frame_selection_token != selection_token:
723
+ raise HTTPException(409, "Superseded by a newer frame selection")
724
+ if trim_requested:
725
+ source.trim_start = trim_start
726
+ source.trim_end = trim_end
727
+ if trim_requested and trim_changed:
728
+ clear_excluded_source_mappings(session, source)
729
+ if frame_changed:
730
+ source.current_frame = new_frame
731
+ source.frame = frame
732
+ source.frame_selection_token = None
733
+ source.draft = None
734
+ source.prediction_token = None
735
+ source.hover_prediction_token = None
736
+ source.sam_status = "queued"
737
+ source.sam_error = None
738
+ if trim_requested and trim_changed:
739
+ session.touch()
740
+ revision = source.revision
741
+ if frame_changed:
742
+ runtime.scheduler.cancel_prefix(
743
+ f"sam:{_sam_prefix(session.id, 'source', source_id)}"
744
+ )
745
+ _queue_embedding(
746
+ runtime,
747
+ session,
748
+ "source",
749
+ source_id,
750
+ revision,
751
+ new_frame,
752
+ )
753
+ return _state(runtime, session)
754
+
755
+ @application.delete("/api/sessions/{session_id}/sources/{source_id}")
756
+ async def remove_source(session_id: str, source_id: str):
757
+ session = _session(runtime, session_id)
758
+ with session.lock:
759
+ if source_id not in session.sources:
760
+ raise HTTPException(404, f"Unknown source {source_id}")
761
+ source = session.sources.pop(source_id)
762
+ clear_source_mappings(session, source_id)
763
+ session.touch()
764
+ _release_asset(runtime, session.id, "source", source_id)
765
+ _clear_asset(source)
766
+ source.video_path.unlink(missing_ok=True)
767
+ return _state(runtime, session)
768
+
769
+ @application.post("/api/sessions/{session_id}/assets/{kind}/{asset_id}/predict")
770
+ async def predict_mask(
771
+ session_id: str, kind: str, asset_id: str, request: PromptRequest
772
+ ):
773
+ _validate_prompts(request)
774
+ session = _session(runtime, session_id)
775
+ with session.lock:
776
+ asset = _asset(session, kind, asset_id)
777
+ if asset.revision != request.asset_revision:
778
+ raise HTTPException(409, "The underlying media has changed")
779
+ frame_index = asset.current_frame if kind == "source" else None
780
+ if kind == "source" and request.frame_index != frame_index:
781
+ raise HTTPException(409, "The selected source frame has changed")
782
+ key = _sam_key(
783
+ session.id,
784
+ kind,
785
+ asset_id,
786
+ asset.revision,
787
+ frame_index,
788
+ )
789
+ prediction_token = new_id("prediction")
790
+ token_name = (
791
+ "hover_prediction_token" if request.transient else "prediction_token"
792
+ )
793
+ if not request.transient:
794
+ asset.draft = None
795
+ asset.hover_prediction_token = None
796
+ setattr(asset, token_name, prediction_token)
797
+
798
+ def run_prediction():
799
+ with session.lock:
800
+ try:
801
+ current = _asset(session, kind, asset_id)
802
+ except HTTPException as error:
803
+ raise Superseded("Media removed during mask prediction") from error
804
+ if (
805
+ session.closed
806
+ or current.revision != request.asset_revision
807
+ or (kind == "source" and current.current_frame != frame_index)
808
+ or getattr(current, token_name) != prediction_token
809
+ ):
810
+ raise Superseded("Media changed during mask prediction")
811
+ image = current.image if kind == "target" else current.frame
812
+ mask, score = runtime.sam.predict(
813
+ key,
814
+ image,
815
+ request.positive,
816
+ request.negative,
817
+ request.box,
818
+ )
819
+ with session.lock:
820
+ try:
821
+ current = _asset(session, kind, asset_id)
822
+ except HTTPException as error:
823
+ raise Superseded("Media removed during mask prediction") from error
824
+ if (
825
+ current.revision != request.asset_revision
826
+ or (kind == "source" and current.current_frame != frame_index)
827
+ or getattr(current, token_name) != prediction_token
828
+ ):
829
+ raise Superseded("Media changed during mask prediction")
830
+ current.sam_status = "ready"
831
+ current.sam_error = None
832
+ if not request.transient:
833
+ current.draft = Draft(
834
+ mask,
835
+ request.asset_revision,
836
+ request.prompt_revision,
837
+ frame_index,
838
+ )
839
+ return mask, score
840
+
841
+ try:
842
+ if runtime.config.device == "cpu":
843
+ mask, score = run_prediction()
844
+ else:
845
+ try:
846
+ if not request.transient:
847
+ runtime.scheduler.cancel_prefix(
848
+ f"sam:{_sam_prefix(session.id, kind, asset_id)}"
849
+ "prediction:hover"
850
+ )
851
+ _, future = runtime.scheduler.submit(
852
+ run_prediction,
853
+ priority=30 if request.transient else 0,
854
+ key=(
855
+ f"sam:{_sam_prefix(session.id, kind, asset_id)}"
856
+ f"prediction:{'hover' if request.transient else 'commit'}"
857
+ ),
858
+ owner=session.id,
859
+ label="Update mask draft",
860
+ )
861
+ except QueueFull as error:
862
+ raise HTTPException(503, str(error)) from error
863
+ mask, score = await asyncio.wrap_future(future)
864
+ except Superseded as error:
865
+ raise HTTPException(409, str(error)) from error
866
+ except Exception as error:
867
+ raise HTTPException(500, f"SAM prediction failed: {error}") from error
868
+ return {
869
+ "asset_revision": request.asset_revision,
870
+ "prompt_revision": request.prompt_revision,
871
+ "score": score,
872
+ "transient": request.transient,
873
+ "mask": base64.b64encode(mask_png_bytes(mask)).decode("ascii"),
874
+ }
875
+
876
+ @application.delete("/api/sessions/{session_id}/assets/{kind}/{asset_id}/draft")
877
+ async def clear_draft(
878
+ session_id: str,
879
+ kind: str,
880
+ asset_id: str,
881
+ revision: int,
882
+ ):
883
+ session = _session(runtime, session_id)
884
+ runtime.scheduler.cancel_prefix(
885
+ f"sam:{_sam_prefix(session.id, kind, asset_id)}prediction"
886
+ )
887
+ with session.lock:
888
+ asset = _asset(session, kind, asset_id)
889
+ if asset.revision == revision:
890
+ asset.draft = None
891
+ asset.prediction_token = None
892
+ asset.hover_prediction_token = None
893
+ return Response(status_code=204)
894
+
895
+ @application.post("/api/sessions/{session_id}/assets/{kind}/{asset_id}/masks")
896
+ async def commit_mask(
897
+ session_id: str, kind: str, asset_id: str, request: CommitRequest
898
+ ):
899
+ session = _session(runtime, session_id)
900
+ with session.lock:
901
+ asset = _asset(session, kind, asset_id)
902
+ draft = asset.draft
903
+ if (
904
+ draft is None
905
+ or draft.asset_revision != request.asset_revision
906
+ or draft.prompt_revision != request.prompt_revision
907
+ or draft.frame_index != request.frame_index
908
+ ):
909
+ raise HTTPException(409, "The mask draft is no longer current")
910
+ if not draft.data.any():
911
+ raise HTTPException(400, "Cannot add an empty mask")
912
+ mask_id = new_id("mask")
913
+ color = session.allocate_color() if kind == "source" else None
914
+ asset.masks[mask_id] = MaskRecord(
915
+ mask_id,
916
+ draft.data.copy(),
917
+ color=color,
918
+ frame_index=draft.frame_index if kind == "source" else None,
919
+ )
920
+ asset.draft = None
921
+ asset.prediction_token = None
922
+ asset.hover_prediction_token = None
923
+ asset.mask_revision += 1
924
+ session.touch()
925
+ return _state(runtime, session)
926
+
927
+ @application.delete(
928
+ "/api/sessions/{session_id}/assets/{kind}/{asset_id}/masks/{mask_id}"
929
+ )
930
+ async def remove_mask(session_id: str, kind: str, asset_id: str, mask_id: str):
931
+ session = _session(runtime, session_id)
932
+ with session.lock:
933
+ asset = _asset(session, kind, asset_id)
934
+ if mask_id not in asset.masks:
935
+ raise HTTPException(404, f"Unknown mask {mask_id}")
936
+ del asset.masks[mask_id]
937
+ asset.mask_revision += 1
938
+ if kind == "source":
939
+ clear_source_mappings(session, asset_id, mask_id)
940
+ session.touch()
941
+ return _state(runtime, session)
942
+
943
+ @application.put(
944
+ "/api/sessions/{session_id}/targets/{target_id}/masks/{target_mask_id}/mapping"
945
+ )
946
+ async def map_mask(
947
+ session_id: str,
948
+ target_id: str,
949
+ target_mask_id: str,
950
+ request: MappingRequest,
951
+ ):
952
+ session = _session(runtime, session_id)
953
+ with session.lock:
954
+ target = _asset(session, "target", target_id)
955
+ if target_mask_id not in target.masks:
956
+ raise HTTPException(404, f"Unknown target mask {target_mask_id}")
957
+ target_mask = target.masks[target_mask_id]
958
+ if request.source_id is None and request.source_mask_id is None:
959
+ target_mask.source_id = None
960
+ target_mask.source_mask_id = None
961
+ elif request.source_id is None or request.source_mask_id is None:
962
+ raise HTTPException(400, "Both source and source mask are required")
963
+ else:
964
+ try:
965
+ source_mask_lookup(
966
+ session, request.source_id, request.source_mask_id
967
+ )
968
+ except KeyError as error:
969
+ raise HTTPException(404, str(error)) from error
970
+ target_mask.source_id = request.source_id
971
+ target_mask.source_mask_id = request.source_mask_id
972
+ target.mask_revision += 1
973
+ session.touch()
974
+ return _state(runtime, session)
975
+
976
+ @application.post("/api/sessions/{session_id}/generate")
977
+ async def generate(session_id: str, request: GenerateRequest):
978
+ session = _session(runtime, session_id)
979
+ with session.lock:
980
+ if session.closed:
981
+ raise HTTPException(410, "The browser session has expired")
982
+ if session.active_generation is not None:
983
+ active = runtime.scheduler.get(session.active_generation)
984
+ if active is not None and active.status in {"queued", "running"}:
985
+ raise HTTPException(409, "This session is already generating")
986
+ session.active_generation = None
987
+ snapshot = _generation_snapshot(runtime, session, request)
988
+ input_revision = session.input_revision
989
+ previous_failure = session.failed_generation
990
+ session.failed_generation = None
991
+ if previous_failure is not None:
992
+ runtime.scheduler.discard(previous_failure)
993
+ output_path = snapshot.output_path
994
+ private_inputs = tuple(source.video_path for source in snapshot.sources)
995
+ task_id = new_id("task")
996
+
997
+ def report_progress(stage, current=None, total=None):
998
+ runtime.scheduler.update_progress(
999
+ task_id,
1000
+ stage,
1001
+ current,
1002
+ total,
1003
+ )
1004
+
1005
+ def run_generation():
1006
+ try:
1007
+ result = runtime.wan.generate(snapshot, report_progress)
1008
+ result["video_url"] = (
1009
+ f"/api/sessions/{session.id}/outputs/{result['output_name']}"
1010
+ )
1011
+ with session.lock:
1012
+ result["inputs_changed"] = (
1013
+ session.input_revision != input_revision
1014
+ )
1015
+ return result
1016
+ finally:
1017
+ for path in private_inputs:
1018
+ path.unlink(missing_ok=True)
1019
+
1020
+ try:
1021
+ task_id, future = runtime.scheduler.submit(
1022
+ run_generation,
1023
+ priority=10,
1024
+ key=f"generation:{session.id}",
1025
+ owner=session.id,
1026
+ label="Generate video",
1027
+ retain_record=True,
1028
+ task_id=task_id,
1029
+ )
1030
+ except (QueueFull, RuntimeError) as error:
1031
+ for path in private_inputs:
1032
+ path.unlink(missing_ok=True)
1033
+ output_path.unlink(missing_ok=True)
1034
+ raise HTTPException(503, str(error)) from error
1035
+ session.active_generation = task_id
1036
+
1037
+ def generation_finished(done):
1038
+ try:
1039
+ done.result()
1040
+ except Exception:
1041
+ succeeded = False
1042
+ else:
1043
+ succeeded = True
1044
+
1045
+ old_task = None
1046
+ old_output = None
1047
+ with session.lock:
1048
+ if session.active_generation == task_id:
1049
+ session.active_generation = None
1050
+ closed = session.closed
1051
+ if succeeded and not closed:
1052
+ old_task = session.latest_generation
1053
+ old_output = session.latest_output
1054
+ session.latest_generation = task_id
1055
+ session.latest_output = output_path
1056
+ elif not closed:
1057
+ old_task = session.failed_generation
1058
+ session.failed_generation = task_id
1059
+ if old_task is not None and old_task != task_id:
1060
+ runtime.scheduler.discard(old_task)
1061
+ if old_output is not None and old_output != output_path:
1062
+ old_output.unlink(missing_ok=True)
1063
+ if not succeeded or closed:
1064
+ output_path.unlink(missing_ok=True)
1065
+ if closed:
1066
+ runtime.scheduler.discard(task_id)
1067
+ _remove_session_directory(
1068
+ runtime.config.runtime_root,
1069
+ session.directory,
1070
+ )
1071
+
1072
+ future.add_done_callback(generation_finished)
1073
+ return {"task_id": task_id}
1074
+
1075
+ @application.delete("/api/sessions/{session_id}")
1076
+ async def delete_session(session_id: str):
1077
+ session = runtime.store.pop(session_id)
1078
+ if session is None:
1079
+ raise HTTPException(404, f"Unknown session {session_id}")
1080
+ runtime.dispose_session(session)
1081
+ return Response(status_code=204)
1082
+
1083
+ @application.get("/api/tasks/{task_id}")
1084
+ async def task_status(task_id: str):
1085
+ record = runtime.scheduler.get(task_id)
1086
+ if record is None:
1087
+ raise HTTPException(404, f"Unknown task {task_id}")
1088
+ return record.as_dict()
1089
+
1090
+ @application.get("/api/sessions/{session_id}/targets/{target_id}/image")
1091
+ async def target_image(session_id: str, target_id: str):
1092
+ session = _session(runtime, session_id)
1093
+ with session.lock:
1094
+ content = png_bytes(_asset(session, "target", target_id).image)
1095
+ return Response(
1096
+ content,
1097
+ media_type="image/png",
1098
+ headers={"Cache-Control": "no-store"},
1099
+ )
1100
+
1101
+ @application.get("/api/sessions/{session_id}/targets/{target_id}/thumbnail.png")
1102
+ async def target_thumbnail(session_id: str, target_id: str):
1103
+ session = _session(runtime, session_id)
1104
+ with session.lock:
1105
+ target = _asset(session, "target", target_id)
1106
+ masks = []
1107
+ for mask in target.masks.values():
1108
+ color = "#a8afb9"
1109
+ if mask.source_id and mask.source_mask_id:
1110
+ try:
1111
+ color = source_mask_lookup(
1112
+ session, mask.source_id, mask.source_mask_id
1113
+ ).color
1114
+ except KeyError:
1115
+ pass
1116
+ masks.append((mask.data, color))
1117
+ image = composite_masks(target.image, masks, max_size=(180, 120))
1118
+ return Response(
1119
+ png_bytes(image),
1120
+ media_type="image/png",
1121
+ headers={"Cache-Control": "no-store"},
1122
+ )
1123
+
1124
+ @application.get(
1125
+ "/api/sessions/{session_id}/targets/{target_id}/masks/{mask_id}.png"
1126
+ )
1127
+ async def target_mask_image(session_id: str, target_id: str, mask_id: str):
1128
+ session = _session(runtime, session_id)
1129
+ with session.lock:
1130
+ target = _asset(session, "target", target_id)
1131
+ if mask_id not in target.masks:
1132
+ raise HTTPException(404, f"Unknown target mask {mask_id}")
1133
+ content = mask_png_bytes(target.masks[mask_id].data)
1134
+ return Response(
1135
+ content,
1136
+ media_type="image/png",
1137
+ headers={"Cache-Control": "no-store"},
1138
+ )
1139
+
1140
+ @application.get("/api/sessions/{session_id}/sources/{source_id}/frame.png")
1141
+ async def source_frame(session_id: str, source_id: str, frame: int | None = None):
1142
+ session = _session(runtime, session_id)
1143
+ with session.lock:
1144
+ source = _asset(session, "source", source_id)
1145
+ requested = source.current_frame if frame is None else frame
1146
+ if not 0 <= requested < source.frame_count:
1147
+ raise HTTPException(404, f"Unknown frame {requested}")
1148
+ path = source.video_path
1149
+ cached = source.frame.copy() if requested == source.current_frame else None
1150
+ image = cached
1151
+ if image is None:
1152
+ image = await _run_cpu(
1153
+ runtime,
1154
+ decode_video_frame,
1155
+ path,
1156
+ requested,
1157
+ runtime.config.max_image_side,
1158
+ )
1159
+ return Response(
1160
+ png_bytes(image),
1161
+ media_type="image/png",
1162
+ headers={"Cache-Control": "no-store"},
1163
+ )
1164
+
1165
+ @application.get("/api/sessions/{session_id}/sources/{source_id}/video")
1166
+ async def source_video(session_id: str, source_id: str):
1167
+ session = _session(runtime, session_id)
1168
+ with session.lock:
1169
+ path = _asset(session, "source", source_id).video_path
1170
+ if not path.is_file():
1171
+ raise HTTPException(404, "Source video is unavailable")
1172
+ return FileResponse(
1173
+ path,
1174
+ media_type="video/mp4",
1175
+ headers={"Cache-Control": "no-store", "Accept-Ranges": "bytes"},
1176
+ )
1177
+
1178
+ @application.get(
1179
+ "/api/sessions/{session_id}/sources/{source_id}/frames/{frame_index}.jpg"
1180
+ )
1181
+ async def source_timeline_frame(session_id: str, source_id: str, frame_index: int):
1182
+ session = _session(runtime, session_id)
1183
+ with session.lock:
1184
+ source = _asset(session, "source", source_id)
1185
+ if not 0 <= frame_index < source.frame_count:
1186
+ raise HTTPException(404, f"Unknown frame {frame_index}")
1187
+ path = source.video_path
1188
+ image = await _run_cpu(
1189
+ runtime,
1190
+ decode_video_frame,
1191
+ path,
1192
+ frame_index,
1193
+ 256,
1194
+ )
1195
+ return Response(
1196
+ jpeg_bytes(image),
1197
+ media_type="image/jpeg",
1198
+ headers={"Cache-Control": "private, max-age=3600"},
1199
+ )
1200
+
1201
+ @application.get("/api/sessions/{session_id}/sources/{source_id}/thumbnail.png")
1202
+ async def source_thumbnail(session_id: str, source_id: str):
1203
+ session = _session(runtime, session_id)
1204
+ with session.lock:
1205
+ source = _asset(session, "source", source_id)
1206
+ frame_index = source.trim_start
1207
+ path = source.video_path
1208
+ masks = [
1209
+ (mask.data.copy(), mask.color)
1210
+ for mask in source.masks.values()
1211
+ if mask.frame_index == frame_index
1212
+ ]
1213
+ frame = await _run_cpu(runtime, decode_video_frame, path, frame_index, 320)
1214
+ image = composite_masks(frame, masks, max_size=(180, 120))
1215
+ return Response(
1216
+ png_bytes(image),
1217
+ media_type="image/png",
1218
+ headers={"Cache-Control": "no-store"},
1219
+ )
1220
+
1221
+ @application.get(
1222
+ "/api/sessions/{session_id}/sources/{source_id}/masks/{mask_id}.png"
1223
+ )
1224
+ async def source_mask_image(session_id: str, source_id: str, mask_id: str):
1225
+ session = _session(runtime, session_id)
1226
+ with session.lock:
1227
+ source = _asset(session, "source", source_id)
1228
+ if mask_id not in source.masks:
1229
+ raise HTTPException(404, f"Unknown mask {mask_id}")
1230
+ content = mask_png_bytes(source.masks[mask_id].data)
1231
+ return Response(
1232
+ content,
1233
+ media_type="image/png",
1234
+ headers={"Cache-Control": "no-store"},
1235
+ )
1236
+
1237
+ @application.get(
1238
+ "/api/sessions/{session_id}/sources/{source_id}/masks/{mask_id}/preview.png"
1239
+ )
1240
+ async def source_mask_preview(session_id: str, source_id: str, mask_id: str):
1241
+ session = _session(runtime, session_id)
1242
+ with session.lock:
1243
+ source = _asset(session, "source", source_id)
1244
+ if mask_id not in source.masks:
1245
+ raise HTTPException(404, f"Unknown mask {mask_id}")
1246
+ mask = source.masks[mask_id]
1247
+ frame_index = mask.frame_index
1248
+ path = source.video_path
1249
+ mask_data = mask.data.copy()
1250
+ color = mask.color
1251
+ if frame_index is None:
1252
+ raise HTTPException(409, "Source mask has no reference frame")
1253
+ frame = await _run_cpu(runtime, decode_video_frame, path, frame_index, 320)
1254
+ image = composite_masks(frame, [(mask_data, color)], max_size=(160, 100))
1255
+ return Response(
1256
+ png_bytes(image),
1257
+ media_type="image/png",
1258
+ headers={"Cache-Control": "no-store"},
1259
+ )
1260
+
1261
+ @application.get("/api/sessions/{session_id}/outputs/{name}")
1262
+ async def output_video(session_id: str, name: str):
1263
+ session = _session(runtime, session_id)
1264
+ safe_name = Path(name).name
1265
+ with session.lock:
1266
+ path = session.latest_output
1267
+ if safe_name != name or path is None or path.name != name or not path.is_file():
1268
+ raise HTTPException(404, "Unknown output")
1269
+ return FileResponse(
1270
+ path,
1271
+ media_type="video/mp4",
1272
+ headers={"Cache-Control": "no-store"},
1273
+ )
1274
+
1275
+ return application
app/state.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory browser-session state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ import threading
10
+ import time
11
+ from typing import Any
12
+ from uuid import uuid4
13
+
14
+ import numpy as np
15
+
16
+ MASK_COLORS = (
17
+ "#E69F00",
18
+ "#56B4E9",
19
+ "#009E73",
20
+ "#F0E442",
21
+ "#0072B2",
22
+ "#D55E00",
23
+ "#CC79A7",
24
+ "#7A6FF0",
25
+ "#32B8A6",
26
+ "#EF6C96",
27
+ "#8AAE3D",
28
+ "#C98932",
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class MaskRecord:
34
+ id: str
35
+ data: np.ndarray
36
+ color: str | None = None
37
+ source_id: str | None = None
38
+ source_mask_id: str | None = None
39
+ frame_index: int | None = None
40
+
41
+
42
+ @dataclass
43
+ class Draft:
44
+ data: np.ndarray
45
+ asset_revision: int
46
+ prompt_revision: int
47
+ frame_index: int | None = None
48
+
49
+
50
+ @dataclass
51
+ class TargetAsset:
52
+ id: str
53
+ name: str
54
+ image: np.ndarray
55
+ revision: int = 1
56
+ sam_status: str = "queued"
57
+ sam_error: str | None = None
58
+ masks: OrderedDict[str, MaskRecord] = field(default_factory=OrderedDict)
59
+ draft: Draft | None = None
60
+ prediction_token: str | None = None
61
+ hover_prediction_token: str | None = None
62
+ mask_revision: int = 0
63
+ example: bool = False
64
+
65
+
66
+ @dataclass
67
+ class SourceAsset:
68
+ id: str
69
+ name: str
70
+ video_path: Path
71
+ frame: np.ndarray
72
+ fps: float
73
+ frame_count: int
74
+ original_fps: float
75
+ current_frame: int = 0
76
+ trim_start: int = 0
77
+ trim_end: int = 0
78
+ revision: int = 1
79
+ sam_status: str = "queued"
80
+ sam_error: str | None = None
81
+ masks: OrderedDict[str, MaskRecord] = field(default_factory=OrderedDict)
82
+ draft: Draft | None = None
83
+ prediction_token: str | None = None
84
+ hover_prediction_token: str | None = None
85
+ frame_selection_token: str | None = None
86
+ mask_revision: int = 0
87
+ example: bool = False
88
+
89
+
90
+ @dataclass
91
+ class Session:
92
+ id: str
93
+ directory: Path
94
+ targets: OrderedDict[str, TargetAsset] = field(default_factory=OrderedDict)
95
+ selected_target_id: str | None = None
96
+ sources: OrderedDict[str, SourceAsset] = field(default_factory=OrderedDict)
97
+ next_color: int = 0
98
+ target_revision: int = 0
99
+ active_generation: str | None = None
100
+ latest_generation: str | None = None
101
+ failed_generation: str | None = None
102
+ latest_output: Path | None = None
103
+ input_revision: int = 0
104
+ created_at: float = field(default_factory=time.time)
105
+ accessed_at: float = field(default_factory=time.time)
106
+ closed: bool = False
107
+ lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
108
+
109
+ @property
110
+ def selected_target(self) -> TargetAsset | None:
111
+ if self.selected_target_id is None:
112
+ return None
113
+ return self.targets.get(self.selected_target_id)
114
+
115
+ def allocate_color(self) -> str:
116
+ if self.next_color < len(MASK_COLORS):
117
+ color = MASK_COLORS[self.next_color]
118
+ else:
119
+ hue = (0.13 + self.next_color * 0.61803398875) % 1.0
120
+ rgb = colorsys.hsv_to_rgb(hue, 0.68, 0.94)
121
+ color = "#" + "".join(f"{round(channel * 255):02X}" for channel in rgb)
122
+ self.next_color += 1
123
+ return color
124
+
125
+ def touch(self) -> None:
126
+ self.input_revision += 1
127
+ self.accessed_at = time.time()
128
+
129
+ def access(self) -> None:
130
+ self.accessed_at = time.time()
131
+
132
+
133
+ class SessionStore:
134
+ def __init__(self, root: Path) -> None:
135
+ self.root = root
136
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
137
+ self.root.chmod(0o700)
138
+ self._sessions: dict[str, Session] = {}
139
+ self._lock = threading.RLock()
140
+
141
+ def create(self) -> Session:
142
+ session_id = uuid4().hex
143
+ directory = self.root / session_id
144
+ directory.mkdir(parents=True, exist_ok=False, mode=0o700)
145
+ session = Session(session_id, directory)
146
+ with self._lock:
147
+ self._sessions[session_id] = session
148
+ return session
149
+
150
+ def get(self, session_id: str) -> Session:
151
+ with self._lock:
152
+ try:
153
+ session = self._sessions[session_id]
154
+ except KeyError as error:
155
+ raise KeyError(f"Unknown session {session_id}") from error
156
+ session.access()
157
+ return session
158
+
159
+ def pop(self, session_id: str) -> Session | None:
160
+ with self._lock:
161
+ return self._sessions.pop(session_id, None)
162
+
163
+ def pop_expired(self, ttl_seconds: float) -> list[Session]:
164
+ cutoff = time.time() - float(ttl_seconds)
165
+ with self._lock:
166
+ expired = [
167
+ session
168
+ for session in self._sessions.values()
169
+ if session.accessed_at < cutoff
170
+ ]
171
+ for session in expired:
172
+ self._sessions.pop(session.id, None)
173
+ return expired
174
+
175
+ def pop_all(self) -> list[Session]:
176
+ with self._lock:
177
+ sessions = list(self._sessions.values())
178
+ self._sessions.clear()
179
+ return sessions
180
+
181
+
182
+ def new_id(prefix: str) -> str:
183
+ return f"{prefix}_{uuid4().hex[:10]}"
184
+
185
+
186
+ def clear_source_mappings(
187
+ session: Session, source_id: str, mask_id: str | None = None
188
+ ) -> None:
189
+ for target in session.targets.values():
190
+ changed = False
191
+ for target_mask in target.masks.values():
192
+ if target_mask.source_id != source_id:
193
+ continue
194
+ if mask_id is None or target_mask.source_mask_id == mask_id:
195
+ target_mask.source_id = None
196
+ target_mask.source_mask_id = None
197
+ changed = True
198
+ if changed:
199
+ target.mask_revision += 1
200
+
201
+
202
+ def clear_excluded_source_mappings(session: Session, source: SourceAsset) -> int:
203
+ """Clear mappings whose source observation falls outside the trim."""
204
+ excluded = {
205
+ mask.id
206
+ for mask in source.masks.values()
207
+ if mask.frame_index is None
208
+ or not source.trim_start <= mask.frame_index <= source.trim_end
209
+ }
210
+ changed = 0
211
+ for target in session.targets.values():
212
+ target_changed = False
213
+ for mask in target.masks.values():
214
+ if mask.source_id == source.id and mask.source_mask_id in excluded:
215
+ mask.source_id = None
216
+ mask.source_mask_id = None
217
+ changed += 1
218
+ target_changed = True
219
+ if target_changed:
220
+ target.mask_revision += 1
221
+ return changed
222
+
223
+
224
+ def source_mask_lookup(session: Session, source_id: str, mask_id: str) -> MaskRecord:
225
+ try:
226
+ return session.sources[source_id].masks[mask_id]
227
+ except KeyError as error:
228
+ raise KeyError(f"Unknown source mask {source_id}/{mask_id}") from error
229
+
230
+
231
+ def session_payload(session: Session) -> dict[str, Any]:
232
+ def mask_payload(mask: MaskRecord, url: str, preview_url: str | None = None):
233
+ payload = {
234
+ "id": mask.id,
235
+ "color": mask.color,
236
+ "source_id": mask.source_id,
237
+ "source_mask_id": mask.source_mask_id,
238
+ "frame_index": mask.frame_index,
239
+ "url": url,
240
+ }
241
+ if preview_url:
242
+ payload["preview_url"] = preview_url
243
+ return payload
244
+
245
+ targets = []
246
+ for target in session.targets.values():
247
+ base = f"/api/sessions/{session.id}/targets/{target.id}"
248
+ targets.append(
249
+ {
250
+ "id": target.id,
251
+ "name": target.name,
252
+ "revision": target.revision,
253
+ "width": int(target.image.shape[1]),
254
+ "height": int(target.image.shape[0]),
255
+ "sam_status": target.sam_status,
256
+ "sam_error": target.sam_error,
257
+ "example": target.example,
258
+ "image_url": f"{base}/image?v={target.revision}",
259
+ "thumbnail_url": (
260
+ f"{base}/thumbnail.png?v={target.revision}-{target.mask_revision}"
261
+ ),
262
+ "masks": [
263
+ mask_payload(
264
+ mask,
265
+ f"{base}/masks/{mask.id}.png?v={target.mask_revision}",
266
+ )
267
+ for mask in target.masks.values()
268
+ ],
269
+ }
270
+ )
271
+
272
+ sources = []
273
+ for source in session.sources.values():
274
+ base = f"/api/sessions/{session.id}/sources/{source.id}"
275
+ sources.append(
276
+ {
277
+ "id": source.id,
278
+ "name": source.name,
279
+ "revision": source.revision,
280
+ "width": int(source.frame.shape[1]),
281
+ "height": int(source.frame.shape[0]),
282
+ "fps": source.fps,
283
+ "original_fps": source.original_fps,
284
+ "frame_count": source.frame_count,
285
+ "current_frame": source.current_frame,
286
+ "trim_start": source.trim_start,
287
+ "trim_end": source.trim_end,
288
+ "sam_status": source.sam_status,
289
+ "sam_error": source.sam_error,
290
+ "example": source.example,
291
+ "image_url": (
292
+ f"{base}/frame.png?frame={source.current_frame}&v={source.revision}"
293
+ ),
294
+ "video_url": f"{base}/video?v={source.revision}",
295
+ "thumbnail_url": f"{base}/thumbnail.png?v={source.revision}-{source.mask_revision}",
296
+ "masks": [
297
+ mask_payload(
298
+ mask,
299
+ f"{base}/masks/{mask.id}.png?v={source.mask_revision}",
300
+ f"{base}/masks/{mask.id}/preview.png?v={source.mask_revision}",
301
+ )
302
+ for mask in source.masks.values()
303
+ ],
304
+ }
305
+ )
306
+ return {
307
+ "id": session.id,
308
+ "targets": targets,
309
+ "selected_target_id": session.selected_target_id,
310
+ "sources": sources,
311
+ "active_generation": session.active_generation,
312
+ "latest_generation": session.latest_generation,
313
+ "input_revision": session.input_revision,
314
+ }
app/static/app.js ADDED
@@ -0,0 +1,1389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MaskCanvas } from "/static/mask-canvas.js?v=20260904h";
2
+ import { VideoTimeline } from "/static/video-timeline.js?v=20260904h";
3
+ import { Client, handle_file } from "/static/gradio-client.js?v=20260904j";
4
+
5
+ const $ = (id) => document.getElementById(id);
6
+ const elements = {
7
+ startOver: $("startOver"),
8
+ sourceRail: $("sourceRail"),
9
+ targetRail: $("targetRail"),
10
+ sourceFile: $("sourceFile"),
11
+ targetFile: $("targetFile"),
12
+ removeSource: $("removeSource"),
13
+ removeTarget: $("removeTarget"),
14
+ sourceRailUpload: $("sourceRailUpload"),
15
+ sourceEmpty: $("sourceEmpty"),
16
+ targetEmpty: $("targetEmpty"),
17
+ sourceTools: $("sourceTools"),
18
+ targetTools: $("targetTools"),
19
+ sourceMaskMode: $("sourceMaskMode"),
20
+ targetMaskMode: $("targetMaskMode"),
21
+ sourceModeBadge: $("sourceModeBadge"),
22
+ targetModeBadge: $("targetModeBadge"),
23
+ sourceCanvasStatus: $("sourceCanvasStatus"),
24
+ targetCanvasStatus: $("targetCanvasStatus"),
25
+ sourceLoader: $("sourceLoader"),
26
+ targetLoader: $("targetLoader"),
27
+ sourceAddMask: $("sourceAddMask"),
28
+ targetAddMask: $("targetAddMask"),
29
+ sourceCancelDraft: $("sourceCancelDraft"),
30
+ targetCancelDraft: $("targetCancelDraft"),
31
+ sourceSettings: $("sourceSettings"),
32
+ sourceVideo: $("sourceVideo"),
33
+ sourcePlay: $("sourcePlay"),
34
+ sourceTime: $("sourceTime"),
35
+ sourceTimeline: $("sourceTimeline"),
36
+ contextMenu: $("contextMenu"),
37
+ dragGhost: $("dragGhost"),
38
+ mappingHint: $("mappingHint"),
39
+ toast: $("toast"),
40
+ prompt: $("prompt"),
41
+ negativePrompt: $("negativePrompt"),
42
+ guidanceMode: $("guidanceMode"),
43
+ steps: $("steps"),
44
+ seed: $("seed"),
45
+ textGuidance: $("textGuidance"),
46
+ motionGuidance: $("motionGuidance"),
47
+ loraScale: $("loraScale"),
48
+ generateButton: $("generateButton"),
49
+ generateButtonProgress: $("generateButtonProgress"),
50
+ generateButtonLabel: $("generateButtonLabel"),
51
+ generateButtonCount: $("generateButtonCount"),
52
+ generateHint: $("generateHint"),
53
+ generatedRail: $("generatedRail"),
54
+ resultPanel: $("resultPanel"),
55
+ resultVideo: $("resultVideo"),
56
+ resultLoader: $("resultLoader"),
57
+ resultMeta: $("resultMeta"),
58
+ resultClose: $("resultClose"),
59
+ };
60
+
61
+ let sessionState = null;
62
+ let selectedSourceId = null;
63
+ let toastTimer = null;
64
+ let pollTimer = null;
65
+ let activeGeneration = null;
66
+ let activeGenerationForm = null;
67
+ let activeGenerationStatus = null;
68
+ let frameController = null;
69
+ let draggedMaskId = null;
70
+ let mappingHintTimer = null;
71
+ let exampleTutorialState = "pending";
72
+ let exampleTutorialScheduled = false;
73
+ let formTargetId = null;
74
+ let settingsDirty = false;
75
+ let settingsSaveTimer = null;
76
+ let targetSettingsSessionId = null;
77
+ let targetSettings = {};
78
+ let generatedOutputs = [];
79
+ const predictionControllers = { source: null, target: null };
80
+ const hoverControllers = { source: null, target: null };
81
+ const hoverTimers = { source: null, target: null };
82
+ const loadingReasons = { source: new Set(), target: new Set(), result: new Set() };
83
+
84
+ function setMediaLoading(kind, reason, active) {
85
+ const reasons = loadingReasons[kind];
86
+ const loader = kind === "source"
87
+ ? elements.sourceLoader
88
+ : kind === "target"
89
+ ? elements.targetLoader
90
+ : elements.resultLoader;
91
+ if (active) reasons.add(reason);
92
+ else reasons.delete(reason);
93
+ loader.classList.toggle("hidden", reasons.size === 0);
94
+ }
95
+
96
+ let gradioClientPromise = null;
97
+
98
+ function getGradioClient() {
99
+ if (!gradioClientPromise) {
100
+ gradioClientPromise = Client.connect(new URL("/gradio", window.location.origin).href, {
101
+ events: ["data", "status"],
102
+ }).catch((error) => {
103
+ gradioClientPromise = null;
104
+ throw error;
105
+ });
106
+ }
107
+ return gradioClientPromise;
108
+ }
109
+
110
+ async function request(url, options = {}) {
111
+ const method = options.method || "GET";
112
+ const signal = options.signal;
113
+ if (signal?.aborted) throw new DOMException("Request aborted", "AbortError");
114
+
115
+ let body = null;
116
+ let upload = null;
117
+ if (options.body instanceof FormData) {
118
+ const file = options.body.get("file");
119
+ if (file instanceof File) upload = handle_file(file);
120
+ } else if (typeof options.body === "string") {
121
+ body = options.body;
122
+ }
123
+
124
+ const endpoint = url.includes("/predict")
125
+ ? "/sam_api"
126
+ : url.split("?", 1)[0].endsWith("/generate")
127
+ ? "/generation_api"
128
+ : "/api";
129
+ const client = await getGradioClient();
130
+ const inputs = [method, url, body, upload];
131
+ if (endpoint === "/api") {
132
+ const result = await client.predict(endpoint, inputs);
133
+ let envelope = result.data?.[0] ?? null;
134
+ if (typeof envelope === "string") {
135
+ try { envelope = JSON.parse(envelope); } catch (_) { /* Keep the server value for the error below. */ }
136
+ }
137
+ if (!envelope?.ok) {
138
+ const payload = envelope?.data;
139
+ const message = payload?.detail || payload || `Request failed (${envelope?.status || "unknown"})`;
140
+ const error = new Error(message);
141
+ error.status = envelope?.status;
142
+ throw error;
143
+ }
144
+ return envelope.data;
145
+ }
146
+ const submission = client.submit(endpoint, inputs);
147
+ const abort = () => submission.cancel();
148
+ signal?.addEventListener("abort", abort, { once: true });
149
+
150
+ let envelope = null;
151
+ try {
152
+ for await (const message of submission) {
153
+ if (signal?.aborted) throw new DOMException("Request aborted", "AbortError");
154
+ if (message.type === "status") {
155
+ options.onGradioStatus?.(message);
156
+ if (message.stage === "error") {
157
+ throw new Error(typeof message.message === "string" ? message.message : "Gradio request failed");
158
+ }
159
+ } else if (message.type === "data") {
160
+ envelope = message.data?.[0] ?? null;
161
+ }
162
+ }
163
+ } finally {
164
+ signal?.removeEventListener("abort", abort);
165
+ }
166
+
167
+ if (typeof envelope === "string") {
168
+ try { envelope = JSON.parse(envelope); } catch (_) { /* Gradio may already return an object. */ }
169
+ }
170
+ if (!envelope?.ok) {
171
+ const payload = envelope?.data;
172
+ const message = payload?.detail || payload || `Request failed (${envelope?.status || "unknown"})`;
173
+ const error = new Error(message);
174
+ error.status = envelope?.status;
175
+ throw error;
176
+ }
177
+ return envelope.data;
178
+ }
179
+
180
+ function jsonOptions(value, method = "POST") {
181
+ return {
182
+ method,
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify(value),
185
+ };
186
+ }
187
+
188
+ function showToast(message) {
189
+ clearTimeout(toastTimer);
190
+ elements.toast.textContent = message;
191
+ elements.toast.classList.remove("hidden");
192
+ toastTimer = setTimeout(() => elements.toast.classList.add("hidden"), 4200);
193
+ }
194
+
195
+ function canvasStatus(kind, message = "", error = false) {
196
+ const element = kind === "source" ? elements.sourceCanvasStatus : elements.targetCanvasStatus;
197
+ element.textContent = message;
198
+ element.style.color = error ? "#ff9a9c" : "";
199
+ element.classList.toggle("hidden", !message);
200
+ }
201
+
202
+ function modeChanged(kind, active, pinned) {
203
+ const badge = kind === "source" ? elements.sourceModeBadge : elements.targetModeBadge;
204
+ const button = kind === "source" ? elements.sourceMaskMode : elements.targetMaskMode;
205
+ badge.classList.toggle("hidden", !active);
206
+ button.classList.toggle("active", pinned);
207
+ button.textContent = pinned ? "Done masking" : "Start masking";
208
+ }
209
+
210
+ function draftChanged(kind, ready) {
211
+ const add = kind === "source" ? elements.sourceAddMask : elements.targetAddMask;
212
+ const cancel = kind === "source" ? elements.sourceCancelDraft : elements.targetCancelDraft;
213
+ add.classList.toggle("hidden", !ready);
214
+ cancel.classList.toggle("hidden", !ready);
215
+ }
216
+
217
+ const sourceCanvas = new MaskCanvas({
218
+ host: $("sourceCanvasHost"),
219
+ canvas: $("sourceCanvas"),
220
+ role: "source",
221
+ callbacks: {
222
+ onError: (error) => showToast(error.message),
223
+ onLoading: (active) => setMediaLoading("source", "canvas", active),
224
+ onPromptsChanged: (payload) => predictMask("source", payload),
225
+ onHoverPrompt: (payload) => scheduleHover("source", payload),
226
+ onDraftState: (ready) => draftChanged("source", ready),
227
+ onModeChange: (active, pinned) => modeChanged("source", active, pinned),
228
+ onMaskClick: (masks, x, y) => openSourceMenu(masks, x, y),
229
+ onMappingDrag: (phase, mask, x, y) => mappingDrag(phase, mask, x, y),
230
+ },
231
+ });
232
+
233
+ const targetCanvas = new MaskCanvas({
234
+ host: $("targetCanvasHost"),
235
+ canvas: $("targetCanvas"),
236
+ role: "target",
237
+ callbacks: {
238
+ onError: (error) => showToast(error.message),
239
+ onLoading: (active) => setMediaLoading("target", "canvas", active),
240
+ onPromptsChanged: (payload) => predictMask("target", payload),
241
+ onHoverPrompt: (payload) => scheduleHover("target", payload),
242
+ onDraftState: (ready) => draftChanged("target", ready),
243
+ onModeChange: (active, pinned) => modeChanged("target", active, pinned),
244
+ onMaskClick: (masks, x, y) => openTargetMenu(masks, x, y),
245
+ },
246
+ });
247
+
248
+ const sourceTimeline = new VideoTimeline({
249
+ root: elements.sourceTimeline,
250
+ video: elements.sourceVideo,
251
+ playButton: elements.sourcePlay,
252
+ time: elements.sourceTime,
253
+ callbacks: {
254
+ onSeek: (frame, final) => selectSourceFrame(frame, final),
255
+ onTrim: (start, end) => updateSourceTrim(start, end),
256
+ onMaskClick: () => sourceCanvas.setPinned(false),
257
+ onLoading: (active) => setMediaLoading(
258
+ "source",
259
+ "video",
260
+ active && $("sourceCanvasHost").classList.contains("playing"),
261
+ ),
262
+ onPlaybackChange: (playing) => {
263
+ $("sourceCanvasHost").classList.toggle("playing", playing);
264
+ if (!playing) setMediaLoading("source", "video", false);
265
+ if (playing) {
266
+ sourceCanvas.setPinned(false);
267
+ clearCanvasDraft("source");
268
+ }
269
+ },
270
+ },
271
+ });
272
+
273
+ ["loadstart", "waiting", "seeking"].forEach((eventName) => {
274
+ elements.resultVideo.addEventListener(eventName, () => setMediaLoading("result", "video", true));
275
+ });
276
+ ["loadeddata", "canplay", "playing", "seeked", "emptied", "error"].forEach((eventName) => {
277
+ elements.resultVideo.addEventListener(eventName, () => setMediaLoading("result", "video", false));
278
+ });
279
+
280
+ function selectedSource() {
281
+ return sessionState?.sources.find((source) => source.id === selectedSourceId) || null;
282
+ }
283
+
284
+ function selectedTarget() {
285
+ return sessionState?.targets.find((target) => target.id === sessionState.selected_target_id) || null;
286
+ }
287
+
288
+ function sourceMaskEntries() {
289
+ const entries = [];
290
+ (sessionState?.sources || []).forEach((source, sourceIndex) => {
291
+ source.masks.filter((mask) => mask.frame_index >= source.trim_start && mask.frame_index <= source.trim_end).forEach((mask, maskIndex) => {
292
+ entries.push({
293
+ ...mask,
294
+ source,
295
+ sourceLabel: `S${sourceIndex + 1}`,
296
+ maskLabel: `M${maskIndex + 1}`,
297
+ });
298
+ });
299
+ });
300
+ return entries;
301
+ }
302
+
303
+ function mappedColor(targetMask) {
304
+ const found = sourceMaskEntries().find(
305
+ (mask) => mask.source.id === targetMask.source_id && mask.id === targetMask.source_mask_id,
306
+ );
307
+ return found?.color || "#a8afb9";
308
+ }
309
+
310
+ function isExampleTutorial(target = selectedTarget()) {
311
+ return Boolean(
312
+ target?.example &&
313
+ sessionState?.sources.some((source) => source.example) &&
314
+ target.masks.some((mask) => mask.source_id && mask.source_mask_id)
315
+ );
316
+ }
317
+
318
+ function generationSettingsPayload() {
319
+ const numberValue = (element, fallback, integer = false) => {
320
+ const value = integer ? Number.parseInt(element.value, 10) : Number.parseFloat(element.value);
321
+ return Number.isFinite(value) ? value : fallback;
322
+ };
323
+ return {
324
+ prompt: elements.prompt.value,
325
+ negative_prompt: elements.negativePrompt.value || null,
326
+ steps: numberValue(elements.steps, 40, true),
327
+ seed: numberValue(elements.seed, 42, true),
328
+ guidance_mode: elements.guidanceMode.value,
329
+ text_guidance_scale: numberValue(elements.textGuidance, 3.5),
330
+ motion_guidance_scale: numberValue(elements.motionGuidance, 1.0),
331
+ lora_scale: numberValue(elements.loraScale, 1.0),
332
+ };
333
+ }
334
+
335
+ function defaultGenerationSettings() {
336
+ return {
337
+ prompt: "A bear and a dog together in the forest.",
338
+ negative_prompt: null,
339
+ steps: 40,
340
+ seed: 42,
341
+ guidance_mode: "text_cfg",
342
+ text_guidance_scale: 3.5,
343
+ motion_guidance_scale: 1.0,
344
+ lora_scale: 1.0,
345
+ };
346
+ }
347
+
348
+ function targetSettingsStorageKey(sessionId = targetSettingsSessionId) {
349
+ return `whatmoves-target-settings-${sessionId}`;
350
+ }
351
+
352
+ function loadTargetSettings(sessionId) {
353
+ targetSettingsSessionId = sessionId;
354
+ try {
355
+ targetSettings = JSON.parse(sessionStorage.getItem(targetSettingsStorageKey(sessionId)) || "{}");
356
+ } catch (_) {
357
+ targetSettings = {};
358
+ }
359
+ }
360
+
361
+ function persistTargetSettings() {
362
+ if (!targetSettingsSessionId) return;
363
+ sessionStorage.setItem(targetSettingsStorageKey(), JSON.stringify(targetSettings));
364
+ }
365
+
366
+ function restoreGenerationSettings(target) {
367
+ if (!target) return;
368
+ const settings = targetSettings[target.id] || defaultGenerationSettings();
369
+ elements.prompt.value = settings.prompt ?? "";
370
+ elements.negativePrompt.value = settings.negative_prompt ?? "";
371
+ elements.steps.value = settings.steps ?? 40;
372
+ elements.seed.value = settings.seed ?? 42;
373
+ elements.guidanceMode.value = settings.guidance_mode ?? "text_cfg";
374
+ elements.textGuidance.value = settings.text_guidance_scale ?? 3.5;
375
+ elements.motionGuidance.value = settings.motion_guidance_scale ?? 1.0;
376
+ elements.loraScale.value = settings.lora_scale ?? 1.0;
377
+ settingsDirty = false;
378
+ }
379
+
380
+ async function flushTargetSettings() {
381
+ clearTimeout(settingsSaveTimer);
382
+ settingsSaveTimer = null;
383
+ if (!settingsDirty || !sessionState || !formTargetId) return true;
384
+ targetSettings[formTargetId] = generationSettingsPayload();
385
+ persistTargetSettings();
386
+ settingsDirty = false;
387
+ return true;
388
+ }
389
+
390
+ function scheduleTargetSettingsSave() {
391
+ if (!formTargetId) return;
392
+ settingsDirty = true;
393
+ clearTimeout(settingsSaveTimer);
394
+ settingsSaveTimer = setTimeout(flushTargetSettings, 450);
395
+ }
396
+
397
+ async function applyState(next) {
398
+ if (targetSettingsSessionId !== next.id) loadTargetSettings(next.id);
399
+ sessionState = next;
400
+ if (!activeGeneration && next.active_generation) {
401
+ activeGeneration = next.active_generation;
402
+ activeGenerationForm = null;
403
+ activeGenerationStatus = "queued";
404
+ pollGeneration(activeGeneration);
405
+ }
406
+ if (!next.sources.some((source) => source.id === selectedSourceId)) {
407
+ selectedSourceId = next.sources[0]?.id || null;
408
+ }
409
+ renderSourceRail();
410
+ renderTargetRail();
411
+ renderGeneratedRail();
412
+
413
+ const source = selectedSource();
414
+ elements.sourceTools.classList.toggle("hidden", !source);
415
+ elements.sourceSettings.classList.toggle("hidden", !source);
416
+ elements.removeSource.classList.toggle("hidden", !source);
417
+ elements.sourceEmpty.classList.toggle("hidden", Boolean(source));
418
+ if (source) {
419
+ source.session_id = next.id;
420
+ const masks = source.masks.filter((mask) => mask.frame_index === source.current_frame)
421
+ .map((mask) => ({ ...mask, displayColor: mask.color }));
422
+ await sourceCanvas.setAsset(source, masks);
423
+ sourceTimeline.setSource(source);
424
+ assetStatus("source", source);
425
+ } else {
426
+ await sourceCanvas.setAsset(null);
427
+ sourceTimeline.setSource(null);
428
+ canvasStatus("source");
429
+ }
430
+
431
+ const target = selectedTarget();
432
+ if (target?.id !== formTargetId) {
433
+ formTargetId = target?.id || null;
434
+ restoreGenerationSettings(target);
435
+ }
436
+ elements.targetTools.classList.toggle("hidden", !target);
437
+ elements.removeTarget.classList.toggle("hidden", !target);
438
+ elements.targetEmpty.classList.toggle("hidden", Boolean(target));
439
+ if (target) {
440
+ const hideExampleAssignments = exampleTutorialState !== "complete" && isExampleTutorial(target);
441
+ const masks = target.masks.map((mask) => ({
442
+ ...mask,
443
+ displayColor: hideExampleAssignments ? "#a8afb9" : mappedColor(mask),
444
+ }));
445
+ await targetCanvas.setAsset(target, masks);
446
+ assetStatus("target", target);
447
+ } else {
448
+ await targetCanvas.setAsset(null);
449
+ canvasStatus("target");
450
+ }
451
+
452
+ if (!source || (elements.sourceVideo.paused && !sourceTimeline.drag)) {
453
+ $("sourceCanvasHost").classList.remove("playing");
454
+ }
455
+
456
+ renderModelStatus();
457
+ updateGenerateState();
458
+ scheduleStatePoll();
459
+ scheduleMappingHint();
460
+ }
461
+
462
+ function renderModelStatus() {
463
+ // Model state is reflected by the generation control and the page content.
464
+ }
465
+
466
+ function assetStatus(kind, asset) {
467
+ if (asset.sam_status === "queued") canvasStatus(kind, "Preparing mask features…");
468
+ else if (asset.sam_status === "error") canvasStatus(kind, asset.sam_error || "SAM failed", true);
469
+ else canvasStatus(kind);
470
+ }
471
+
472
+ function renderSourceRail() {
473
+ elements.sourceRail.replaceChildren();
474
+ sessionState.sources.forEach((source, index) => {
475
+ const button = document.createElement("button");
476
+ button.className = `source-tile${source.id === selectedSourceId ? " selected" : ""}`;
477
+ button.setAttribute("aria-label", `Select source video ${index + 1}`);
478
+ const image = document.createElement("img");
479
+ image.src = source.thumbnail_url;
480
+ image.alt = "";
481
+ button.append(image);
482
+ button.addEventListener("click", async () => {
483
+ if (source.id === selectedSourceId) return;
484
+ await clearCanvasDraft("source");
485
+ selectedSourceId = source.id;
486
+ await applyState(sessionState);
487
+ });
488
+ elements.sourceRail.append(button);
489
+ });
490
+ const upload = document.createElement("button");
491
+ upload.className = "source-upload-tile";
492
+ upload.title = "Upload source video";
493
+ upload.textContent = "+";
494
+ upload.addEventListener("click", () => elements.sourceFile.click());
495
+ elements.sourceRail.append(upload);
496
+ }
497
+
498
+ function renderTargetRail() {
499
+ elements.targetRail.replaceChildren();
500
+ sessionState.targets.forEach((target, index) => {
501
+ const button = document.createElement("button");
502
+ button.className = `target-tile${target.id === sessionState.selected_target_id ? " selected" : ""}`;
503
+ button.setAttribute("aria-label", `Select start frame ${index + 1}`);
504
+ const image = document.createElement("img");
505
+ image.src = target.thumbnail_url;
506
+ image.alt = "";
507
+ button.append(image);
508
+ button.addEventListener("click", async () => {
509
+ if (target.id === sessionState.selected_target_id) return;
510
+ if (!await flushTargetSettings()) return;
511
+ await clearCanvasDraft("target");
512
+ try {
513
+ await applyState(await request(
514
+ `/api/sessions/${sessionState.id}/targets/${target.id}/selection`,
515
+ { method: "PUT" },
516
+ ));
517
+ } catch (error) {
518
+ showToast(error.message);
519
+ }
520
+ });
521
+ elements.targetRail.append(button);
522
+ });
523
+ const upload = document.createElement("button");
524
+ upload.className = "target-upload-tile";
525
+ upload.title = "Upload target image";
526
+ upload.textContent = "+";
527
+ upload.addEventListener("click", () => elements.targetFile.click());
528
+ elements.targetRail.append(upload);
529
+ }
530
+
531
+ function outputUrl(output) {
532
+ if (output.local_url) return output.local_url;
533
+ return `${output.video_url}?v=${encodeURIComponent(output.task_id || output.output_name)}`;
534
+ }
535
+
536
+ async function rememberGeneratedOutput(output) {
537
+ let record = { ...output };
538
+ try {
539
+ const response = await fetch(outputUrl(output));
540
+ if (!response.ok) throw new Error(`Could not cache output (${response.status})`);
541
+ record.local_url = URL.createObjectURL(await response.blob());
542
+ } catch (_) {
543
+ // The current server URL remains usable until a later generation replaces it.
544
+ }
545
+ generatedOutputs.push(record);
546
+ while (generatedOutputs.length > 8) {
547
+ const expired = generatedOutputs.shift();
548
+ if (expired.local_url) URL.revokeObjectURL(expired.local_url);
549
+ }
550
+ renderGeneratedRail();
551
+ return record;
552
+ }
553
+
554
+ function clearGeneratedOutputs() {
555
+ generatedOutputs.forEach((output) => {
556
+ if (output.local_url) URL.revokeObjectURL(output.local_url);
557
+ });
558
+ generatedOutputs = [];
559
+ renderGeneratedRail();
560
+ }
561
+
562
+ function showGeneratedOutput(output) {
563
+ if (!output?.video_url) return;
564
+ elements.resultVideo.pause();
565
+ elements.resultVideo.src = outputUrl(output);
566
+ elements.resultMeta.textContent = output.frames
567
+ ? `${output.frames} frames · ${output.width}×${output.height} · ${output.fps} fps`
568
+ : "";
569
+ elements.resultPanel.classList.remove("hidden");
570
+ elements.resultVideo.load();
571
+ elements.resultVideo.play().catch(() => {});
572
+ }
573
+
574
+ function renderGeneratedRail() {
575
+ const outputs = generatedOutputs;
576
+ elements.generatedRail.replaceChildren();
577
+ elements.generatedRail.classList.toggle("hidden", outputs.length === 0);
578
+ [...outputs].reverse().forEach((output, index) => {
579
+ const button = document.createElement("button");
580
+ button.className = "generated-tile";
581
+ button.setAttribute("aria-label", `Open generated video ${index + 1}`);
582
+ const video = document.createElement("video");
583
+ video.src = outputUrl(output);
584
+ video.muted = true;
585
+ video.loop = true;
586
+ video.playsInline = true;
587
+ video.preload = "metadata";
588
+ button.append(video);
589
+ button.addEventListener("click", () => showGeneratedOutput(output));
590
+ elements.generatedRail.append(button);
591
+ });
592
+ }
593
+
594
+ function scheduleStatePoll() {
595
+ clearTimeout(pollTimer);
596
+ const preparing = sessionState?.targets.some((target) => target.sam_status === "queued") ||
597
+ sessionState?.sources.some((source) => source.sam_status === "queued") ||
598
+ ["loading", "generating"].includes(sessionState?.wan_status) ||
599
+ Boolean(activeGeneration || sessionState?.active_generation);
600
+ const delay = preparing ? 900 : (document.hidden ? 60000 : 30000);
601
+ pollTimer = setTimeout(async () => {
602
+ try {
603
+ await applyState(await request(`/api/sessions/${sessionState.id}`));
604
+ } catch (_) {
605
+ scheduleStatePoll();
606
+ }
607
+ }, delay);
608
+ }
609
+
610
+ async function upload(kind, file) {
611
+ if (!file || !sessionState) return;
612
+ const body = new FormData();
613
+ body.append("file", file);
614
+ canvasStatus(kind, "Uploading…");
615
+ try {
616
+ predictionControllers[kind]?.abort();
617
+ const endpoint = kind === "target" ? "targets" : "sources";
618
+ const next = await request(`/api/sessions/${sessionState.id}/${endpoint}`, { method: "POST", body });
619
+ if (kind === "source") {
620
+ await clearCanvasDraft("source");
621
+ selectedSourceId = next.sources.at(-1)?.id || selectedSourceId;
622
+ }
623
+ if (kind === "target") {
624
+ targetCanvas.clearDraft();
625
+ }
626
+ await applyState(next);
627
+ if (kind === "source") sourceCanvas.setPinned(true);
628
+ if (kind === "target") targetCanvas.setPinned(true);
629
+ } catch (error) {
630
+ canvasStatus(kind);
631
+ showToast(error.message);
632
+ }
633
+ }
634
+
635
+ async function predictMask(kind, payload) {
636
+ const assetId = kind === "target" ? selectedTarget()?.id : selectedSourceId;
637
+ if (!assetId) return;
638
+ hoverControllers[kind]?.abort();
639
+ clearTimeout(hoverTimers[kind]);
640
+ payload.transient = false;
641
+ predictionControllers[kind]?.abort();
642
+ const controller = new AbortController();
643
+ predictionControllers[kind] = controller;
644
+ canvasStatus(kind, "Updating mask…");
645
+ try {
646
+ const result = await request(
647
+ `/api/sessions/${sessionState.id}/assets/${kind}/${assetId}/predict`,
648
+ { ...jsonOptions(payload), signal: controller.signal },
649
+ );
650
+ const canvas = kind === "source" ? sourceCanvas : targetCanvas;
651
+ await canvas.setDraft(result.mask, result.prompt_revision);
652
+ canvasStatus(kind);
653
+ } catch (error) {
654
+ if (error.name === "AbortError" || error.status === 409) return;
655
+ canvasStatus(kind, error.message, true);
656
+ showToast(error.message);
657
+ }
658
+ }
659
+
660
+ function scheduleHover(kind, payload) {
661
+ clearTimeout(hoverTimers[kind]);
662
+ hoverControllers[kind]?.abort();
663
+ hoverControllers[kind] = null;
664
+ const canvas = kind === "source" ? sourceCanvas : targetCanvas;
665
+ if (!payload) {
666
+ canvas.clearHoverDraft();
667
+ return;
668
+ }
669
+ hoverTimers[kind] = setTimeout(async () => {
670
+ const assetId = kind === "target" ? selectedTarget()?.id : selectedSourceId;
671
+ if (!assetId) return;
672
+ const controller = new AbortController();
673
+ hoverControllers[kind] = controller;
674
+ try {
675
+ const result = await request(
676
+ `/api/sessions/${sessionState.id}/assets/${kind}/${assetId}/predict`,
677
+ { ...jsonOptions(payload), signal: controller.signal },
678
+ );
679
+ await canvas.setHoverDraft(result.mask, result.prompt_revision);
680
+ } catch (error) {
681
+ if (error.name !== "AbortError" && error.status !== 409) showToast(error.message);
682
+ }
683
+ }, 85);
684
+ }
685
+
686
+ async function commitMask(kind) {
687
+ const canvas = kind === "source" ? sourceCanvas : targetCanvas;
688
+ const assetId = kind === "target" ? selectedTarget()?.id : selectedSourceId;
689
+ const payload = canvas.promptPayload();
690
+ try {
691
+ const next = await request(
692
+ `/api/sessions/${sessionState.id}/assets/${kind}/${assetId}/masks`,
693
+ jsonOptions({
694
+ asset_revision: payload.asset_revision,
695
+ prompt_revision: payload.prompt_revision,
696
+ frame_index: payload.frame_index,
697
+ }),
698
+ );
699
+ canvas.clearDraft();
700
+ await applyState(next);
701
+ } catch (error) {
702
+ showToast(error.message);
703
+ }
704
+ }
705
+
706
+ async function clearCanvasDraft(kind) {
707
+ const canvas = kind === "source" ? sourceCanvas : targetCanvas;
708
+ const asset = canvas.asset;
709
+ predictionControllers[kind]?.abort();
710
+ hoverControllers[kind]?.abort();
711
+ clearTimeout(hoverTimers[kind]);
712
+ predictionControllers[kind] = null;
713
+ canvas.clearDraft();
714
+ if (!asset || !sessionState) return;
715
+ try {
716
+ await request(
717
+ `/api/sessions/${sessionState.id}/assets/${kind}/${asset.id}/draft?revision=${asset.revision}`,
718
+ { method: "DELETE" },
719
+ );
720
+ } catch (error) {
721
+ if (error.status !== 404 && error.status !== 409) showToast(error.message);
722
+ }
723
+ }
724
+
725
+ async function discardUnrecoverableDrafts(state) {
726
+ const assets = [];
727
+ state.targets.forEach((target) => assets.push(["target", target.id, target.revision]));
728
+ state.sources.forEach((source) => assets.push(["source", source.id, source.revision]));
729
+ await Promise.allSettled(assets.map(([kind, assetId, revision]) => request(
730
+ `/api/sessions/${state.id}/assets/${kind}/${assetId}/draft?revision=${revision}`,
731
+ { method: "DELETE" },
732
+ )));
733
+ }
734
+
735
+ function menuButton(label, action, { danger = false, color = null, image = null, detail = null } = {}) {
736
+ const button = document.createElement("button");
737
+ button.className = `menu-item${danger ? " danger" : ""}`;
738
+ if (image) {
739
+ const preview = document.createElement("img");
740
+ preview.src = image;
741
+ preview.alt = "";
742
+ button.append(preview);
743
+ }
744
+ if (color) {
745
+ const dot = document.createElement("span");
746
+ dot.className = "color-dot";
747
+ dot.style.background = color;
748
+ button.append(dot);
749
+ }
750
+ const copy = document.createElement("span");
751
+ copy.className = "menu-copy";
752
+ const main = document.createElement("span");
753
+ main.textContent = label;
754
+ copy.append(main);
755
+ if (detail) {
756
+ const small = document.createElement("small");
757
+ small.textContent = detail;
758
+ copy.append(small);
759
+ }
760
+ button.append(copy);
761
+ button.addEventListener("click", async (event) => {
762
+ event.stopPropagation();
763
+ hideMenu();
764
+ await action();
765
+ });
766
+ return button;
767
+ }
768
+
769
+ function menuLabel(text) {
770
+ const label = document.createElement("div");
771
+ label.className = "menu-label";
772
+ label.textContent = text;
773
+ return label;
774
+ }
775
+
776
+ function divider() {
777
+ const value = document.createElement("div");
778
+ value.className = "menu-divider";
779
+ return value;
780
+ }
781
+
782
+ function showMenu(children, x, y) {
783
+ elements.contextMenu.replaceChildren(...children);
784
+ elements.contextMenu.classList.remove("hidden");
785
+ const width = elements.contextMenu.offsetWidth;
786
+ const height = elements.contextMenu.offsetHeight;
787
+ elements.contextMenu.style.left = `${Math.min(x + 6, window.innerWidth - width - 8)}px`;
788
+ elements.contextMenu.style.top = `${Math.min(y + 6, window.innerHeight - height - 8)}px`;
789
+ }
790
+
791
+ function hideMenu() {
792
+ elements.contextMenu.classList.add("hidden");
793
+ }
794
+
795
+ function openSourceMenu(masks, x, y) {
796
+ const source = selectedSource();
797
+ if (!source) return;
798
+ const children = [menuLabel(masks.length > 1 ? "Masks here" : "Source mask")];
799
+ masks.forEach((mask) => {
800
+ const index = source.masks.findIndex((value) => value.id === mask.id);
801
+ children.push(menuButton(
802
+ masks.length > 1 ? `Remove S${sessionState.sources.indexOf(source) + 1} · M${index + 1}` : "Remove mask",
803
+ () => removeMask("source", source.id, mask.id),
804
+ { danger: true, color: mask.color },
805
+ ));
806
+ });
807
+ showMenu(children, x, y);
808
+ }
809
+
810
+ function openTargetMenu(masks, x, y, selected = null) {
811
+ const target = selectedTarget();
812
+ if (!target) return;
813
+ const mask = selected || masks[0];
814
+ const targetIndex = target.masks.findIndex((value) => value.id === mask.id);
815
+ const children = [];
816
+ if (masks.length > 1 && !selected) {
817
+ children.push(menuLabel("Target masks here"));
818
+ masks.forEach((candidate) => {
819
+ const index = target.masks.findIndex((value) => value.id === candidate.id);
820
+ children.push(menuButton(`Target mask ${index + 1}`, () => openTargetMenu(masks, x, y, candidate), {
821
+ color: candidate.displayColor,
822
+ }));
823
+ });
824
+ children.push(divider());
825
+ }
826
+ children.push(menuLabel(`Target mask ${targetIndex + 1}`));
827
+ children.push(menuButton("Remove mask", () => removeMask("target", target.id, mask.id), { danger: true }));
828
+ const stored = target.masks.find((value) => value.id === mask.id);
829
+ if (stored?.source_id) {
830
+ children.push(menuButton("Clear motion", () => setMapping(mask.id, null, null)));
831
+ }
832
+ const sources = sourceMaskEntries();
833
+ if (sources.length) {
834
+ children.push(divider(), menuLabel("Use motion from"));
835
+ sources.forEach((entry) => {
836
+ children.push(menuButton(
837
+ `${entry.sourceLabel} · ${entry.maskLabel}`,
838
+ () => setMapping(mask.id, entry.source.id, entry.id),
839
+ { color: entry.color, image: entry.preview_url, detail: entry.source.name },
840
+ ));
841
+ });
842
+ }
843
+ showMenu(children, x, y);
844
+ }
845
+
846
+ async function removeMask(kind, assetId, maskId) {
847
+ try {
848
+ const next = await request(
849
+ `/api/sessions/${sessionState.id}/assets/${kind}/${assetId}/masks/${maskId}`,
850
+ { method: "DELETE" },
851
+ );
852
+ await applyState(next);
853
+ } catch (error) {
854
+ showToast(error.message);
855
+ }
856
+ }
857
+
858
+ async function setMapping(targetMaskId, sourceId, sourceMaskId) {
859
+ const target = selectedTarget();
860
+ if (!target) return false;
861
+ try {
862
+ const next = await request(
863
+ `/api/sessions/${sessionState.id}/targets/${target.id}/masks/${targetMaskId}/mapping`,
864
+ jsonOptions({ source_id: sourceId, source_mask_id: sourceMaskId }, "PUT"),
865
+ );
866
+ targetCanvas.setDropHover(null);
867
+ await applyState(next);
868
+ return true;
869
+ } catch (error) {
870
+ showToast(error.message);
871
+ return false;
872
+ }
873
+ }
874
+
875
+ function maskShape(element, mask, color) {
876
+ element.replaceChildren();
877
+ if (!mask?.alpha) return false;
878
+ let minX = mask.alpha.width;
879
+ let minY = mask.alpha.height;
880
+ let maxX = -1;
881
+ let maxY = -1;
882
+ for (let y = 0; y < mask.alpha.height; y += 1) {
883
+ for (let x = 0; x < mask.alpha.width; x += 1) {
884
+ if (!mask.pixels[y * mask.alpha.width + x]) continue;
885
+ minX = Math.min(minX, x);
886
+ minY = Math.min(minY, y);
887
+ maxX = Math.max(maxX, x);
888
+ maxY = Math.max(maxY, y);
889
+ }
890
+ }
891
+ if (maxX < 0 || maxY < 0) return false;
892
+ const width = maxX - minX + 1;
893
+ const height = maxY - minY + 1;
894
+ const raster = document.createElement("canvas");
895
+ raster.width = width;
896
+ raster.height = height;
897
+ const context = raster.getContext("2d");
898
+ context.fillStyle = color || mask.color || "#25d5f4";
899
+ context.fillRect(0, 0, raster.width, raster.height);
900
+ context.globalCompositeOperation = "destination-in";
901
+ context.drawImage(mask.alpha, minX, minY, width, height, 0, 0, width, height);
902
+ const image = document.createElement("img");
903
+ image.src = raster.toDataURL("image/png");
904
+ image.alt = "";
905
+ element.append(image);
906
+ return true;
907
+ }
908
+
909
+ function hideDragGhost() {
910
+ elements.dragGhost.classList.add("hidden");
911
+ elements.dragGhost.replaceChildren();
912
+ draggedMaskId = null;
913
+ }
914
+
915
+ function playMappingDrop(target, color, x, y) {
916
+ const pop = document.createElement("span");
917
+ pop.className = "mapping-drop-pop";
918
+ pop.style.left = `${x}px`;
919
+ pop.style.top = `${y}px`;
920
+ if (!maskShape(pop, target, color)) return;
921
+ document.body.append(pop);
922
+ pop.addEventListener("animationend", () => pop.remove(), { once: true });
923
+ }
924
+
925
+ function scheduleMappingHint() {
926
+ if (exampleTutorialState === "complete" || exampleTutorialScheduled) return;
927
+ const target = selectedTarget();
928
+ const source = selectedSource();
929
+ if (!isExampleTutorial(target) || !source) return;
930
+ const targetRecord = target.masks.find((value) =>
931
+ value.source_id === source.id && sourceCanvas.getMask(value.source_mask_id)
932
+ );
933
+ const sourceMask = targetRecord && sourceCanvas.getMask(targetRecord.source_mask_id);
934
+ const targetMask = targetRecord && targetCanvas.getMask(targetRecord.id);
935
+ if (!targetRecord || !sourceMask || !targetMask) return;
936
+ exampleTutorialScheduled = true;
937
+ mappingHintTimer = setTimeout(() => {
938
+ const start = sourceCanvas.maskClientCenter(sourceMask.id);
939
+ const end = targetCanvas.maskClientCenter(targetMask.id);
940
+ if (!start || !end) {
941
+ exampleTutorialScheduled = false;
942
+ return;
943
+ }
944
+ const token = document.createElement("span");
945
+ token.className = "mapping-hint-token";
946
+ token.style.left = `${start.x}px`;
947
+ token.style.top = `${start.y}px`;
948
+ token.style.setProperty("--hint-x", `${end.x - start.x}px`);
949
+ token.style.setProperty("--hint-y", `${end.y - start.y}px`);
950
+ maskShape(token, sourceMask, sourceMask.color);
951
+ const pointer = document.createElement("span");
952
+ pointer.className = "mapping-hint-pointer";
953
+ pointer.innerHTML = '<svg viewBox="0 0 24 28" aria-hidden="true"><path d="M3 2v20l5.2-5.1 3.6 8.1 4.1-1.9-3.7-7.8H20L3 2Z"></path></svg>';
954
+ const copy = document.createElement("span");
955
+ copy.className = "mapping-hint-copy";
956
+ copy.textContent = "Drag a source mask onto a target mask";
957
+ elements.mappingHint.replaceChildren(token, pointer, copy);
958
+ pointer.style.left = `${start.x + 8}px`;
959
+ pointer.style.top = `${start.y + 8}px`;
960
+ pointer.style.setProperty("--hint-x", `${end.x - start.x}px`);
961
+ pointer.style.setProperty("--hint-y", `${end.y - start.y}px`);
962
+ elements.mappingHint.classList.remove("hidden");
963
+ token.addEventListener("animationend", () => {
964
+ playMappingDrop(targetMask, sourceMask.color, end.x, end.y);
965
+ exampleTutorialState = "complete";
966
+ elements.mappingHint.classList.add("hidden");
967
+ elements.mappingHint.replaceChildren();
968
+ const currentTarget = selectedTarget();
969
+ if (currentTarget?.id === target.id) {
970
+ const masks = currentTarget.masks.map((mask) => ({
971
+ ...mask,
972
+ displayColor: mappedColor(mask),
973
+ }));
974
+ targetCanvas.setAsset(currentTarget, masks);
975
+ }
976
+ }, { once: true });
977
+ }, 850);
978
+ }
979
+
980
+ function mappingDrag(phase, mask, x, y) {
981
+ if (phase === "cancel") {
982
+ hideDragGhost();
983
+ targetCanvas.setDropHover(null);
984
+ return;
985
+ }
986
+ if (phase === "move") {
987
+ if (draggedMaskId !== mask.id) {
988
+ elements.dragGhost.replaceChildren();
989
+ const shape = document.createElement("span");
990
+ shape.className = "drag-mask-shape";
991
+ maskShape(shape, mask, mask.color);
992
+ elements.dragGhost.append(shape);
993
+ draggedMaskId = mask.id;
994
+ }
995
+ elements.dragGhost.style.left = `${x - 8}px`;
996
+ elements.dragGhost.style.top = `${y - 8}px`;
997
+ elements.dragGhost.classList.remove("hidden");
998
+ const target = targetCanvas.masksAtClient(x, y)[0] || null;
999
+ targetCanvas.setDropHover(target?.id || null);
1000
+ return;
1001
+ }
1002
+ const target = targetCanvas.masksAtClient(x, y)[0] || null;
1003
+ hideDragGhost();
1004
+ targetCanvas.setDropHover(null);
1005
+ if (target) {
1006
+ playMappingDrop(target, mask.color, x, y);
1007
+ setMapping(target.id, selectedSourceId, mask.id);
1008
+ }
1009
+ }
1010
+
1011
+ async function selectSourceFrame(frame, final) {
1012
+ const source = selectedSource();
1013
+ if (!source || !final) {
1014
+ if (source) $("sourceCanvasHost").classList.add("playing");
1015
+ return true;
1016
+ }
1017
+ const selected = Math.max(0, Math.min(source.frame_count - 1, Math.round(frame)));
1018
+ $("sourceCanvasHost").classList.add("playing");
1019
+ frameController?.abort();
1020
+ frameController = new AbortController();
1021
+ try {
1022
+ canvasStatus("source", "Preparing selected frame…");
1023
+ const next = await request(
1024
+ `/api/sessions/${sessionState.id}/sources/${source.id}`,
1025
+ { ...jsonOptions({ current_frame: selected }, "PATCH"), signal: frameController.signal },
1026
+ );
1027
+ await applyState(next);
1028
+ return true;
1029
+ } catch (error) {
1030
+ if (error.name === "AbortError" || error.status === 409) return false;
1031
+ showToast(error.message);
1032
+ await applyState(sessionState);
1033
+ return false;
1034
+ }
1035
+ }
1036
+
1037
+ async function updateSourceTrim(start, end) {
1038
+ const source = selectedSource();
1039
+ if (!source) return false;
1040
+ const excluded = source.masks.filter((mask) =>
1041
+ (mask.frame_index < start || mask.frame_index > end) &&
1042
+ sessionState.targets.some((target) => target.masks.some((value) =>
1043
+ value.source_id === source.id && value.source_mask_id === mask.id,
1044
+ )),
1045
+ );
1046
+ if (excluded.length && !window.confirm(
1047
+ `This trim excludes ${excluded.length} mapped source mask${excluded.length === 1 ? "" : "s"} and will clear those mappings. Continue?`,
1048
+ )) return false;
1049
+ try {
1050
+ const next = await request(
1051
+ `/api/sessions/${sessionState.id}/sources/${source.id}`,
1052
+ jsonOptions({ trim_start: start, trim_end: end }, "PATCH"),
1053
+ );
1054
+ await applyState(next);
1055
+ return true;
1056
+ } catch (error) {
1057
+ showToast(error.message);
1058
+ await applyState(sessionState);
1059
+ return false;
1060
+ }
1061
+ }
1062
+
1063
+ function updateGenerateState() {
1064
+ const mode = elements.guidanceMode.value;
1065
+ const target = selectedTarget();
1066
+ const mapped = target?.masks.some((mask) => mask.source_id && mask.source_mask_id);
1067
+ const ready = Boolean(target && (mode === "base_cfg" || mapped));
1068
+ if (activeGeneration) {
1069
+ elements.generateButton.disabled = true;
1070
+ elements.generateHint.textContent = "GPU work continues in the background; you can keep editing inputs.";
1071
+ return;
1072
+ }
1073
+ elements.generateButton.disabled = !ready;
1074
+ elements.generateHint.textContent = !target
1075
+ ? "Upload a target image first."
1076
+ : mode === "base_cfg"
1077
+ ? "Base Wan ignores source masks."
1078
+ : mapped
1079
+ ? ""
1080
+ : "Create source and target masks, then drag a source mask onto a target mask.";
1081
+ }
1082
+
1083
+ function setGenerateButtonStatus(label, count = "") {
1084
+ elements.generateButtonLabel.textContent = label;
1085
+ elements.generateButtonCount.textContent = count;
1086
+ }
1087
+
1088
+ async function generate() {
1089
+ if (activeGeneration) return;
1090
+ const formSignature = generationFormSignature();
1091
+ const payload = {
1092
+ prompt: elements.prompt.value,
1093
+ negative_prompt: elements.negativePrompt.value || null,
1094
+ steps: Number.parseInt(elements.steps.value, 10),
1095
+ seed: Number.parseInt(elements.seed.value, 10),
1096
+ guidance_mode: elements.guidanceMode.value,
1097
+ text_guidance_scale: Number.parseFloat(elements.textGuidance.value),
1098
+ motion_guidance_scale: Number.parseFloat(elements.motionGuidance.value),
1099
+ lora_scale: Number.parseFloat(elements.loraScale.value),
1100
+ };
1101
+ try {
1102
+ activeGeneration = "gradio-pending";
1103
+ activeGenerationForm = formSignature;
1104
+ activeGenerationStatus = "queued";
1105
+ elements.generateButton.disabled = true;
1106
+ updateProgress({ stage: "Queued for GPU", progress_current: null, progress_total: null });
1107
+ const result = await request(
1108
+ `/api/sessions/${sessionState.id}/generate`,
1109
+ {
1110
+ ...jsonOptions(payload),
1111
+ onGradioStatus: (message) => {
1112
+ const progress = message.progress_data?.[0];
1113
+ if (progress?.desc) {
1114
+ updateProgress({
1115
+ stage: progress.desc,
1116
+ progress_current: progress.index,
1117
+ progress_total: progress.length,
1118
+ });
1119
+ } else if (message.stage === "pending") {
1120
+ const position = Number.isFinite(message.position) ? ` · ${message.position + 1} ahead` : "";
1121
+ updateProgress({ stage: `Queued for GPU${position}`, progress_current: null, progress_total: null });
1122
+ } else if (message.stage === "generating") {
1123
+ updateProgress({ stage: "Starting generation", progress_current: null, progress_total: null });
1124
+ }
1125
+ },
1126
+ },
1127
+ );
1128
+ activeGeneration = result.task_id;
1129
+ activeGenerationForm = formSignature;
1130
+ activeGenerationStatus = "queued";
1131
+ elements.generateButton.disabled = true;
1132
+ updateProgress({ stage: "Queued", progress_current: null, progress_total: null });
1133
+ elements.generateHint.textContent = "GPU work continues in the background; you can keep editing inputs.";
1134
+ renderModelStatus();
1135
+ pollGeneration(result.task_id);
1136
+ } catch (error) {
1137
+ activeGeneration = null;
1138
+ activeGenerationForm = null;
1139
+ activeGenerationStatus = null;
1140
+ updateProgress(null);
1141
+ updateGenerateState();
1142
+ showToast(error.message);
1143
+ }
1144
+ }
1145
+
1146
+ function updateProgress(task = null) {
1147
+ if (!task) {
1148
+ elements.generateButton.classList.remove("generating");
1149
+ elements.generateButtonProgress.classList.remove("indeterminate");
1150
+ elements.generateButtonProgress.style.width = "0";
1151
+ setGenerateButtonStatus("Generate video");
1152
+ return;
1153
+ }
1154
+ elements.generateButton.classList.add("generating");
1155
+ const stage = task.stage || (task.status === "queued" ? "Queued" : "Working");
1156
+ const determinate = Number.isFinite(task.progress_current) && Number.isFinite(task.progress_total) && task.progress_total > 0;
1157
+ setGenerateButtonStatus(stage, determinate ? `${task.progress_current} / ${task.progress_total}` : "");
1158
+ elements.generateButtonProgress.classList.toggle("indeterminate", !determinate);
1159
+ if (determinate) {
1160
+ elements.generateButtonProgress.style.width = `${Math.max(0, Math.min(100, task.progress_current / task.progress_total * 100))}%`;
1161
+ } else {
1162
+ elements.generateButtonProgress.style.width = "";
1163
+ }
1164
+ }
1165
+
1166
+ async function pollGeneration(taskId) {
1167
+ if (activeGeneration !== taskId) return;
1168
+ try {
1169
+ const task = await request(`/api/tasks/${taskId}`);
1170
+ updateProgress(task);
1171
+ if (task.status === "queued") {
1172
+ activeGenerationStatus = "queued";
1173
+ } else if (task.status === "running") {
1174
+ activeGenerationStatus = "running";
1175
+ } else if (task.status === "complete") {
1176
+ const formChanged = activeGenerationForm !== null &&
1177
+ activeGenerationForm !== generationFormSignature();
1178
+ activeGeneration = null;
1179
+ activeGenerationForm = null;
1180
+ activeGenerationStatus = null;
1181
+ updateProgress(null);
1182
+ const previousInputs = task.result.inputs_changed || formChanged;
1183
+ const rememberedOutput = await rememberGeneratedOutput(task.result);
1184
+ showGeneratedOutput(rememberedOutput);
1185
+ if (previousInputs) {
1186
+ showToast("Generation finished from the inputs captured when you clicked Generate.");
1187
+ }
1188
+ try {
1189
+ await applyState(await request(`/api/sessions/${sessionState.id}`));
1190
+ } catch (_) {
1191
+ renderModelStatus();
1192
+ updateGenerateState();
1193
+ }
1194
+ return;
1195
+ } else if (["failed", "superseded"].includes(task.status)) {
1196
+ activeGeneration = null;
1197
+ activeGenerationForm = null;
1198
+ activeGenerationStatus = null;
1199
+ updateProgress(null);
1200
+ showToast(task.error || "Generation failed");
1201
+ renderModelStatus();
1202
+ updateGenerateState();
1203
+ return;
1204
+ }
1205
+ renderModelStatus();
1206
+ } catch (error) {
1207
+ activeGeneration = null;
1208
+ activeGenerationForm = null;
1209
+ activeGenerationStatus = null;
1210
+ updateProgress(null);
1211
+ showToast(error.message);
1212
+ renderModelStatus();
1213
+ updateGenerateState();
1214
+ return;
1215
+ }
1216
+ setTimeout(() => pollGeneration(taskId), 1000);
1217
+ }
1218
+
1219
+ function generationFormSignature() {
1220
+ return JSON.stringify([
1221
+ elements.prompt.value,
1222
+ elements.negativePrompt.value,
1223
+ elements.guidanceMode.value,
1224
+ elements.steps.value,
1225
+ elements.seed.value,
1226
+ elements.textGuidance.value,
1227
+ elements.motionGuidance.value,
1228
+ elements.loraScale.value,
1229
+ ]);
1230
+ }
1231
+
1232
+ elements.sourceRailUpload.addEventListener("click", () => elements.sourceFile.click());
1233
+ elements.sourceEmpty.addEventListener("click", () => elements.sourceFile.click());
1234
+ elements.sourceFile.addEventListener("change", () => {
1235
+ upload("source", elements.sourceFile.files[0]);
1236
+ elements.sourceFile.value = "";
1237
+ });
1238
+ elements.targetEmpty.addEventListener("click", () => elements.targetFile.click());
1239
+ elements.targetFile.addEventListener("change", () => {
1240
+ const file = elements.targetFile.files[0];
1241
+ elements.targetFile.value = "";
1242
+ flushTargetSettings().then((saved) => {
1243
+ if (saved) upload("target", file);
1244
+ });
1245
+ });
1246
+ elements.removeSource.addEventListener("click", async () => {
1247
+ const source = selectedSource();
1248
+ if (!source) return;
1249
+ predictionControllers.source?.abort();
1250
+ try {
1251
+ const next = await request(`/api/sessions/${sessionState.id}/sources/${source.id}`, { method: "DELETE" });
1252
+ await applyState(next);
1253
+ } catch (error) {
1254
+ showToast(error.message);
1255
+ }
1256
+ });
1257
+ elements.removeTarget.addEventListener("click", async () => {
1258
+ const target = selectedTarget();
1259
+ if (!target) return;
1260
+ predictionControllers.target?.abort();
1261
+ try {
1262
+ const next = await request(`/api/sessions/${sessionState.id}/targets/${target.id}`, { method: "DELETE" });
1263
+ await applyState(next);
1264
+ } catch (error) {
1265
+ showToast(error.message);
1266
+ }
1267
+ });
1268
+
1269
+ elements.resultClose.addEventListener("click", () => {
1270
+ elements.resultVideo.pause();
1271
+ setMediaLoading("result", "video", false);
1272
+ elements.resultPanel.classList.add("hidden");
1273
+ });
1274
+
1275
+ elements.startOver.addEventListener("click", async () => {
1276
+ if (!sessionState || !window.confirm("Remove the current media and start over?")) return;
1277
+ predictionControllers.source?.abort();
1278
+ predictionControllers.target?.abort();
1279
+ activeGeneration = null;
1280
+ activeGenerationForm = null;
1281
+ activeGenerationStatus = null;
1282
+ try {
1283
+ const oldSettingsKey = targetSettingsStorageKey(sessionState.id);
1284
+ await request(`/api/sessions/${sessionState.id}`, { method: "DELETE" });
1285
+ let next = await request("/api/sessions", { method: "POST" });
1286
+ for (const source of [...next.sources]) {
1287
+ next = await request(`/api/sessions/${next.id}/sources/${source.id}`, { method: "DELETE" });
1288
+ }
1289
+ for (const target of [...next.targets]) {
1290
+ next = await request(`/api/sessions/${next.id}/targets/${target.id}`, { method: "DELETE" });
1291
+ }
1292
+ sessionStorage.setItem("whatmoves-session", next.id);
1293
+ sessionStorage.removeItem(oldSettingsKey);
1294
+ selectedSourceId = null;
1295
+ formTargetId = null;
1296
+ settingsDirty = false;
1297
+ targetSettingsSessionId = null;
1298
+ targetSettings = {};
1299
+ exampleTutorialState = "complete";
1300
+ exampleTutorialScheduled = false;
1301
+ clearGeneratedOutputs();
1302
+ elements.resultVideo.pause();
1303
+ elements.resultVideo.removeAttribute("src");
1304
+ setMediaLoading("result", "video", false);
1305
+ elements.resultPanel.classList.add("hidden");
1306
+ updateProgress(null);
1307
+ await applyState(next);
1308
+ } catch (error) {
1309
+ showToast(error.message);
1310
+ }
1311
+ });
1312
+
1313
+ elements.sourceMaskMode.addEventListener("click", async () => {
1314
+ if (sourceCanvas.pinned) {
1315
+ sourceCanvas.setPinned(false);
1316
+ return;
1317
+ }
1318
+ sourceTimeline.pause();
1319
+ if (await selectSourceFrame(sourceTimeline.currentFrame, true)) {
1320
+ sourceCanvas.setPinned(true);
1321
+ }
1322
+ });
1323
+ elements.targetMaskMode.addEventListener("click", () => targetCanvas.togglePinned());
1324
+ elements.sourceAddMask.addEventListener("click", () => commitMask("source"));
1325
+ elements.targetAddMask.addEventListener("click", () => commitMask("target"));
1326
+ elements.sourceCancelDraft.addEventListener("click", () => clearCanvasDraft("source"));
1327
+ elements.targetCancelDraft.addEventListener("click", () => clearCanvasDraft("target"));
1328
+ [
1329
+ elements.prompt,
1330
+ elements.negativePrompt,
1331
+ elements.guidanceMode,
1332
+ elements.steps,
1333
+ elements.seed,
1334
+ elements.textGuidance,
1335
+ elements.motionGuidance,
1336
+ elements.loraScale,
1337
+ ].forEach((element) => {
1338
+ element.addEventListener("input", scheduleTargetSettingsSave);
1339
+ element.addEventListener("change", scheduleTargetSettingsSave);
1340
+ });
1341
+ elements.guidanceMode.addEventListener("change", updateGenerateState);
1342
+ elements.generateButton.addEventListener("click", generate);
1343
+
1344
+ window.addEventListener("keydown", (event) => {
1345
+ if (event.key === "Escape") {
1346
+ hideMenu();
1347
+ if (sourceCanvas.isMasking()) clearCanvasDraft("source");
1348
+ if (targetCanvas.isMasking()) clearCanvasDraft("target");
1349
+ }
1350
+ });
1351
+ document.addEventListener("pointerdown", (event) => {
1352
+ if (!elements.contextMenu.contains(event.target)) hideMenu();
1353
+ });
1354
+
1355
+ async function start() {
1356
+ try {
1357
+ let initial = null;
1358
+ let resumed = false;
1359
+ const savedSession = sessionStorage.getItem("whatmoves-session");
1360
+ if (savedSession) {
1361
+ try {
1362
+ initial = await request(`/api/sessions/${savedSession}`);
1363
+ resumed = true;
1364
+ } catch (error) {
1365
+ if (error.status !== 404) throw error;
1366
+ sessionStorage.removeItem("whatmoves-session");
1367
+ }
1368
+ }
1369
+ if (!initial) {
1370
+ initial = await request("/api/sessions", { method: "POST" });
1371
+ sessionStorage.setItem("whatmoves-session", initial.id);
1372
+ }
1373
+ if (resumed) await discardUnrecoverableDrafts(initial);
1374
+ await applyState(initial);
1375
+ if (!activeGeneration && initial.latest_generation) {
1376
+ activeGeneration = initial.latest_generation;
1377
+ activeGenerationForm = null;
1378
+ activeGenerationStatus = "queued";
1379
+ updateGenerateState();
1380
+ pollGeneration(activeGeneration);
1381
+ }
1382
+ } catch (error) {
1383
+ const message = `Could not start the app: ${error.message}`;
1384
+ elements.generateHint.textContent = message;
1385
+ showToast(message);
1386
+ }
1387
+ }
1388
+
1389
+ start();
app/static/broom-flaticon.png ADDED
app/static/favicon.svg ADDED
app/static/gradio-client.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ var Mt=Object.create;var je=Object.defineProperty;var Gt=Object.getOwnPropertyDescriptor;var jt=Object.getOwnPropertyNames;var Ft=Object.getPrototypeOf,qt=Object.prototype.hasOwnProperty;var Bt=(e,t)=>()=>{try{return t||e((t={exports:{}}).exports,t),t.exports}catch(n){throw t=0,n}};var zt=(e,t,n,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of jt(t))!qt.call(e,r)&&r!==n&&je(e,r,{get:()=>t[r],enumerable:!(s=Gt(t,r))||s.enumerable});return e};var Jt=(e,t,n)=>(n=e!=null?Mt(Ft(e)):{},zt(t||!e||!e.__esModule?je(n,"default",{value:e,enumerable:!0}):n,e));var Pt=Bt((ss,At)=>{"use strict";At.exports=function(){throw new Error("ws does not work in the browser. Browser clients must use the native WebSocket object")}});var ye=new Intl.Collator(0,{numeric:1}).compare;function re(e,t,n){return e=e.split("."),t=t.split("."),ye(e[0],t[0])||ye(e[1],t[1])||(t[2]=t.slice(2).join("."),n=/[.-]/.test(e[2]=e.slice(2).join(".")),n==/[.-]/.test(t[2])?ye(e[2],t[2]):n?-1:1)}var Fe="host";var oe="queue/data",qe="queue/join",we="upload",Be="login",ze="config",Je="info",We="runtime",He="sleeptime",Ve="heartbeat",Ke="component_server",Ze="reset",Qe="cancel";var Xe="https://gradio-space-api-fetcher-v2.hf.space/api";var ie="This application is currently busy. Please try again. ",k="Connection errored out. ",N="Could not resolve app config. ",Ye="Could not get space status. ",et="Could not get API info. ",V="Space metadata could not be loaded. ",tt="Invalid URL. A full URL path is required.",nt="Not authorized to access this space. ",be="Invalid credentials. Could not login. ",st="Login credentials are required to access this space.",rt="File system access is only available in Node.js environments",ve="Root URL not found in client config",ot="Error uploading file";function Ee(e,t,n){return t.startsWith("http://")||t.startsWith("https://")?n?e:t:e+t}async function Se(e,t,n){try{return(await(await fetch(`https://huggingface.co/api/spaces/${e}/jwt`,{headers:{Authorization:`Bearer ${t}`,...n?{Cookie:n}:{}}})).json()).token||!1}catch{return!1}}function it(e){let t={};return e.forEach(({api_name:n,id:s})=>{n&&(t[n]=s)}),t}async function at(e){let t=this.options.hf_token?{Authorization:`Bearer ${this.options.hf_token}`}:{};if(t["Content-Type"]="application/json",typeof window<"u"&&window.gradio_config&&location.origin!=="http://localhost:9876"&&!window.gradio_config.dev_mode){let n=window.gradio_config.root,s=window.gradio_config,r=Ee(e,s.root,!1);return s.root=r,{...s,path:n}}else if(e){let n=ce(e,ze),s=await this.fetch(n,{headers:t,credentials:"include"});if(s?.status===401&&!this.options.auth)throw new Error(st);if(s?.status===401&&this.options.auth)throw new Error(be);if(s?.status===200){let r=await s.json();return r.path=r.path??"",r.root=e,r.dependencies?.forEach((o,i)=>{o.id===void 0&&(o.id=i)}),r}else if(s?.status===401)throw new Error(nt);throw new Error(N)}throw new Error(N)}async function pt(){let{http_protocol:e,host:t}=await j(this.app_reference,this.options.hf_token);try{if(this.options.auth){let n=await Re(e,t,this.options.auth,this.fetch,this.options.hf_token);n&&this.set_cookies(n)}}catch(n){throw Error(n.message)}}async function Re(e,t,n,s,r){let o=new FormData;o.append("username",n?.[0]),o.append("password",n?.[1]);let i={};r&&(i.Authorization=`Bearer ${r}`);let a=await s(`${e}//${t}/${Be}`,{headers:i,method:"POST",body:o,credentials:"include"});if(a.status===200)return a.headers.get("set-cookie");throw a.status===401?new Error(be):new Error(V)}function ae(e){if(e.startsWith("http")){let{protocol:t,host:n,pathname:s}=new URL(e);return{ws_protocol:t==="https:"?"wss":"ws",http_protocol:t,host:n+(s!=="/"?s:"")}}else if(e.startsWith("file:"))return{ws_protocol:"ws",http_protocol:"http:",host:"lite.local"};return{ws_protocol:"wss",http_protocol:"https:",host:new URL(e).host}}var pe=e=>{let t=[];return e.split(/,(?=\s*[^\s=;]+=[^\s=;]+)/).forEach(s=>{let[r,o]=s.split(";")[0].split("=");r&&o&&t.push(`${r.trim()}=${o.trim()}`)}),t};var se=/^[a-zA-Z0-9_\-\.]+\/[a-zA-Z0-9_\-\.]+$/,Wt=/.*hf\.space\/{0,1}.*$/;async function j(e,t){let n={};t&&(n.Authorization=`Bearer ${t}`);let s=e.trim().replace(/\/$/,"");if(se.test(s))try{let o=(await(await fetch(`https://huggingface.co/api/spaces/${s}/${Fe}`,{headers:n})).json()).host;return{space_id:e,...ae(o)}}catch{throw new Error(V)}if(Wt.test(s)){let{ws_protocol:r,http_protocol:o,host:i}=ae(s);return{space_id:i.split("/")[0].replace(".hf.space",""),ws_protocol:r,http_protocol:o,host:i}}return{space_id:!1,...ae(s)}}var ce=(...e)=>{try{return e.reduce((t,n)=>(t=t.replace(/\/+$/,""),n=n.replace(/^\/+/,""),new URL(n,t+"/").toString()))}catch{throw new Error(tt)}};function ct(e,t,n){let s={named_endpoints:{},unnamed_endpoints:{}};return Object.keys(e).forEach(r=>{(r==="named_endpoints"||r==="unnamed_endpoints")&&(s[r]={},Object.entries(e[r]).forEach(([o,{parameters:i,returns:a}])=>{let p=t.dependencies.find(l=>l.api_name===o||l.api_name===o.replace("/",""))?.id||n[o.replace("/","")]||-1,c=p!==-1?t.dependencies.find(l=>l.id==p)?.types:{generator:!1,cancel:!1};if(p!==-1&&t.dependencies.find(l=>l.id==p)?.inputs?.length!==i.length){let l=t.dependencies.find(g=>g.id==p).inputs.map(g=>t.components.find(y=>y.id===g)?.type);try{l.forEach((g,y)=>{if(g==="state"){let D={component:"state",example:null,parameter_default:null,parameter_has_default:!0,parameter_name:null,hidden:!0};i.splice(y,0,D)}})}catch(g){console.error(g)}}let d=(l,g,y,D)=>({...l,description:Vt(l?.type,y),type:Ht(l?.type,g,y,D)||""});s[r][o]={parameters:i.map(l=>d(l,l?.component,l?.serializer,"parameter")),returns:a.map(l=>d(l,l?.component,l?.serializer,"return")),type:c}}))}),s}function Ht(e,t,n,s){if(t==="Api")return e.type;switch(e?.type){case"string":return"string";case"boolean":return"boolean";case"number":return"number"}if(n==="JSONSerializable"||n==="StringSerializable")return"any";if(n==="ListStringSerializable")return"string[]";if(t==="Image")return s==="parameter"?"Blob | File | Buffer":"string";if(n==="FileSerializable")return e?.type==="array"?s==="parameter"?"(Blob | File | Buffer)[]":"{ name: string; data: string; size?: number; is_file?: boolean; orig_name?: string}[]":s==="parameter"?"Blob | File | Buffer":"{ name: string; data: string; size?: number; is_file?: boolean; orig_name?: string}";if(n==="GallerySerializable")return s==="parameter"?"[(Blob | File | Buffer), (string | null)][]":"[{ name: string; data: string; size?: number; is_file?: boolean; orig_name?: string}, (string | null))][]"}function Vt(e,t){return t==="GallerySerializable"?"array of [file, label] tuples":t==="ListStringSerializable"?"array of strings":t==="FileSerializable"?"array of files or single file":e?.description}function le(e,t){switch(e.msg){case"send_data":return{type:"data"};case"send_hash":return{type:"hash"};case"queue_full":return{type:"update",status:{queue:!0,message:ie,stage:"error",code:e.code,success:e.success}};case"heartbeat":return{type:"heartbeat"};case"unexpected_error":return{type:"unexpected_error",status:{queue:!0,message:e.message,stage:"error",success:!1}};case"estimation":return{type:"update",status:{queue:!0,stage:t||"pending",code:e.code,size:e.queue_size,position:e.rank,eta:e.rank_eta,success:e.success}};case"progress":return{type:"update",status:{queue:!0,stage:"pending",code:e.code,progress_data:e.progress_data,success:e.success}};case"log":return{type:"log",data:e};case"process_generating":return{type:"generating",status:{queue:!0,message:e.success?null:e.output.error,stage:e.success?"generating":"error",code:e.code,progress_data:e.progress_data,eta:e.average_duration,changed_state_ids:e.success?e.output.changed_state_ids:void 0},data:e.success?e.output:null};case"process_streaming":return{type:"streaming",status:{queue:!0,message:e.output.error,stage:"streaming",time_limit:e.time_limit,code:e.code,progress_data:e.progress_data,eta:e.eta},data:e.output};case"process_completed":return"error"in e.output?{type:"update",status:{queue:!0,title:e.output.title,message:e.output.error,visible:e.output.visible,duration:e.output.duration,stage:"error",code:e.code,success:e.success}}:{type:"complete",status:{queue:!0,message:e.success?void 0:e.output.error,stage:e.success?"complete":"error",code:e.code,progress_data:e.progress_data,changed_state_ids:e.success?e.output.changed_state_ids:void 0},data:e.success?e.output:null};case"process_starts":return{type:"update",status:{queue:!0,stage:"pending",code:e.code,size:e.rank,position:0,success:e.success,eta:e.eta},original_msg:"process_starts"}}return{type:"none",status:{stage:"error",queue:!0}}}var lt=(e=[],t)=>{let n=t?t.parameters:[];if(Array.isArray(e))return e.length>n.length&&console.warn("Too many arguments provided for the endpoint."),e;let s=[],r=Object.keys(e);return n.forEach((o,i)=>{if(e.hasOwnProperty(o.parameter_name))s[i]=e[o.parameter_name];else if(o.parameter_has_default)s[i]=o.parameter_default;else throw new Error(`No value provided for required parameter: ${o.parameter_name}`)}),r.forEach(o=>{if(!n.some(i=>i.parameter_name===o))throw new Error(`Parameter \`${o}\` is not a valid keyword argument. Please refer to the API for usage.`)}),s.forEach((o,i)=>{if(o===void 0&&!n[i].parameter_has_default)throw new Error(`No value provided for required parameter: ${n[i].parameter_name}`)}),s};async function ut(){if(this.api_info)return this.api_info;let{hf_token:e}=this.options,{config:t}=this,n={"Content-Type":"application/json"};if(e&&(n.Authorization=`Bearer ${e}`),!!t)try{let s,r;if(typeof window<"u"&&window.gradio_api_info)r=window.gradio_api_info;else{if(re(t?.version||"2.0.0","3.30")<0)s=await this.fetch(Xe,{method:"POST",body:JSON.stringify({serialize:!1,config:JSON.stringify(t)}),headers:n,credentials:"include"});else{let o=ce(t.root,this.api_prefix,Je);s=await this.fetch(o,{headers:n,credentials:"include"})}if(!s.ok)throw new Error(k);r=await s.json()}return"api"in r&&(r=r.api),r.named_endpoints["/predict"]&&!r.unnamed_endpoints[0]&&(r.unnamed_endpoints[0]=r.named_endpoints["/predict"]),ct(r,t,this.api_map)}catch(s){""+s.message}}async function xe(e,t,n){let s={};this?.options?.hf_token&&(s.Authorization=`Bearer ${this.options.hf_token}`);let r=1e3,o=[],i;for(let a=0;a<t.length;a+=r){let p=t.slice(a,a+r),c=new FormData;p.forEach(l=>{c.append("files",l)});try{let l=n?`${e}${this.api_prefix}/${we}?upload_id=${n}`:`${e}${this.api_prefix}/${we}`;i=await this.fetch(l,{method:"POST",body:c,headers:s,credentials:"include"})}catch(l){throw new Error(k+l.message)}if(!i.ok){let l=await i.text();return{error:`HTTP ${i.status}: ${l}`}}let d=await i.json();d&&o.push(...d)}return{files:o}}async function Ce(e,t,n,s){let r=(Array.isArray(e)?e:[e]).map(i=>i.blob),o=r.filter(i=>i.size>(s??1/0));if(o.length)throw new Error(`File size exceeds the maximum allowed size of ${s} bytes: ${o.map(i=>i.name).join(", ")}`);return await Promise.all(await this.upload_files(t,r,n).then(async i=>{if(i.error)throw new Error(i.error);return i.files?i.files.map((a,p)=>new B({...e[p],path:a,url:`${t}${this.api_prefix}/file=${a}`})):[]}))}var B=class{path;url;orig_name;size;blob;is_stream;mime_type;alt_text;b64;meta={_type:"gradio.FileData"};constructor({path:t,url:n,orig_name:s,size:r,blob:o,is_stream:i,mime_type:a,alt_text:p,b64:c}){this.path=t,this.url=n,this.orig_name=s,this.size=r,this.blob=n?void 0:o,this.is_stream=i,this.mime_type=a,this.alt_text=p,this.b64=c}};var K=class{type;command;meta;fileData;constructor(t,n){this.type="command",this.command=t,this.meta=n}};var Kt=typeof process<"u"&&process.versions&&process.versions.node;function ke(e,t,n){for(;n.length>1;){let r=n.shift();if(typeof r=="string"||typeof r=="number")e=e[r];else throw new Error("Invalid key type")}let s=n.shift();if(typeof s=="string"||typeof s=="number")e[s]=t;else throw new Error("Invalid key type")}async function ue(e,t=void 0,n=[],s=!1,r=void 0){if(Array.isArray(e)){let o=[];return await Promise.all(e.map(async(i,a)=>{let p=n.slice();p.push(String(a));let c=await ue(e[a],s?r?.parameters[a]?.component||void 0:t,p,!1,r);o=o.concat(c)})),o}else{if(globalThis.Buffer&&e instanceof globalThis.Buffer||e instanceof Blob)return[{path:n,blob:new Blob([e]),type:t}];if(typeof e=="object"&&e!==null){let o=[];for(let i of Object.keys(e)){let a=[...n,i],p=e[i];o=o.concat(await ue(p,void 0,a,!1,r))}return o}}return[]}function dt(e,t){let n=t?.dependencies?.find(s=>s.id==e)?.queue;return n!=null?!n:!t.enable_queue}function ft(e,t){return new Promise((n,s)=>{let r=new MessageChannel;r.port1.onmessage=(({data:o})=>{r.port1.close(),n(o)}),window.parent.postMessage(e,t,[r.port2])})}function mt(e){if(typeof e=="string"){if(e.startsWith("http://")||e.startsWith("https://"))return{path:e,url:e,orig_name:e.split("/").pop()??"unknown",meta:{_type:"gradio.FileData"}};if(Kt)return new K("upload_file",{path:e,name:e,orig_path:e})}else{if(typeof File<"u"&&e instanceof File)return new Blob([e]);if(e instanceof Buffer)return new Blob([e]);if(e instanceof Blob)return e}throw new Error("Invalid input: must be a URL, File, Blob, or Buffer object.")}function Z(e,t,n,s,r=!1){if(s==="input"&&!r)throw new Error("Invalid code path. Cannot skip state inputs for input.");if(s==="output"&&r)return e;let o=[],i=0,a=s==="input"?t.inputs:t.outputs;for(let p=0;p<a.length;p++){let c=a[p];if(n.find(l=>l.id===c)?.type==="state"){if(r)if(e.length===a.length){let l=e[i];o.push(l),i++}else o.push(null);else{i++;continue}continue}else{let l=e[i];o.push(l),i++}}return o}async function _t(e,t,n){let s=this;await Zt(s,t);let r=await ue(t,void 0,[],!0,n);return(await Promise.all(r.map(async({path:i,blob:a,type:p})=>{if(!a)return{path:i,type:p};let c=await s.upload_files(e,[a]),d=c.files&&c.files[0];return{path:i,file_url:d,type:p,name:typeof File<"u"&&a instanceof File?a?.name:void 0}}))).forEach(({path:i,file_url:a,type:p,name:c})=>{if(p==="Gallery")ke(t,a,i);else if(a){let d=new B({path:a,orig_name:c});ke(t,d,i)}}),t}async function Zt(e,t){if(!(e.config?.root||e.config?.root_url))throw new Error(ve);await gt(e,t)}async function gt(e,t,n=[]){for(let s in t)t[s]instanceof K?await Qt(e,t,s):typeof t[s]=="object"&&t[s]!==null&&await gt(e,t[s],[...n,s])}async function Qt(e,t,n){let s=t[n],r=e.config?.root||e.config?.root_url;if(!r)throw new Error(ve);try{let o,i;if(typeof process<"u"&&process.versions&&process.versions.node){let d=await import("fs/promises");i=(await import("path")).resolve(process.cwd(),s.meta.path),o=await d.readFile(i)}else throw new Error(rt);let a=new Blob([o],{type:"application/octet-stream"}),p=await e.upload_files(r,[a]),c=p.files&&p.files[0];if(c){let d=new B({path:c,orig_name:s.meta.name||""});t[n]=d}}catch(o){console.error(ot,o)}}async function ht(e,t,n){let s={"Content-Type":"application/json"};this.options.hf_token&&(s.Authorization=`Bearer ${this.options.hf_token}`);try{var r=await this.fetch(e,{method:"POST",body:JSON.stringify(t),headers:{...s,...n},credentials:"include"})}catch{return[{error:k},500]}let o,i;try{o=await r.json(),i=r.status}catch(a){o={error:`Could not parse server response: ${a}`},i=500}return[o,i]}async function Ae(e,t={}){let n=!1,s=!1,r;if(!this.config)throw new Error("Could not resolve app config");if(typeof e=="number")r=this.config.dependencies.find(o=>o.id==e);else{let o=e.replace(/^\//,"");r=this.config.dependencies.find(i=>i.id==this.api_map[o])}return new Promise(async(o,i)=>{let a=this.submit(e,t,null,null,!0),p;for await(let c of a)c.type==="data"&&(s&&o(p),n=!0,p=c),c.type==="status"&&(c.stage==="error"&&i(c),c.stage==="complete"&&(s=!0,n&&o(p)))})}async function Q(e,t,n){let s=t==="subdomain"?`https://huggingface.co/api/spaces/by-subdomain/${e}`:`https://huggingface.co/api/spaces/${e}`,r,o;try{if(r=await fetch(s),o=r.status,o!==200)throw new Error;r=await r.json()}catch{n({status:"error",load_status:"error",message:Ye,detail:"NOT_FOUND"});return}if(!r||o!==200)return;let{runtime:{stage:i},id:a}=r;switch(i){case"STOPPED":case"SLEEPING":n({status:"sleeping",load_status:"pending",message:"Space is asleep. Waking it up...",detail:i}),setTimeout(()=>{Q(e,t,n)},1e3);break;case"PAUSED":n({status:"paused",load_status:"error",message:"This space has been paused by the author. If you would like to try this demo, consider duplicating the space.",detail:i,discussions_enabled:await yt(a)});break;case"RUNNING":case"RUNNING_BUILDING":n({status:"running",load_status:"complete",message:"Space is running.",detail:i});break;case"BUILDING":n({status:"building",load_status:"pending",message:"Space is building...",detail:i}),setTimeout(()=>{Q(e,t,n)},1e3);break;case"APP_STARTING":n({status:"starting",load_status:"pending",message:"Space is starting...",detail:i}),setTimeout(()=>{Q(e,t,n)},1e3);break;default:n({status:"space_error",load_status:"error",message:"This space is experiencing an issue.",detail:i,discussions_enabled:await yt(a)});break}}var Pe=async(e,t)=>{let n=0,s=12,r=5e3;return new Promise(o=>{Q(e,se.test(e)?"space_name":"subdomain",i=>{t(i),i.status==="running"||i.status==="error"||i.status==="paused"||i.status==="space_error"?o():(i.status==="sleeping"||i.status==="building")&&(n<s?(n++,setTimeout(()=>{Pe(e,t).then(o)},r)):o())})})},Xt=/^(?=[^]*\b[dD]iscussions{0,1}\b)(?=[^]*\b[dD]isabled\b)[^]*$/;async function yt(e){try{let t=await fetch(`https://huggingface.co/api/spaces/${e}/discussions`,{method:"HEAD"}),n=t.headers.get("x-error-message");return!(!t.ok||n&&Xt.test(n))}catch{return!1}}async function wt(e,t){let n={};t&&(n.Authorization=`Bearer ${t}`);try{let s=await fetch(`https://huggingface.co/api/spaces/${e}/${We}`,{headers:n});if(s.status!==200)throw new Error("Space hardware could not be obtained.");let{hardware:r}=await s.json();return r.current}catch(s){throw new Error(s.message)}}async function bt(e,t,n){let s={};n&&(s.Authorization=`Bearer ${n}`);let r={seconds:t};try{let o=await fetch(`https://huggingface.co/api/spaces/${e}/${He}`,{method:"POST",headers:{"Content-Type":"application/json",...s},body:JSON.stringify(r)});if(o.status!==200)throw new Error("Could not set sleep timeout on duplicated Space. Please visit *ADD HF LINK TO SETTINGS* to set a timeout manually to reduce billing charges.");return await o.json()}catch(o){throw new Error(o.message)}}var De=["cpu-basic","cpu-upgrade","cpu-xl","t4-small","t4-medium","a10g-small","a10g-large","a10g-largex2","a10g-largex4","a100-large","zero-a10g","h100","h100x8"];async function vt(e,t){let{hf_token:n,private:s,hardware:r,timeout:o,auth:i}=t;if(r&&!De.includes(r))throw new Error(`Invalid hardware type provided. Valid types are: ${De.map(w=>`"${w}"`).join(",")}.`);let{http_protocol:a,host:p}=await j(e,n),c=null;if(i){let w=await Re(a,p,i,fetch);w&&(c=pe(w))}let d={Authorization:`Bearer ${n}`,"Content-Type":"application/json",...c?{Cookie:c.join("; ")}:{}},l=(await(await fetch("https://huggingface.co/api/whoami-v2",{headers:d})).json()).name,g=e.split("/")[1],y={repository:`${l}/${g}`};s&&(y.private=!0);let D;try{r||(D=await wt(e,n))}catch(w){throw Error(V+w.message)}let Y=r||D||"cpu-basic";y.hardware=Y;try{let w=await fetch(`https://huggingface.co/api/spaces/${e}/duplicate`,{method:"POST",headers:d,body:JSON.stringify(y)});if(w.status===409)try{return await z.connect(`${l}/${g}`,t)}catch(L){throw console.error("Failed to connect Client instance:",L),L}else if(w.status!==200)throw new Error(w.statusText);let ee=await w.json();return await bt(`${l}/${g}`,o||300,n),await z.connect(Yt(ee.url),t)}catch(w){throw new Error(w)}}function Yt(e){let t=/https:\/\/huggingface.co\/spaces\/([^/]+\/[^/]+)/,n=e.match(t);if(n)return n[1]}var de=class extends TransformStream{#e="";constructor(t={allowCR:!1}){super({transform:(n,s)=>{for(n=this.#e+n;;){let r=n.indexOf(`
2
+ `),o=t.allowCR?n.indexOf("\r"):-1;if(o!==-1&&o!==n.length-1&&(r===-1||r-1>o)){s.enqueue(n.slice(0,o)),n=n.slice(o+1);continue}if(r===-1)break;let i=n[r-1]==="\r"?r-1:r;s.enqueue(n.slice(0,i)),n=n.slice(r+1)}this.#e=n},flush:n=>{if(this.#e==="")return;let s=t.allowCR&&this.#e.endsWith("\r")?this.#e.slice(0,-1):this.#e;n.enqueue(s)}})}};function Et(e){let t=new TextDecoderStream,n=new de({allowCR:!0});return e.pipeThrough(t).pipeThrough(n)}function St(e){let n=/[:]\s*/.exec(e),s=n&&n.index;if(s)return[e.substring(0,s),e.substring(s+n[0].length)]}function Ie(e,t,n){e.get(t)||e.set(t,n)}async function*tn(e,t){if(!e.body)return;let n=Et(e.body),s,r=n.getReader(),o;for(;;){if(t&&t.aborted)return r.cancel();if(s=await r.read(),s.done)return;if(!s.value){o&&(yield o),o=void 0;continue}let[i,a]=St(s.value)||[];i==="data"?(o||={},o[i]=o[i]?o[i]+`
3
+ `+a:a):i==="event"?(o||={},o[i]=a):i==="id"?(o||={},o[i]=String(+a)===a?+a:a):i==="retry"&&(o||={},o[i]=+a||void 0)}}async function Rt(e,t){let n=new Request(e,t);Ie(n.headers,"Accept","text/event-stream"),Ie(n.headers,"Content-Type","application/json");let s=await fetch(n);if(!s.ok)throw s;return tn(s,n.signal)}async function xt(){let{event_callbacks:e,unclosed_events:t,pending_stream_messages:n,stream_status:s,config:r,jwt:o}=this,i=this;if(!r)throw new Error("Could not resolve app config");s.open=!0;let a=null,p=new URLSearchParams({session_hash:this.session_hash}).toString(),c=new URL(`${r.root}${this.api_prefix}/${oe}?${p}`);if(o&&c.searchParams.set("__sign",o),a=this.stream(c),!a){console.warn("Cannot connect to SSE endpoint: "+c.toString());return}a.onmessage=async function(d){let l=JSON.parse(d.data);if(l.msg==="close_stream"){X(s,i.abort_controller);return}let g=l.event_id;if(!g)await Promise.all(Object.keys(e).map(y=>e[y](l)));else if(e[g]&&r){l.msg==="process_completed"&&["sse","sse_v1","sse_v2","sse_v2.1","sse_v3"].includes(r.protocol)&&t.delete(g);let y=e[g];typeof window<"u"&&typeof document<"u"?setTimeout(y,0,l):y(l)}else n[g]||(n[g]=[]),n[g].push(l)},a.onerror=async function(){await Promise.all(Object.keys(e).map(d=>e[d]({msg:"unexpected_error",message:k})))}}function X(e,t){e&&(e.open=!1,t?.abort())}function Ct(e,t,n){!e[t]?(e[t]=[],n.data.forEach((r,o)=>{e[t][o]=r})):n.data.forEach((r,o)=>{let i=nn(e[t][o],r);e[t][o]=i,n.data[o]=i})}function nn(e,t){return t.forEach(([n,s,r])=>{e=sn(e,s,n,r)}),e}function sn(e,t,n,s){if(t.length===0){if(n==="replace")return s;if(n==="append")return e+s;throw new Error(`Unsupported action: ${n}`)}let r=e;for(let i=0;i<t.length-1;i++)r=r[t[i]];let o=t[t.length-1];switch(n){case"replace":r[o]=s;break;case"append":r[o]+=s;break;case"add":Array.isArray(r)?r.splice(Number(o),0,s):r[o]=s;break;case"delete":Array.isArray(r)?r.splice(Number(o),1):delete r[o];break;default:throw new Error(`Unknown action: ${n}`)}return e}function kt(e,t={}){let n={close:()=>{console.warn("Method not implemented.")},onerror:null,onmessage:null,onopen:null,readyState:0,url:e.toString(),withCredentials:!1,CONNECTING:0,OPEN:1,CLOSED:2,addEventListener:()=>{throw new Error("Method not implemented.")},dispatchEvent:()=>{throw new Error("Method not implemented.")},removeEventListener:()=>{throw new Error("Method not implemented.")}};return Rt(e,t).then(async s=>{n.readyState=n.OPEN;try{for await(let r of s)n.onmessage&&n.onmessage(r);n.readyState=n.CLOSED}catch(r){n.onerror&&n.onerror(r),n.readyState=n.CLOSED}}).catch(s=>{console.error(s),n.onerror&&n.onerror(s),n.readyState=n.CLOSED}),n}function Oe(e,t={},n,s,r){try{let m=function(_){(r||Ot[_.type])&&$t(_)},G=function(){for(me=!0;ne.length>0;)ne.shift()({value:void 0,done:!0})},Ue=function(_){me||(ne.length>0?ne.shift()(_):_e.push(_))},Lt=function(_){Ue(rn(_)),G()},$t=function(_){Ue({value:_,done:!1})},ge=function(){return _e.length>0?Promise.resolve(_e.shift()):me?Promise.resolve({value:void 0,done:!0}):new Promise(_=>ne.push(_))},{hf_token:o}=this.options,{fetch:i,app_reference:a,config:p,session_hash:c,api_info:d,api_map:l,stream_status:g,pending_stream_messages:y,pending_diff_streams:D,event_callbacks:Y,unclosed_events:w,post_data:ee,options:L,api_prefix:J}=this,Te=this;if(!d)throw new Error("No API found");if(!p)throw new Error("Could not resolve app config");let{fn_index:u,endpoint_info:Ne,dependency:W}=on(d,e,l,p),Dt=lt(t,Ne),S,H,$=p.protocol??"ws",Le="",It=()=>Le,f=typeof e=="number"?"/predict":e,te,b=null,R=!1,fe={},F=typeof window<"u"&&typeof document<"u"?new URLSearchParams(window.location.search).toString():"",Ot=L?.events?.reduce((_,U)=>(_[U]=!0,_),{})||{};async function Tt(){let _={stage:"complete",queue:!1,time:new Date};R=_,m({..._,type:"status",endpoint:f,fn_index:u});let U={},q={};$==="ws"?(S&&S.readyState===0?S.addEventListener("open",()=>{S.close()}):S.close(),U={fn_index:u,session_hash:c}):(X(g,Te.abort_controller),G(),U={event_id:b},q={event_id:b,session_hash:c,fn_index:u});try{if(!p)throw new Error("Could not resolve app config");"event_id"in q&&await i(`${p.root}${J}/${Qe}`,{headers:{"Content-Type":"application/json"},method:"POST",body:JSON.stringify(q)}),await i(`${p.root}${J}/${Ze}`,{headers:{"Content-Type":"application/json"},method:"POST",body:JSON.stringify(U)})}catch{console.warn("The `/reset` endpoint could not be called. Subsequent endpoint results may be unreliable.")}}let Nt=async _=>{await this._resolve_hearbeat(_)};async function $e(_){if(!p)return;let U=_.render_id;p.components=[...p.components.filter(E=>E.props.rendered_in!==U),..._.components],p.dependencies=[...p.dependencies.filter(E=>E.rendered_in!==U),..._.dependencies];let q=p.components.some(E=>E.type==="state"),h=p.dependencies.some(E=>E.targets.some(I=>I[1]==="unload"));p.connect_heartbeat=q||h,await Nt(p),m({type:"render",data:_,endpoint:f,fn_index:u})}this.handle_blob(p.root,Dt,Ne).then(async _=>{if(te={data:Z(_,W,p.components,"input",!0)||[],event_data:n,fn_index:u,trigger_id:s},dt(u,p))m({type:"status",endpoint:f,stage:"pending",queue:!1,fn_index:u,time:new Date}),ee(`${p.root}${J}/run${f.startsWith("/")?f:`/${f}`}${F?"?"+F:""}`,{...te,session_hash:c}).then(([h,E])=>{let I=h.data;E==200?(m({type:"data",endpoint:f,fn_index:u,data:Z(I,W,p.components,"output",L.with_null_state),time:new Date,event_data:n,trigger_id:s}),h.render_config&&$e(h.render_config),m({type:"status",endpoint:f,fn_index:u,stage:"complete",eta:h.average_duration,queue:!1,time:new Date})):m({type:"status",stage:"error",endpoint:f,fn_index:u,message:h.error,queue:!1,time:new Date})}).catch(h=>{m({type:"status",stage:"error",message:h.message,endpoint:f,fn_index:u,queue:!1,time:new Date})});else if($=="ws"){let{ws_protocol:h,host:E}=await j(a,o);m({type:"status",stage:"pending",queue:!0,endpoint:f,fn_index:u,time:new Date});let I=new URL(`${h}://${Ee(E,p.path,!0)}/queue/join${F?"?"+F:""}`);this.jwt&&I.searchParams.set("__sign",this.jwt),S=new WebSocket(I),S.onclose=A=>{A.wasClean||m({type:"status",stage:"error",broken:!0,message:k,queue:!0,endpoint:f,fn_index:u,time:new Date})},S.onmessage=function(A){let O=JSON.parse(A.data),{type:v,status:x,data:C}=le(O,fe[u]);if(v==="update"&&x&&!R)m({type:"status",endpoint:f,fn_index:u,time:new Date,...x}),x.stage==="error"&&S.close();else if(v==="hash"){S.send(JSON.stringify({fn_index:u,session_hash:c}));return}else v==="data"?S.send(JSON.stringify({...te,session_hash:c})):v==="complete"?R=x:v==="log"?m({type:"log",title:C.title,log:C.log,level:C.level,endpoint:f,duration:C.duration,visible:C.visible,fn_index:u}):v==="generating"&&m({type:"status",time:new Date,...x,stage:x?.stage,queue:!0,endpoint:f,fn_index:u});C&&(m({type:"data",time:new Date,data:Z(C.data,W,p.components,"output",L.with_null_state),endpoint:f,fn_index:u,event_data:n,trigger_id:s}),R&&(m({type:"status",time:new Date,...R,stage:x?.stage,queue:!0,endpoint:f,fn_index:u}),S.close()))},re(p.version||"2.0.0","3.6")<0&&addEventListener("open",()=>S.send(JSON.stringify({hash:c})))}else if($=="sse"){m({type:"status",stage:"pending",queue:!0,endpoint:f,fn_index:u,time:new Date});var q=new URLSearchParams({fn_index:u.toString(),session_hash:c}).toString();let h=new URL(`${p.root}${J}/${oe}?${F?F+"&":""}${q}`);if(this.jwt&&h.searchParams.set("__sign",this.jwt),H=this.stream(h),!H)return Promise.reject(new Error("Cannot connect to SSE endpoint: "+h.toString()));H.onmessage=async function(E){let I=JSON.parse(E.data),{type:A,status:O,data:v}=le(I,fe[u]);if(A==="update"&&O&&!R)m({type:"status",endpoint:f,fn_index:u,time:new Date,...O}),O.stage==="error"&&(H?.close(),G());else if(A==="data"){let[x,C]=await ee(`${p.root}${J}/queue/data`,{...te,session_hash:c,event_id:b});C!==200&&(m({type:"status",stage:"error",message:k,queue:!0,endpoint:f,fn_index:u,time:new Date}),H?.close(),G())}else A==="complete"?R=O:A==="log"?m({type:"log",title:v.title,log:v.log,level:v.level,endpoint:f,duration:v.duration,visible:v.visible,fn_index:u}):(A==="generating"||A==="streaming")&&m({type:"status",time:new Date,...O,stage:O?.stage,queue:!0,endpoint:f,fn_index:u});v&&(m({type:"data",time:new Date,data:Z(v.data,W,p.components,"output",L.with_null_state),endpoint:f,fn_index:u,event_data:n,trigger_id:s}),R&&(m({type:"status",time:new Date,...R,stage:O?.stage,queue:!0,endpoint:f,fn_index:u}),H?.close(),G()))}}else if($=="sse_v1"||$=="sse_v2"||$=="sse_v2.1"||$=="sse_v3"){m({type:"status",stage:"pending",queue:!0,endpoint:f,fn_index:u,time:new Date});let h="";typeof window<"u"&&typeof document<"u"&&(h=window?.location?.hostname);let I=h.includes(".dev.")?`https://moon-${h.split(".")[1]}.dev.spaces.huggingface.tech`:"https://huggingface.co";(typeof window<"u"&&typeof document<"u"&&window.parent!=window&&window.supports_zerogpu_headers?ft("zerogpu-headers",I):Promise.resolve(null)).then(x=>ee(`${p.root}${J}/${qe}?${F}`,{...te,session_hash:c},x)).then(async([x,C])=>{if(C===503)m({type:"status",stage:"error",message:ie,queue:!0,endpoint:f,fn_index:u,time:new Date});else if(C!==200)m({type:"status",stage:"error",message:k,queue:!0,endpoint:f,fn_index:u,time:new Date});else{b=x.event_id,Le=b;let Ge=async function(he){try{let{type:M,status:T,data:P,original_msg:Ut}=le(he,fe[u]);if(M=="heartbeat")return;if(M==="update"&&T&&!R)m({type:"status",endpoint:f,fn_index:u,time:new Date,original_msg:Ut,...T});else if(M==="complete")R=T;else if(M=="unexpected_error")console.error("Unexpected error",T?.message),m({type:"status",stage:"error",message:T?.message||"An Unexpected Error Occurred!",queue:!0,endpoint:f,fn_index:u,time:new Date});else if(M==="log"){m({type:"log",title:P.title,log:P.log,level:P.level,endpoint:f,duration:P.duration,visible:P.visible,fn_index:u});return}else(M==="generating"||M==="streaming")&&(m({type:"status",time:new Date,...T,stage:T?.stage,queue:!0,endpoint:f,fn_index:u}),P&&W.connection!=="stream"&&["sse_v2","sse_v2.1","sse_v3"].includes($)&&Ct(D,b,P));P&&(m({type:"data",time:new Date,data:Z(P.data,W,p.components,"output",L.with_null_state),endpoint:f,fn_index:u}),P.render_config&&await $e(P.render_config),R&&(m({type:"status",time:new Date,...R,stage:T?.stage,queue:!0,endpoint:f,fn_index:u}),G())),(T?.stage==="complete"||T?.stage==="error")&&(Y[b]&&delete Y[b],b in D&&delete D[b])}catch(M){console.error("Unexpected client exception",M),m({type:"status",stage:"error",message:"An Unexpected Error Occurred!",queue:!0,endpoint:f,fn_index:u,time:new Date}),["sse_v2","sse_v2.1","sse_v3"].includes($)&&(X(g,Te.abort_controller),g.open=!1,G())}};b in y&&(y[b].forEach(he=>Ge(he)),delete y[b]),Y[b]=Ge,w.add(b),g.open||await this.open_stream()}})}});let me=!1,_e=[],ne=[],Me={[Symbol.asyncIterator]:()=>Me,next:ge,throw:async _=>(Lt(_),ge()),return:async()=>(G(),ge()),cancel:Tt,event_id:It};return Me}catch(o){throw console.error("Submit function encountered an error:",o),o}}function rn(e){return{then:(t,n)=>n(e)}}function on(e,t,n,s){let r,o,i;if(typeof t=="number")r=t,o=e.unnamed_endpoints[r],i=s.dependencies.find(a=>a.id==t);else{let a=t.replace(/^\//,"");r=n[a],o=e.named_endpoints[t.trim()],i=s.dependencies.find(p=>p.id==n[a])}if(typeof r!="number")throw new Error("There is no endpoint matching that name of fn_index matching that number.");return{fn_index:r,endpoint_info:o,dependency:i}}var z=class{app_reference;options;config;api_prefix="";api_info;api_map={};session_hash=Math.random().toString(36).substring(2);jwt=!1;last_status={};cookies=null;stream_status={open:!1};closed=!1;pending_stream_messages={};pending_diff_streams={};event_callbacks={};unclosed_events=new Set;heartbeat_event=null;abort_controller=null;stream_instance=null;current_payload;ws_map={};get_url_config(t=null){if(!this.config)throw new Error(N);t===null&&(t=window.location.href);let n=i=>i.replace(/^\/+|\/+$/g,""),s=n(new URL(this.config.root).pathname),r=n(new URL(t).pathname),o;return r.startsWith(s)?o=n(r.substring(s.length)):o="",this.get_page_config(o)}get_page_config(t){if(!this.config)throw new Error(N);let n=this.config;return t in n.page||(t=""),{...n,current_page:t,layout:n.page[t].layout,components:n.components.filter(s=>n.page[t].components.includes(s.id)),dependencies:this.config.dependencies.filter(s=>n.page[t].dependencies.includes(s.id))}}fetch(t,n){let s=new Headers(n?.headers||{});if(this&&this.cookies&&s.append("Cookie",this.cookies),this&&this.options.headers)for(let r in this.options.headers)s.append(r,this.options.headers[r]);return fetch(t,{...n,headers:s})}stream(t){let n=new Headers;if(this&&this.cookies&&n.append("Cookie",this.cookies),this&&this.options.headers)for(let s in this.options.headers)n.append(s,this.options.headers[s]);return this.abort_controller=new AbortController,this.stream_instance=kt(t.toString(),{credentials:"include",headers:n,signal:this.abort_controller.signal}),this.stream_instance}view_api;upload_files;upload;handle_blob;post_data;submit;predict;open_stream;resolve_config;resolve_cookies;constructor(t,n={events:["data"]}){this.app_reference=t,n.events||(n.events=["data"]),this.options=n,this.current_payload={},this.view_api=ut.bind(this),this.upload_files=xe.bind(this),this.handle_blob=_t.bind(this),this.post_data=ht.bind(this),this.submit=Oe.bind(this),this.predict=Ae.bind(this),this.open_stream=xt.bind(this),this.resolve_config=at.bind(this),this.resolve_cookies=pt.bind(this),this.upload=Ce.bind(this),this.fetch=this.fetch.bind(this),this.handle_space_success=this.handle_space_success.bind(this),this.stream=this.stream.bind(this)}async init(){if((typeof window>"u"||!("WebSocket"in window))&&!global.WebSocket){let t=await Promise.resolve().then(()=>Jt(Pt(),1));global.WebSocket=t.WebSocket}this.options.auth&&await this.resolve_cookies(),await this._resolve_config().then(({config:t})=>this._resolve_hearbeat(t)),this.api_info=await this.view_api(),this.api_map=it(this.config?.dependencies||[])}async _resolve_hearbeat(t){if(t&&(this.config=t,this.api_prefix=t.api_prefix||"",this.config&&this.config.connect_heartbeat&&this.config.space_id&&this.options.hf_token&&(this.jwt=await Se(this.config.space_id,this.options.hf_token,this.cookies))),t.space_id&&this.options.hf_token&&(this.jwt=await Se(t.space_id,this.options.hf_token)),this.config&&this.config.connect_heartbeat){let n=new URL(`${this.config.root}${this.api_prefix}/${Ve}/${this.session_hash}`);this.jwt&&n.searchParams.set("__sign",this.jwt),this.heartbeat_event||(this.heartbeat_event=this.stream(n))}}static async connect(t,n={events:["data"]}){let s=new this(t,n);return await s.init(),s}close(){this.closed=!0,X(this.stream_status,this.abort_controller)}set_current_payload(t){this.current_payload=t}static async duplicate(t,n={events:["data"]}){return vt(t,n)}async _resolve_config(){let{http_protocol:t,host:n,space_id:s}=await j(this.app_reference,this.options.hf_token),{status_callback:r}=this.options;s&&r&&await Pe(s,r);let o;try{if(o=await this.resolve_config(`${t}//${n}`),!o)throw new Error(N);return this.config_success(o)}catch(i){if(s&&r)Q(s,se.test(s)?"space_name":"subdomain",this.handle_space_success);else throw r&&r({status:"error",message:"Could not load this space.",load_status:"error",detail:"NOT_FOUND"}),Error(i)}}async config_success(t){if(this.config=t,this.api_prefix=t.api_prefix||"",typeof window<"u"&&typeof document<"u"&&window.location.protocol==="https:"&&(this.config.root=this.config.root.replace("http://","https://")),this.config.auth_required)return this.prepare_return_obj();try{this.api_info=await this.view_api()}catch(n){console.error(et+n.message)}return this.prepare_return_obj()}async handle_space_success(t){if(!this)throw new Error(N);let{status_callback:n}=this.options;if(n&&n(t),t.status==="running")try{if(this.config=await this._resolve_config(),this.api_prefix=this?.config?.api_prefix||"",!this.config)throw new Error(N);return await this.config_success(this.config)}catch(s){throw n&&n({status:"error",message:"Could not load this space.",load_status:"error",detail:"NOT_FOUND"}),s}}async component_server(t,n,s){if(!this.config)throw new Error(N);let r={},{hf_token:o}=this.options,{session_hash:i}=this;o&&(r.Authorization=`Bearer ${this.options.hf_token}`);let a,p=this.config.components.find(d=>d.id===t);p?.props?.root_url?a=p.props.root_url:a=this.config.root;let c;if("binary"in s){c=new FormData;for(let d in s.data)d!=="binary"&&c.append(d,s.data[d]);c.set("component_id",t.toString()),c.set("fn_name",n),c.set("session_hash",i)}else c=JSON.stringify({data:s,component_id:t,fn_name:n,session_hash:i}),r["Content-Type"]="application/json";o&&(r.Authorization=`Bearer ${o}`);try{let d=await this.fetch(`${a}${this.api_prefix}/${Ke}/`,{method:"POST",body:c,headers:r,credentials:"include"});if(!d.ok)throw new Error("Could not connect to component server: "+d.statusText);return await d.json()}catch(d){console.warn(d)}}set_cookies(t){this.cookies=pe(t).join("; ")}prepare_return_obj(){return{config:this.config,predict:this.predict,submit:this.submit,view_api:this.view_api,component_server:this.component_server}}async connect_ws(t){return new Promise((n,s)=>{let r;try{r=new WebSocket(t)}catch{this.ws_map[t]="failed";return}r.onopen=()=>{n()},r.onerror=o=>{console.error("WebSocket error:",o),this.close_ws(t),this.ws_map[t]="failed",n()},r.onclose=()=>{delete this.ws_map[t],this.ws_map[t]="failed"},r.onmessage=o=>{},this.ws_map[t]=r})}async send_ws_message(t,n){t in this.ws_map||await this.connect_ws(t);let s=this.ws_map[t];s instanceof WebSocket?s.send(JSON.stringify(n)):this.post_data(t,n)}async close_ws(t){if(t in this.ws_map){let n=this.ws_map[t];n instanceof WebSocket&&(n.close(),delete this.ws_map[t])}}};export{z as Client,mt as handle_file};
app/static/index.html ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light" />
7
+ <meta
8
+ name="description"
9
+ content="Interactive demo for What Moves? Localized Motion Representations for Compositional Scene Control."
10
+ />
11
+ <title>What Moves? — Interactive Demo</title>
12
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260904h" />
13
+ <link rel="stylesheet" href="/static/styles.css?v=20260904h" />
14
+ </head>
15
+ <body>
16
+ <header class="page-header">
17
+ <div class="app-brand">
18
+ <div>
19
+ <h1><em>What Moves?</em></h1>
20
+ <p>Transfer localized motion from videos to a still image.</p>
21
+ </div>
22
+ </div>
23
+ <div class="header-actions">
24
+ <button id="startOver" class="start-over-button" title="Remove all current media and start over">
25
+ <span class="broom-icon" aria-hidden="true"></span>
26
+ <span>Start Over</span>
27
+ </button>
28
+ </div>
29
+ </header>
30
+
31
+ <main>
32
+ <div class="workspace">
33
+ <section class="panel source-panel">
34
+ <div class="panel-heading">
35
+ <div>
36
+ <span class="eyebrow">Motion</span>
37
+ <h2>Source videos</h2>
38
+ </div>
39
+ <button id="removeSource" class="danger-quiet hidden">Remove</button>
40
+ </div>
41
+
42
+ <div class="source-workspace">
43
+ <aside id="sourceRail" class="source-rail" aria-label="Uploaded source videos">
44
+ <button id="sourceRailUpload" class="source-upload-tile" title="Upload source video">+</button>
45
+ </aside>
46
+ <div class="source-main">
47
+ <div id="sourceCanvasHost" class="canvas-host">
48
+ <video id="sourceVideo" muted playsinline></video>
49
+ <canvas id="sourceCanvas"></canvas>
50
+ <button id="sourceEmpty" class="empty-state">
51
+ <span class="empty-icon">↥</span>
52
+ <strong>Upload a source video</strong>
53
+ <small>It will be sampled at 8 fps and appear here.</small>
54
+ </button>
55
+ <div id="sourceCanvasStatus" class="canvas-status hidden"></div>
56
+ <div id="sourceModeBadge" class="mode-badge hidden">Masking</div>
57
+ <div id="sourceLoader" class="media-loader hidden" role="status" aria-label="Loading source media"><span class="loading-spinner"></span></div>
58
+ </div>
59
+ <div id="sourceTools" class="canvas-tools hidden">
60
+ <button id="sourceMaskMode" class="tool-button">Start masking</button>
61
+ <span class="shortcut-hint">Click foreground · right-click background · drag a box</span>
62
+ <div class="tool-spacer"></div>
63
+ <button id="sourcePlay" class="tool-button playback-button" aria-label="Play" title="Play">
64
+ <svg class="playback-icon" viewBox="0 0 24 24" aria-hidden="true">
65
+ <path d="M8 5v14l11-7z"></path>
66
+ </svg>
67
+ </button>
68
+ <button id="sourceCancelDraft" class="text-button hidden">Cancel</button>
69
+ <button id="sourceAddMask" class="primary-small hidden">Add mask</button>
70
+ </div>
71
+ <div id="sourceSettings" class="source-settings hidden">
72
+ <span id="sourceTime" class="source-time hidden" aria-hidden="true"></span>
73
+ <div id="sourceTimeline" class="video-timeline hidden" aria-label="Source video timeline">
74
+ <div class="timeline-filmstrip"></div>
75
+ <div class="timeline-selection"></div>
76
+ <div class="timeline-before"></div>
77
+ <div class="timeline-after"></div>
78
+ <div class="timeline-masks"></div>
79
+ <div class="timeline-playhead"></div>
80
+ <div class="trim-handle left" data-handle="left"></div>
81
+ <div class="trim-handle right" data-handle="right"></div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </section>
87
+
88
+ <div class="target-stack">
89
+ <section class="panel target-panel">
90
+ <div class="panel-heading">
91
+ <div>
92
+ <span class="eyebrow">Appearance</span>
93
+ <h2>Target image</h2>
94
+ </div>
95
+ <div class="panel-actions">
96
+ <button id="removeTarget" class="danger-quiet hidden">Remove</button>
97
+ </div>
98
+ </div>
99
+ <div class="target-workspace">
100
+ <div class="target-main">
101
+ <div id="targetCanvasHost" class="canvas-host target-host">
102
+ <canvas id="targetCanvas"></canvas>
103
+ <button id="targetEmpty" class="empty-state">
104
+ <span class="empty-icon">↥</span>
105
+ <strong>Upload a target image</strong>
106
+ <small>Create grey regions, then drag colored source masks onto them.</small>
107
+ </button>
108
+ <div id="targetCanvasStatus" class="canvas-status hidden"></div>
109
+ <div id="targetModeBadge" class="mode-badge hidden">Masking</div>
110
+ <div id="targetLoader" class="media-loader hidden" role="status" aria-label="Loading target image"><span class="loading-spinner"></span></div>
111
+ </div>
112
+ <div id="targetTools" class="canvas-tools hidden">
113
+ <button id="targetMaskMode" class="tool-button">Start masking</button>
114
+ <span class="shortcut-hint">Click foreground · right-click background · drag a box</span>
115
+ <div class="tool-spacer"></div>
116
+ <button id="targetCancelDraft" class="text-button hidden">Cancel</button>
117
+ <button id="targetAddMask" class="primary-small hidden">Add mask</button>
118
+ </div>
119
+ </div>
120
+ <aside id="targetRail" class="target-rail" aria-label="Uploaded target images"></aside>
121
+ </div>
122
+ </section>
123
+
124
+ <section class="generate-card">
125
+ <label for="prompt" class="prompt-label">Describe the target video</label>
126
+ <textarea id="prompt" rows="2" placeholder="Describe how the bear and dog should move">A bear and a dog together in the forest.</textarea>
127
+ <details id="advancedSettings">
128
+ <summary>Advanced settings</summary>
129
+ <div class="advanced-grid">
130
+ <label>Guidance
131
+ <select id="guidanceMode">
132
+ <option value="text_cfg">Text CFG</option>
133
+ <option value="joint_cfg">Joint CFG</option>
134
+ <option value="motion_cfg">Motion CFG</option>
135
+ <option value="additive_cfg">Additive CFG</option>
136
+ <option value="factorized_cfg">Factorized CFG</option>
137
+ <option value="base_cfg">Base Wan (no motion)</option>
138
+ </select>
139
+ </label>
140
+ <label>Steps <input id="steps" type="number" min="1" max="100" value="40" /></label>
141
+ <label>Seed <input id="seed" type="number" value="42" /></label>
142
+ <label>Text guidance <input id="textGuidance" type="number" step="0.1" value="3.5" /></label>
143
+ <label>Motion guidance <input id="motionGuidance" type="number" step="0.1" value="1.0" /></label>
144
+ <label>LoRA scale <input id="loraScale" type="number" step="0.1" value="1.0" /></label>
145
+ <label class="negative-prompt">Negative prompt
146
+ <input id="negativePrompt" type="text" placeholder="Use the Wan default" />
147
+ </label>
148
+ </div>
149
+ </details>
150
+ <button id="generateButton" class="generate-button">
151
+ <span id="generateButtonProgress" class="generate-button-progress"></span>
152
+ <span class="generate-button-content">
153
+ <span id="generateButtonLabel">Generate video</span>
154
+ <span id="generateButtonCount"></span>
155
+ </span>
156
+ </button>
157
+ <div id="generatedRail" class="generated-rail hidden" aria-label="Generated videos"></div>
158
+ <p id="generateHint" class="generate-hint">Upload media and create a motion mapping.</p>
159
+ </section>
160
+ </div>
161
+ </div>
162
+
163
+ <section id="resultPanel" class="result-panel hidden">
164
+ <div class="result-heading">
165
+ <div>
166
+ <span class="eyebrow">Result</span>
167
+ <h2>Generated video</h2>
168
+ </div>
169
+ <div class="result-actions">
170
+ <span id="resultMeta"></span>
171
+ <button id="resultClose" class="icon-button result-close" aria-label="Close generated video" title="Close">×</button>
172
+ </div>
173
+ </div>
174
+ <div class="result-media">
175
+ <video id="resultVideo" controls loop playsinline></video>
176
+ <div id="resultLoader" class="media-loader hidden" role="status" aria-label="Loading generated video"><span class="loading-spinner"></span></div>
177
+ </div>
178
+ </section>
179
+ </main>
180
+
181
+ <input id="sourceFile" type="file" accept="video/*" hidden />
182
+ <input id="targetFile" type="file" accept="image/*" hidden />
183
+ <div id="contextMenu" class="context-menu hidden"></div>
184
+ <div id="dragGhost" class="drag-ghost hidden"></div>
185
+ <div id="mappingHint" class="mapping-hint hidden" aria-hidden="true"></div>
186
+ <div id="toast" class="toast hidden"></div>
187
+
188
+ <script type="module" src="/static/app.js?v=20260904l"></script>
189
+ </body>
190
+ </html>
app/static/mask-canvas.js ADDED
@@ -0,0 +1,562 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const GREY = "#a8afb9";
2
+
3
+ function makeCanvas(width, height) {
4
+ const canvas = document.createElement("canvas");
5
+ canvas.width = width;
6
+ canvas.height = height;
7
+ return canvas;
8
+ }
9
+
10
+ async function bitmapFromUrl(url) {
11
+ const response = await fetch(url);
12
+ if (!response.ok) throw new Error(`Could not load image (${response.status})`);
13
+ return createImageBitmap(await response.blob());
14
+ }
15
+
16
+ function boundaryDistance(pixels, width, height) {
17
+ const infinity = 65535;
18
+ const distance = new Uint16Array(width * height);
19
+ distance.fill(infinity);
20
+ for (let y = 0; y < height; y += 1) {
21
+ for (let x = 0; x < width; x += 1) {
22
+ const index = y * width + x;
23
+ const value = pixels[index];
24
+ const boundary = (value && (x === 0 || y === 0 || x === width - 1 || y === height - 1)) ||
25
+ (x && pixels[index - 1] !== value) ||
26
+ (x + 1 < width && pixels[index + 1] !== value) ||
27
+ (y && pixels[index - width] !== value) ||
28
+ (y + 1 < height && pixels[index + width] !== value);
29
+ if (boundary) distance[index] = 0;
30
+ }
31
+ }
32
+ for (let y = 0; y < height; y += 1) {
33
+ for (let x = 0; x < width; x += 1) {
34
+ const index = y * width + x;
35
+ let best = distance[index];
36
+ if (x) best = Math.min(best, distance[index - 1] + 3);
37
+ if (y) best = Math.min(best, distance[index - width] + 3);
38
+ if (x && y) best = Math.min(best, distance[index - width - 1] + 4);
39
+ if (x + 1 < width && y) best = Math.min(best, distance[index - width + 1] + 4);
40
+ distance[index] = best;
41
+ }
42
+ }
43
+ for (let y = height - 1; y >= 0; y -= 1) {
44
+ for (let x = width - 1; x >= 0; x -= 1) {
45
+ const index = y * width + x;
46
+ let best = distance[index];
47
+ if (x + 1 < width) best = Math.min(best, distance[index + 1] + 3);
48
+ if (y + 1 < height) best = Math.min(best, distance[index + width] + 3);
49
+ if (x + 1 < width && y + 1 < height) best = Math.min(best, distance[index + width + 1] + 4);
50
+ if (x && y + 1 < height) best = Math.min(best, distance[index + width - 1] + 4);
51
+ distance[index] = best;
52
+ }
53
+ }
54
+ return distance;
55
+ }
56
+
57
+ async function loadMask(url, width, height, record) {
58
+ const bitmap = await bitmapFromUrl(url);
59
+ const raster = makeCanvas(width, height);
60
+ const rasterContext = raster.getContext("2d", { willReadFrequently: true });
61
+ rasterContext.drawImage(bitmap, 0, 0, width, height);
62
+ bitmap.close();
63
+ const rgba = rasterContext.getImageData(0, 0, width, height).data;
64
+ const pixels = new Uint8Array(width * height);
65
+ for (let index = 0; index < pixels.length; index += 1) {
66
+ pixels[index] = rgba[index * 4] > 127 ? 1 : 0;
67
+ }
68
+
69
+ const alpha = makeCanvas(width, height);
70
+ const alphaContext = alpha.getContext("2d");
71
+ const alphaImage = alphaContext.createImageData(width, height);
72
+ for (let y = 0; y < height; y += 1) {
73
+ for (let x = 0; x < width; x += 1) {
74
+ const index = y * width + x;
75
+ if (!pixels[index]) continue;
76
+ const rgbaIndex = index * 4;
77
+ alphaImage.data[rgbaIndex] = 255;
78
+ alphaImage.data[rgbaIndex + 1] = 255;
79
+ alphaImage.data[rgbaIndex + 2] = 255;
80
+ alphaImage.data[rgbaIndex + 3] = 255;
81
+ }
82
+ }
83
+ alphaContext.putImageData(alphaImage, 0, 0);
84
+ return {
85
+ ...record,
86
+ pixels,
87
+ alpha,
88
+ edgeDistance: boundaryDistance(pixels, width, height),
89
+ edgeCanvases: new Map(),
90
+ };
91
+ }
92
+
93
+ export class MaskCanvas {
94
+ constructor({ host, canvas, role, callbacks = {} }) {
95
+ this.host = host;
96
+ this.canvas = canvas;
97
+ this.context = canvas.getContext("2d");
98
+ this.role = role;
99
+ this.callbacks = callbacks;
100
+ this.asset = null;
101
+ this.assetSignature = "";
102
+ this.base = null;
103
+ this.masks = [];
104
+ this.draft = null;
105
+ this.hoverDraft = null;
106
+ this.hoverPoint = null;
107
+ this.positive = [];
108
+ this.negative = [];
109
+ this.box = null;
110
+ this.promptRevision = 0;
111
+ this.hovered = null;
112
+ this.dropHovered = null;
113
+ this.pinned = false;
114
+ this.pointerInside = false;
115
+ this.pointerDown = null;
116
+ this.loadToken = 0;
117
+ this.scratch = makeCanvas(1, 1);
118
+
119
+ canvas.addEventListener("pointerenter", () => {
120
+ this.pointerInside = true;
121
+ this._modeChanged();
122
+ });
123
+ canvas.addEventListener("pointerleave", () => {
124
+ this.pointerInside = false;
125
+ if (!this.pointerDown) this.hovered = null;
126
+ this.clearHoverDraft();
127
+ this.hoverPoint = null;
128
+ this.callbacks.onHoverPrompt?.(null);
129
+ this._modeChanged();
130
+ this.render();
131
+ });
132
+ canvas.addEventListener("pointerdown", (event) => this._pointerDown(event));
133
+ canvas.addEventListener("pointermove", (event) => this._pointerMove(event));
134
+ canvas.addEventListener("pointerup", (event) => this._pointerUp(event));
135
+ canvas.addEventListener("pointercancel", () => this._cancelPointer());
136
+ canvas.addEventListener("contextmenu", (event) => this._contextMenu(event));
137
+ this.resizeObserver = new ResizeObserver(() => this._resizeDisplay());
138
+ this.resizeObserver.observe(host);
139
+ }
140
+
141
+ async setAsset(asset, masks = []) {
142
+ if (!asset) {
143
+ this.loadToken += 1;
144
+ this.asset = null;
145
+ this.assetSignature = "";
146
+ if (this.base) this.base.close();
147
+ this.base = null;
148
+ this.masks = [];
149
+ this.clearDraft();
150
+ this.host.classList.remove("has-image");
151
+ this.callbacks.onLoading?.(false);
152
+ return;
153
+ }
154
+ const signature = JSON.stringify([
155
+ asset.image_url,
156
+ masks.map((mask) => [mask.id, mask.url, mask.displayColor]),
157
+ ]);
158
+ if (signature === this.assetSignature) return;
159
+ const mediaChanged = !this.asset || this.asset.image_url !== asset.image_url;
160
+ const token = ++this.loadToken;
161
+ this.callbacks.onLoading?.(true);
162
+ try {
163
+ const base = mediaChanged ? await bitmapFromUrl(asset.image_url) : this.base;
164
+ if (token !== this.loadToken) {
165
+ if (mediaChanged) base.close();
166
+ return;
167
+ }
168
+ if (mediaChanged) {
169
+ if (this.base) this.base.close();
170
+ this.base = base;
171
+ this.canvas.width = base.width;
172
+ this.canvas.height = base.height;
173
+ this.scratch = makeCanvas(base.width, base.height);
174
+ this.clearDraft();
175
+ }
176
+ const loadedMasks = await Promise.all(
177
+ masks.map((mask) => loadMask(mask.url, this.canvas.width, this.canvas.height, mask)),
178
+ );
179
+ if (token !== this.loadToken) return;
180
+ this.asset = asset;
181
+ this.assetSignature = signature;
182
+ this.masks = loadedMasks;
183
+ this.host.classList.add("has-image");
184
+ this._resizeDisplay();
185
+ this.render();
186
+ } catch (error) {
187
+ this.callbacks.onError?.(error);
188
+ } finally {
189
+ if (token === this.loadToken) this.callbacks.onLoading?.(false);
190
+ }
191
+ }
192
+
193
+ togglePinned() {
194
+ return this.setPinned(!this.pinned);
195
+ }
196
+
197
+ setPinned(value) {
198
+ this.pinned = Boolean(value);
199
+ if (!this.pinned) {
200
+ this.clearHoverDraft();
201
+ this.hoverPoint = null;
202
+ this.callbacks.onHoverPrompt?.(null);
203
+ }
204
+ this._modeChanged();
205
+ this.render();
206
+ return this.pinned;
207
+ }
208
+
209
+ isMasking() {
210
+ return Boolean(this.asset && this.pinned);
211
+ }
212
+
213
+ promptPayload() {
214
+ return {
215
+ asset_revision: this.asset?.revision,
216
+ frame_index: this.role === "source" ? this.asset?.current_frame : null,
217
+ prompt_revision: this.promptRevision,
218
+ positive: this.positive,
219
+ negative: this.negative,
220
+ box: this.box,
221
+ };
222
+ }
223
+
224
+ async setDraft(base64, promptRevision) {
225
+ if (promptRevision !== this.promptRevision || !this.asset) return false;
226
+ const record = { id: "draft", url: `data:image/png;base64,${base64}` };
227
+ const draft = await loadMask(record.url, this.canvas.width, this.canvas.height, record);
228
+ if (promptRevision !== this.promptRevision) return false;
229
+ this.draft = draft;
230
+ this.callbacks.onDraftState?.(true);
231
+ this.render();
232
+ return true;
233
+ }
234
+
235
+ async setHoverDraft(base64, promptRevision) {
236
+ if (promptRevision !== this.promptRevision || !this.asset || !this.isMasking()) return false;
237
+ const record = { id: "hover-draft", url: `data:image/png;base64,${base64}` };
238
+ const draft = await loadMask(record.url, this.canvas.width, this.canvas.height, record);
239
+ if (promptRevision !== this.promptRevision || !this.isMasking()) return false;
240
+ this.hoverDraft = draft;
241
+ this.render();
242
+ return true;
243
+ }
244
+
245
+ clearHoverDraft() {
246
+ if (!this.hoverDraft) return;
247
+ this.hoverDraft = null;
248
+ this.hoverPoint = null;
249
+ this.render();
250
+ }
251
+
252
+ clearDraft() {
253
+ this.positive = [];
254
+ this.negative = [];
255
+ this.box = null;
256
+ this.draft = null;
257
+ this.hoverDraft = null;
258
+ this.promptRevision += 1;
259
+ this.callbacks.onDraftState?.(false);
260
+ this.render();
261
+ }
262
+
263
+ masksAtClient(clientX, clientY) {
264
+ const point = this._point({ clientX, clientY });
265
+ if (!point) return [];
266
+ return this.masks
267
+ .filter((mask) => mask.pixels[Math.floor(point.y) * this.canvas.width + Math.floor(point.x)])
268
+ .reverse();
269
+ }
270
+
271
+ setDropHover(maskId) {
272
+ if (this.dropHovered === maskId) return;
273
+ this.dropHovered = maskId;
274
+ this.render();
275
+ }
276
+
277
+ getMask(maskId) {
278
+ return this.masks.find((value) => value.id === maskId) || null;
279
+ }
280
+
281
+ maskClientCenter(maskId) {
282
+ const mask = this.masks.find((value) => value.id === maskId);
283
+ if (!mask || !this.canvas.width || !this.canvas.height) return null;
284
+ let minX = this.canvas.width;
285
+ let minY = this.canvas.height;
286
+ let maxX = -1;
287
+ let maxY = -1;
288
+ for (let y = 0; y < this.canvas.height; y += 1) {
289
+ for (let x = 0; x < this.canvas.width; x += 1) {
290
+ if (!mask.pixels[y * this.canvas.width + x]) continue;
291
+ minX = Math.min(minX, x);
292
+ minY = Math.min(minY, y);
293
+ maxX = Math.max(maxX, x);
294
+ maxY = Math.max(maxY, y);
295
+ }
296
+ }
297
+ if (maxX < 0 || maxY < 0) return null;
298
+ const rect = this.canvas.getBoundingClientRect();
299
+ return {
300
+ x: rect.left + ((minX + maxX) / 2 / this.canvas.width) * rect.width,
301
+ y: rect.top + ((minY + maxY) / 2 / this.canvas.height) * rect.height,
302
+ };
303
+ }
304
+
305
+ render() {
306
+ if (!this.base) return;
307
+ const context = this.context;
308
+ context.clearRect(0, 0, this.canvas.width, this.canvas.height);
309
+ context.drawImage(this.base, 0, 0, this.canvas.width, this.canvas.height);
310
+ for (const mask of this.masks) {
311
+ const color = mask.displayColor || mask.color || GREY;
312
+ this._drawTint(mask.alpha, color, 0.28);
313
+ const hovered = mask.id === this.hovered;
314
+ const dropping = mask.id === this.dropHovered;
315
+ this._drawEdge(mask, dropping ? "#ffffff" : color, hovered || dropping ? 4 : 2);
316
+ }
317
+ const preview = this.hoverDraft || this.draft;
318
+ if (preview && this.isMasking()) {
319
+ this._drawTint(preview.alpha, "#ffffff", this.hoverDraft ? 0.16 : 0.2);
320
+ this._drawEdge(preview, "#ffffff", 2);
321
+ }
322
+ if (this.isMasking()) this._drawPrompts();
323
+ }
324
+
325
+ _drawTint(alpha, color, opacity) {
326
+ const scratch = this.scratch;
327
+ const scratchContext = scratch.getContext("2d");
328
+ scratchContext.clearRect(0, 0, scratch.width, scratch.height);
329
+ scratchContext.globalCompositeOperation = "source-over";
330
+ scratchContext.fillStyle = color;
331
+ scratchContext.fillRect(0, 0, scratch.width, scratch.height);
332
+ scratchContext.globalCompositeOperation = "destination-in";
333
+ scratchContext.drawImage(alpha, 0, 0);
334
+ scratchContext.globalCompositeOperation = "source-over";
335
+ this.context.save();
336
+ this.context.globalAlpha = opacity;
337
+ this.context.drawImage(scratch, 0, 0);
338
+ this.context.restore();
339
+ }
340
+
341
+ _drawEdge(mask, color, cssRadius) {
342
+ const rect = this.canvas.getBoundingClientRect();
343
+ const radius = Math.max(1, Math.round(cssRadius * this.canvas.width / Math.max(rect.width, 1)));
344
+ let edge = mask.edgeCanvases.get(radius);
345
+ if (!edge) {
346
+ edge = makeCanvas(this.canvas.width, this.canvas.height);
347
+ const edgeContext = edge.getContext("2d");
348
+ const image = edgeContext.createImageData(edge.width, edge.height);
349
+ const threshold = radius * 3;
350
+ for (let index = 0; index < mask.edgeDistance.length; index += 1) {
351
+ if (mask.edgeDistance[index] > threshold) continue;
352
+ const rgba = index * 4;
353
+ image.data[rgba] = 255;
354
+ image.data[rgba + 1] = 255;
355
+ image.data[rgba + 2] = 255;
356
+ image.data[rgba + 3] = 255;
357
+ }
358
+ edgeContext.putImageData(image, 0, 0);
359
+ mask.edgeCanvases.set(radius, edge);
360
+ }
361
+ const scratchContext = this.scratch.getContext("2d");
362
+ scratchContext.clearRect(0, 0, this.scratch.width, this.scratch.height);
363
+ scratchContext.globalCompositeOperation = "source-over";
364
+ scratchContext.fillStyle = color;
365
+ scratchContext.fillRect(0, 0, this.scratch.width, this.scratch.height);
366
+ scratchContext.globalCompositeOperation = "destination-in";
367
+ scratchContext.drawImage(edge, 0, 0);
368
+ scratchContext.globalCompositeOperation = "source-over";
369
+ this.context.drawImage(this.scratch, 0, 0);
370
+ }
371
+
372
+ _drawPrompts() {
373
+ const context = this.context;
374
+ const rect = this.canvas.getBoundingClientRect();
375
+ const scale = this.canvas.width / Math.max(rect.width, 1);
376
+ const radius = Math.max(5, 5 * scale);
377
+ const drawPoint = (point, fill, sign) => {
378
+ const x = point[0] * this.canvas.width;
379
+ const y = point[1] * this.canvas.height;
380
+ context.beginPath();
381
+ context.arc(x, y, radius, 0, Math.PI * 2);
382
+ context.fillStyle = fill;
383
+ context.fill();
384
+ context.lineWidth = Math.max(2, 1.5 * scale);
385
+ context.strokeStyle = "white";
386
+ context.stroke();
387
+ context.beginPath();
388
+ context.moveTo(x - radius * 0.45, y);
389
+ context.lineTo(x + radius * 0.45, y);
390
+ if (sign === "+") {
391
+ context.moveTo(x, y - radius * 0.45);
392
+ context.lineTo(x, y + radius * 0.45);
393
+ }
394
+ context.strokeStyle = "white";
395
+ context.lineWidth = Math.max(1.5, scale);
396
+ context.stroke();
397
+ };
398
+ this.positive.forEach((point) => drawPoint(point, "#29b889", "+"));
399
+ this.negative.forEach((point) => drawPoint(point, "#df5b61", "−"));
400
+ if (this.hoverPoint) drawPoint(this.hoverPoint, "#28c997", "+");
401
+ const box = this.pointerDown?.mode === "mask" && this.pointerDown.dragging
402
+ ? this._normalizedBox(this.pointerDown.start, this.pointerDown.current)
403
+ : this.box;
404
+ if (box) {
405
+ context.strokeStyle = "rgba(255,255,255,.95)";
406
+ context.lineWidth = Math.max(2, 2 * scale);
407
+ context.setLineDash([7 * scale, 5 * scale]);
408
+ context.strokeRect(
409
+ box[0] * this.canvas.width,
410
+ box[1] * this.canvas.height,
411
+ (box[2] - box[0]) * this.canvas.width,
412
+ (box[3] - box[1]) * this.canvas.height,
413
+ );
414
+ context.setLineDash([]);
415
+ }
416
+ }
417
+
418
+ _point(event) {
419
+ if (!this.base) return null;
420
+ const rect = this.canvas.getBoundingClientRect();
421
+ if (!rect.width || !rect.height) return null;
422
+ const x = (event.clientX - rect.left) / rect.width * this.canvas.width;
423
+ const y = (event.clientY - rect.top) / rect.height * this.canvas.height;
424
+ if (x < 0 || y < 0 || x >= this.canvas.width || y >= this.canvas.height) return null;
425
+ return { x, y, clientX: event.clientX, clientY: event.clientY };
426
+ }
427
+
428
+ _normalized(point) {
429
+ return [
430
+ Math.min(1, Math.max(0, point.x / this.canvas.width)),
431
+ Math.min(1, Math.max(0, point.y / this.canvas.height)),
432
+ ];
433
+ }
434
+
435
+ _normalizedBox(first, second) {
436
+ const a = this._normalized(first);
437
+ const b = this._normalized(second);
438
+ return [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[0], b[0]), Math.max(a[1], b[1])];
439
+ }
440
+
441
+ _pointerDown(event) {
442
+ if (!this.asset || event.button !== 0) return;
443
+ const point = this._point(event);
444
+ if (!point) return;
445
+ const mode = this.isMasking() ? "mask" : "normal";
446
+ if (mode === "mask") {
447
+ this.clearHoverDraft();
448
+ this.hoverPoint = null;
449
+ this.callbacks.onHoverPrompt?.(null);
450
+ }
451
+ const hit = this.masksAtClient(event.clientX, event.clientY)[0] || null;
452
+ this.pointerDown = { start: point, current: point, mode, hit, dragging: false };
453
+ this.canvas.setPointerCapture(event.pointerId);
454
+ event.preventDefault();
455
+ }
456
+
457
+ _pointerMove(event) {
458
+ const point = this._point(event);
459
+ if (this.pointerDown) {
460
+ const clientPoint = point || { clientX: event.clientX, clientY: event.clientY };
461
+ const distance = Math.hypot(clientPoint.clientX - this.pointerDown.start.clientX, clientPoint.clientY - this.pointerDown.start.clientY);
462
+ if (distance > 5) this.pointerDown.dragging = true;
463
+ if (this.pointerDown.mode === "normal" && this.pointerDown.dragging && this.role === "source" && this.pointerDown.hit) {
464
+ this.callbacks.onMappingDrag?.("move", this.pointerDown.hit, event.clientX, event.clientY);
465
+ } else if (point) {
466
+ this.pointerDown.current = point;
467
+ }
468
+ this.render();
469
+ return;
470
+ }
471
+ if (this.isMasking() && point) {
472
+ this.hovered = null;
473
+ this.hoverPoint = this._normalized(point);
474
+ const payload = this.promptPayload();
475
+ payload.positive = [...payload.positive, this.hoverPoint];
476
+ payload.transient = true;
477
+ this.callbacks.onHoverPrompt?.(payload, point.clientX, point.clientY);
478
+ this.render();
479
+ return;
480
+ }
481
+ const next = this.masksAtClient(event.clientX, event.clientY)[0]?.id || null;
482
+ this.hoverPoint = null;
483
+ if (next !== this.hovered) {
484
+ this.hovered = next;
485
+ this._modeChanged();
486
+ this.render();
487
+ }
488
+ }
489
+
490
+ _pointerUp(event) {
491
+ if (!this.pointerDown || event.button !== 0) return;
492
+ const interaction = this.pointerDown;
493
+ const point = this._point(event) || interaction.current;
494
+ this.pointerDown = null;
495
+ if (interaction.mode === "mask") {
496
+ if (interaction.dragging) {
497
+ const box = this._normalizedBox(interaction.start, point);
498
+ if ((box[2] - box[0]) * this.canvas.width > 3 && (box[3] - box[1]) * this.canvas.height > 3) {
499
+ this.box = box;
500
+ this._promptsChanged();
501
+ }
502
+ } else {
503
+ this.positive.push(this._normalized(point));
504
+ this._promptsChanged();
505
+ }
506
+ } else if (interaction.dragging && this.role === "source" && interaction.hit) {
507
+ this.callbacks.onMappingDrag?.("drop", interaction.hit, event.clientX, event.clientY);
508
+ } else {
509
+ const hits = this.masksAtClient(event.clientX, event.clientY);
510
+ if (hits.length) this.callbacks.onMaskClick?.(hits, event.clientX, event.clientY);
511
+ }
512
+ this.render();
513
+ }
514
+
515
+ _contextMenu(event) {
516
+ if (!this.isMasking() || !this.asset) return;
517
+ event.preventDefault();
518
+ const point = this._point(event);
519
+ if (!point) return;
520
+ this.negative.push(this._normalized(point));
521
+ this._promptsChanged();
522
+ }
523
+
524
+ _cancelPointer() {
525
+ if (this.pointerDown?.mode === "normal" && this.pointerDown.hit) {
526
+ this.callbacks.onMappingDrag?.("cancel", this.pointerDown.hit, 0, 0);
527
+ }
528
+ this.pointerDown = null;
529
+ this.render();
530
+ }
531
+
532
+ _promptsChanged() {
533
+ this.promptRevision += 1;
534
+ this.draft = null;
535
+ this.hoverDraft = null;
536
+ this.hoverPoint = null;
537
+ this.callbacks.onHoverPrompt?.(null);
538
+ this.callbacks.onDraftState?.(false);
539
+ this.callbacks.onPromptsChanged?.(this.promptPayload());
540
+ this.render();
541
+ }
542
+
543
+ _modeChanged() {
544
+ const active = this.isMasking();
545
+ if (!active) {
546
+ this.hoverDraft = null;
547
+ this.hoverPoint = null;
548
+ }
549
+ this.canvas.style.cursor = active ? "crosshair" : (this.hovered ? "pointer" : "default");
550
+ this.callbacks.onModeChange?.(active, this.pinned);
551
+ }
552
+
553
+ _resizeDisplay() {
554
+ if (!this.base) return;
555
+ const width = this.host.clientWidth;
556
+ const height = this.host.clientHeight;
557
+ const scale = Math.min(width / this.canvas.width, height / this.canvas.height);
558
+ this.canvas.style.width = `${Math.max(1, this.canvas.width * scale)}px`;
559
+ this.canvas.style.height = `${Math.max(1, this.canvas.height * scale)}px`;
560
+ this.render();
561
+ }
562
+ }
app/static/styles.css ADDED
@@ -0,0 +1,1130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --background: #0d0f13;
4
+ --panel: #15181e;
5
+ --panel-raised: #1b1f27;
6
+ --line: #292e38;
7
+ --line-strong: #3a4250;
8
+ --text: #f2f4f7;
9
+ --muted: #969eaa;
10
+ --accent: #8ba5ff;
11
+ --accent-strong: #6f8ff7;
12
+ --danger: #ff7f82;
13
+ --radius: 18px;
14
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ body {
20
+ margin: 0;
21
+ min-height: 100vh;
22
+ background:
23
+ radial-gradient(circle at 80% -20%, rgba(80, 105, 170, 0.13), transparent 38rem),
24
+ var(--background);
25
+ color: var(--text);
26
+ }
27
+
28
+ button, input, textarea, select { font: inherit; }
29
+ button { color: inherit; }
30
+
31
+ .page-header, main {
32
+ width: min(1600px, calc(100% - 48px));
33
+ margin-inline: auto;
34
+ }
35
+
36
+ .page-header {
37
+ display: flex;
38
+ align-items: flex-end;
39
+ justify-content: space-between;
40
+ padding: 34px 0 22px;
41
+ }
42
+
43
+ h1, h2, p { margin: 0; }
44
+ h1 { font-size: 25px; letter-spacing: -0.04em; }
45
+ h2 { font-size: 16px; font-weight: 640; }
46
+ .page-header p { margin-top: 5px; color: var(--muted); font-size: 13px; }
47
+
48
+ .status-pill {
49
+ padding: 7px 11px;
50
+ border: 1px solid var(--line);
51
+ border-radius: 999px;
52
+ color: var(--muted);
53
+ font-size: 12px;
54
+ background: rgba(21, 24, 30, 0.8);
55
+ }
56
+
57
+ .workspace {
58
+ display: grid;
59
+ grid-template-columns: minmax(0, 1.14fr) minmax(380px, 0.86fr);
60
+ gap: 18px;
61
+ align-items: start;
62
+ }
63
+
64
+ .panel, .generate-card, .result-panel {
65
+ border: 1px solid var(--line);
66
+ background: rgba(21, 24, 30, 0.94);
67
+ border-radius: var(--radius);
68
+ box-shadow: 0 16px 50px rgba(0, 0, 0, 0.16);
69
+ }
70
+
71
+ .panel { padding: 16px; }
72
+ .panel-heading, .result-heading {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ margin-bottom: 13px;
77
+ }
78
+ .panel-actions { display: flex; align-items: center; gap: 6px; }
79
+ .panel-actions .danger-quiet { margin-left: 0; }
80
+
81
+ .eyebrow {
82
+ display: block;
83
+ color: var(--muted);
84
+ font-size: 10px;
85
+ font-weight: 700;
86
+ letter-spacing: 0.12em;
87
+ text-transform: uppercase;
88
+ margin-bottom: 3px;
89
+ }
90
+
91
+ .icon-button, .quiet-button, .tool-button, .text-button, .primary-small, .danger-quiet {
92
+ border: 1px solid var(--line);
93
+ background: var(--panel-raised);
94
+ border-radius: 9px;
95
+ cursor: pointer;
96
+ transition: border-color 120ms, background 120ms, transform 120ms;
97
+ }
98
+
99
+ .icon-button:hover, .quiet-button:hover, .tool-button:hover, .text-button:hover,
100
+ .primary-small:hover { border-color: var(--line-strong); background: #222731; }
101
+ .icon-button:active, .quiet-button:active, .tool-button:active { transform: scale(0.97); }
102
+ .icon-button { width: 31px; height: 31px; font-size: 20px; line-height: 1; }
103
+ .quiet-button, .tool-button, .text-button, .primary-small, .danger-quiet { padding: 7px 10px; font-size: 12px; }
104
+ .primary-small { background: var(--accent-strong); border-color: var(--accent-strong); color: white; }
105
+ .tool-button.active { background: rgba(111, 143, 247, 0.2); border-color: var(--accent); }
106
+ .danger-quiet { margin-left: auto; color: var(--danger); background: transparent; }
107
+ .danger-quiet:hover { background: rgba(255, 127, 130, 0.08); }
108
+
109
+ .source-workspace { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 12px; }
110
+ .source-rail {
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: 8px;
114
+ max-height: 620px;
115
+ overflow-y: auto;
116
+ scrollbar-width: thin;
117
+ }
118
+
119
+ .source-tile, .source-upload-tile, .target-tile, .target-upload-tile {
120
+ position: relative;
121
+ flex: 0 0 68px;
122
+ width: 100%;
123
+ padding: 0;
124
+ overflow: hidden;
125
+ border: 1px solid var(--line);
126
+ border-radius: 11px;
127
+ background: #0f1116;
128
+ cursor: pointer;
129
+ }
130
+ .source-tile img, .target-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
131
+ .source-tile.selected, .target-tile.selected { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
132
+ .source-tile .tile-label, .target-tile .tile-label {
133
+ position: absolute; inset: auto 4px 4px;
134
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
135
+ padding: 3px 5px; border-radius: 5px;
136
+ font-size: 9px; text-align: left;
137
+ background: rgba(0, 0, 0, 0.7);
138
+ }
139
+ .source-upload-tile, .target-upload-tile { font-size: 24px; color: var(--muted); border-style: dashed; }
140
+ .source-upload-tile:hover, .target-upload-tile:hover { color: var(--text); border-color: var(--line-strong); }
141
+
142
+ .canvas-host {
143
+ position: relative;
144
+ display: grid;
145
+ place-items: center;
146
+ width: 100%;
147
+ min-height: 430px;
148
+ max-height: 650px;
149
+ overflow: hidden;
150
+ border-radius: 13px;
151
+ background: #090b0e;
152
+ border: 1px solid #20242c;
153
+ }
154
+ .target-host { min-height: 350px; }
155
+ .canvas-host canvas {
156
+ display: none;
157
+ max-height: 650px;
158
+ touch-action: none;
159
+ }
160
+ .canvas-host video {
161
+ display: none;
162
+ width: 100%;
163
+ height: 100%;
164
+ max-height: 650px;
165
+ object-fit: contain;
166
+ background: black;
167
+ }
168
+ .canvas-host.playing video { display: block; }
169
+ .canvas-host.has-image canvas { display: block; }
170
+ .canvas-host.has-image.playing canvas { display: none; }
171
+
172
+ .empty-state {
173
+ display: flex;
174
+ flex-direction: column;
175
+ align-items: center;
176
+ gap: 6px;
177
+ border: 0;
178
+ color: var(--muted);
179
+ background: transparent;
180
+ cursor: pointer;
181
+ padding: 40px;
182
+ }
183
+ .canvas-host.has-image .empty-state { display: none; }
184
+ .empty-state strong { color: #d9dde4; font-size: 14px; font-weight: 600; }
185
+ .empty-state small { max-width: 270px; line-height: 1.45; }
186
+ .empty-icon { font-size: 26px; color: var(--accent); }
187
+
188
+ .canvas-status, .mode-badge {
189
+ position: absolute;
190
+ top: 10px;
191
+ padding: 6px 9px;
192
+ border-radius: 7px;
193
+ font-size: 11px;
194
+ backdrop-filter: blur(10px);
195
+ }
196
+ .canvas-status { left: 10px; background: rgba(11, 13, 17, 0.76); color: #c4cad4; }
197
+ .mode-badge { right: 10px; background: rgba(111, 143, 247, 0.88); color: white; }
198
+
199
+ .canvas-tools {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 7px;
203
+ min-height: 39px;
204
+ padding-top: 8px;
205
+ }
206
+ .shortcut-hint { color: var(--muted); font-size: 10px; line-height: 1.3; }
207
+ .tool-spacer { flex: 1; }
208
+
209
+ .source-settings {
210
+ display: flex;
211
+ flex-direction: column;
212
+ gap: 7px;
213
+ padding-top: 5px;
214
+ border-top: 1px solid transparent;
215
+ }
216
+ .source-settings label, .advanced-grid label {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: 5px;
220
+ color: var(--muted);
221
+ font-size: 10px;
222
+ font-weight: 620;
223
+ }
224
+ .source-settings input { width: 90px; }
225
+ .source-meta { color: var(--muted); font-size: 10px; padding-bottom: 8px; }
226
+
227
+ .playback-row { display: flex; align-items: center; gap: 9px; min-height: 34px; }
228
+ .playback-button {
229
+ flex: 0 0 34px;
230
+ width: 34px;
231
+ height: 34px;
232
+ padding: 0;
233
+ }
234
+ .playback-icon {
235
+ display: block;
236
+ width: 15px;
237
+ height: 15px;
238
+ fill: currentColor;
239
+ }
240
+ .playback-row .source-meta { margin-left: auto; padding: 0; }
241
+ .playback-row .danger-quiet { margin-left: 0; }
242
+ .source-time { color: #cbd1dc; font-size: 10px; font-variant-numeric: tabular-nums; }
243
+ .video-timeline {
244
+ position: relative;
245
+ height: 66px;
246
+ overflow: hidden;
247
+ border: 1px solid var(--line);
248
+ border-radius: 10px;
249
+ background: #090b0e;
250
+ cursor: ew-resize;
251
+ touch-action: none;
252
+ user-select: none;
253
+ }
254
+ .timeline-filmstrip { position: absolute; inset: 0; display: flex; justify-content: space-between; gap: 3px; }
255
+ .timeline-thumbnail { position: relative; flex: 0 0 auto; height: 100%; overflow: hidden; }
256
+ .timeline-thumbnail img { width: 100%; height: 100%; object-fit: cover; opacity: .84; }
257
+ .timeline-selection { position: absolute; top: 0; bottom: 0; border-block: 2px solid var(--accent); pointer-events: none; }
258
+ .timeline-before, .timeline-after { position: absolute; top: 0; bottom: 0; background: rgba(4,5,7,.67); pointer-events: none; }
259
+ .trim-handle {
260
+ position: absolute; z-index: 12; top: 0; bottom: 0; width: 10px;
261
+ border: 0; background: var(--accent); transform: translateX(-50%); cursor: col-resize;
262
+ }
263
+ .trim-handle::after { content: ""; position: absolute; inset: 24px 3px; border-left: 1px solid white; border-right: 1px solid white; opacity: .8; }
264
+ .timeline-playhead { position: absolute; z-index: 7; top: 0; bottom: 0; width: 2px; background: white; transform: translateX(-1px); pointer-events: none; box-shadow: 0 0 0 1px rgba(0,0,0,.3); }
265
+ .timeline-playhead::before { content: ""; position: absolute; left: -4px; top: 0; border: 5px solid transparent; border-top-color: white; }
266
+ .timeline-masks { position: absolute; z-index: 9; inset: 0; pointer-events: none; }
267
+ .timeline-mask-preview {
268
+ position: absolute; width: 38px; height: 27px;
269
+ padding: 0; overflow: hidden; border: 2px solid; border-radius: 5px;
270
+ background: black; transform: translateX(-50%); pointer-events: auto; cursor: pointer;
271
+ box-shadow: 0 2px 8px rgba(0,0,0,.5);
272
+ }
273
+ .timeline-mask-preview img { display: block; width: 100%; height: 100%; object-fit: cover; }
274
+ .timeline-mask-preview.excluded { opacity: .42; filter: grayscale(.45); }
275
+
276
+ .target-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 76px; gap: 10px; }
277
+ .target-main { min-width: 0; }
278
+ .target-rail {
279
+ display: flex; flex-direction: column; gap: 8px; max-height: 500px;
280
+ overflow-y: auto; scrollbar-width: thin;
281
+ }
282
+ .target-tile, .target-upload-tile { flex-basis: 62px; }
283
+
284
+ input, textarea, select {
285
+ border: 1px solid var(--line);
286
+ color: var(--text);
287
+ background: #101318;
288
+ border-radius: 9px;
289
+ outline: none;
290
+ transition: border-color 120ms, box-shadow 120ms;
291
+ }
292
+ input:focus, textarea:focus, select:focus {
293
+ border-color: var(--accent-strong);
294
+ box-shadow: 0 0 0 3px rgba(111, 143, 247, 0.12);
295
+ }
296
+ input, select { height: 34px; padding: 0 9px; }
297
+ textarea { width: 100%; padding: 10px 11px; resize: vertical; min-height: 62px; line-height: 1.4; }
298
+
299
+ .target-stack { display: flex; flex-direction: column; gap: 18px; }
300
+ .generate-card { padding: 16px; }
301
+ .prompt-label { display: block; margin-bottom: 7px; font-size: 12px; font-weight: 620; }
302
+ details { margin-top: 9px; }
303
+ summary { color: var(--muted); font-size: 11px; cursor: pointer; user-select: none; }
304
+ .advanced-grid {
305
+ display: grid;
306
+ grid-template-columns: repeat(3, minmax(0, 1fr));
307
+ gap: 10px;
308
+ margin-top: 11px;
309
+ padding: 12px;
310
+ background: #111419;
311
+ border: 1px solid var(--line);
312
+ border-radius: 11px;
313
+ }
314
+ .negative-prompt { grid-column: 1 / -1; }
315
+ .generate-button {
316
+ position: relative;
317
+ overflow: hidden;
318
+ width: 100%;
319
+ height: 50px;
320
+ margin-top: 13px;
321
+ border: 0;
322
+ border-radius: 12px;
323
+ color: white;
324
+ background: linear-gradient(135deg, #6d8ef8, #876fe9);
325
+ font-size: 14px;
326
+ font-weight: 700;
327
+ cursor: pointer;
328
+ box-shadow: 0 10px 28px rgba(93, 111, 218, 0.23);
329
+ transition: filter 120ms, transform 120ms;
330
+ }
331
+ .generate-button-content { position: relative; z-index: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
332
+ .generate-button-progress {
333
+ position: absolute;
334
+ inset: 0 auto 0 0;
335
+ width: 0;
336
+ background: rgba(255, 255, 255, 0.18);
337
+ transition: width 180ms linear;
338
+ }
339
+ .generate-button-progress.indeterminate {
340
+ width: 36%;
341
+ animation: progress-slide 1.1s ease-in-out infinite;
342
+ }
343
+ .generate-button.generating:disabled { opacity: 1; filter: none; }
344
+ .generate-button:hover { filter: brightness(1.08); }
345
+ .generate-button:active { transform: translateY(1px); }
346
+ .generate-button:disabled { cursor: wait; filter: grayscale(.45); opacity: .72; }
347
+ .generate-hint { margin-top: 8px; min-height: 15px; color: var(--muted); font-size: 10px; text-align: center; }
348
+ .generated-rail { display: flex; gap: 8px; margin-top: 10px; padding: 2px 1px 3px; overflow-x: auto; scrollbar-width: thin; }
349
+ .generated-tile { flex: 0 0 74px; width: 74px; height: 52px; padding: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: #101319; cursor: pointer; }
350
+ .generated-tile:hover { border-color: var(--accent); }
351
+ .generated-tile video { display: block; width: 100%; height: 100%; object-fit: cover; }
352
+ @keyframes progress-slide { from { transform: translateX(-110%); } to { transform: translateX(310%); } }
353
+
354
+ .result-panel { margin: 18px 0 50px; padding: 16px; }
355
+ .result-heading span:last-child { color: var(--muted); font-size: 11px; }
356
+ .result-panel video { display: block; width: min(100%, 1000px); margin: auto; border-radius: 12px; background: black; }
357
+
358
+ .context-menu {
359
+ position: fixed;
360
+ z-index: 100;
361
+ min-width: 210px;
362
+ max-height: min(430px, calc(100vh - 20px));
363
+ overflow-y: auto;
364
+ padding: 6px;
365
+ border: 1px solid var(--line-strong);
366
+ border-radius: 12px;
367
+ background: rgba(26, 30, 37, 0.98);
368
+ box-shadow: 0 18px 55px rgba(0, 0, 0, .45);
369
+ backdrop-filter: blur(18px);
370
+ }
371
+ .menu-label { padding: 8px 9px 5px; color: var(--muted); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; }
372
+ .menu-divider { height: 1px; background: var(--line); margin: 5px; }
373
+ .menu-item {
374
+ display: flex;
375
+ align-items: center;
376
+ gap: 9px;
377
+ width: 100%;
378
+ min-height: 34px;
379
+ padding: 7px 9px;
380
+ border: 0;
381
+ border-radius: 8px;
382
+ color: var(--text);
383
+ text-align: left;
384
+ background: transparent;
385
+ cursor: pointer;
386
+ }
387
+ .menu-item:hover { background: #252a34; }
388
+ .menu-item.danger { color: var(--danger); }
389
+ .menu-item img { width: 58px; height: 38px; object-fit: cover; border-radius: 5px; }
390
+ .color-dot { flex: 0 0 10px; width: 10px; height: 10px; border-radius: 50%; }
391
+ .menu-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
392
+ .menu-copy small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 130px; }
393
+
394
+ .drag-ghost {
395
+ position: fixed;
396
+ z-index: 120;
397
+ pointer-events: none;
398
+ display: flex;
399
+ align-items: center;
400
+ gap: 7px;
401
+ padding: 7px 10px;
402
+ border: 1px solid rgba(255,255,255,.2);
403
+ border-radius: 999px;
404
+ background: rgba(20, 23, 29, .92);
405
+ box-shadow: 0 10px 30px rgba(0,0,0,.35);
406
+ font-size: 11px;
407
+ transform: translate(12px, 12px);
408
+ }
409
+
410
+ .toast {
411
+ position: fixed;
412
+ z-index: 200;
413
+ left: 50%; bottom: 24px;
414
+ transform: translateX(-50%);
415
+ max-width: min(520px, calc(100% - 32px));
416
+ padding: 10px 13px;
417
+ border: 1px solid var(--line-strong);
418
+ border-radius: 10px;
419
+ background: #232831;
420
+ box-shadow: 0 14px 40px rgba(0,0,0,.4);
421
+ font-size: 12px;
422
+ }
423
+
424
+ .hidden { display: none !important; }
425
+
426
+ @media (max-width: 980px) {
427
+ .workspace { grid-template-columns: 1fr; }
428
+ .source-panel { order: 1; }
429
+ .target-stack { order: 2; }
430
+ .canvas-host { min-height: 330px; }
431
+ }
432
+
433
+ @media (max-width: 620px) {
434
+ .page-header, main { width: min(100% - 22px, 1600px); }
435
+ .page-header { padding-top: 22px; }
436
+ .page-header p, .status-pill { display: none; }
437
+ .panel, .generate-card, .result-panel { border-radius: 14px; }
438
+ .source-workspace { grid-template-columns: 68px minmax(0, 1fr); }
439
+ .source-tile, .source-upload-tile { flex-basis: 58px; }
440
+ .canvas-host, .target-host { min-height: 260px; }
441
+ .shortcut-hint { display: none; }
442
+ .source-settings { flex-wrap: wrap; }
443
+ .target-workspace { grid-template-columns: minmax(0, 1fr) 60px; }
444
+ .advanced-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
445
+ }
446
+
447
+ /* What Moves? project-page theme */
448
+ :root {
449
+ color-scheme: light;
450
+ --background: #ffffff;
451
+ --panel: rgba(255, 255, 255, 0.88);
452
+ --panel-raised: #f7f8fb;
453
+ --line: #e4e6ed;
454
+ --line-strong: #c7ccd8;
455
+ --text: #26272b;
456
+ --muted: #777b84;
457
+ --accent: #25d5f4;
458
+ --accent-strong: #1688b9;
459
+ --magenta: #df58f5;
460
+ --blue: #73a3ff;
461
+ --danger: #c94d57;
462
+ --radius: 22px;
463
+ --shadow-cyan: 0 18px 52px rgba(37, 213, 244, 0.2);
464
+ --shadow-magenta: 0 18px 52px rgba(223, 88, 245, 0.18);
465
+ }
466
+
467
+ body {
468
+ background:
469
+ linear-gradient(120deg, rgba(37, 213, 244, 0.09), transparent 28%),
470
+ linear-gradient(260deg, rgba(223, 88, 245, 0.08), transparent 24%),
471
+ var(--background);
472
+ }
473
+
474
+ .page-header {
475
+ position: sticky;
476
+ top: 0;
477
+ z-index: 50;
478
+ align-items: center;
479
+ width: 100%;
480
+ max-width: none;
481
+ padding: 13px clamp(18px, 4vw, 56px);
482
+ border-bottom: 1px solid rgba(228, 230, 237, 0.8);
483
+ background: rgba(255, 255, 255, 0.76);
484
+ backdrop-filter: blur(18px);
485
+ -webkit-backdrop-filter: blur(18px);
486
+ }
487
+
488
+ .page-header > * { margin-inline: 0; }
489
+
490
+ .start-over-button {
491
+ display: inline-flex;
492
+ align-items: center;
493
+ gap: 7px;
494
+ padding: 8px 12px;
495
+ border: 1px solid var(--line);
496
+ border-radius: 999px;
497
+ color: var(--accent-strong);
498
+ background: rgba(255, 255, 255, 0.82);
499
+ font-size: 12px;
500
+ font-weight: 680;
501
+ cursor: pointer;
502
+ transition: border-color 120ms, background 120ms, transform 120ms;
503
+ }
504
+ .start-over-button:hover { border-color: var(--line-strong); background: white; }
505
+ .start-over-button:active { transform: translateY(1px); }
506
+ .header-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
507
+ .broom-icon {
508
+ width: 19px;
509
+ height: 19px;
510
+ background: currentColor;
511
+ -webkit-mask: url("/static/broom-flaticon.png") center / contain no-repeat;
512
+ mask: url("/static/broom-flaticon.png") center / contain no-repeat;
513
+ }
514
+ .app-brand {
515
+ display: flex;
516
+ align-items: center;
517
+ gap: 13px;
518
+ }
519
+
520
+ .app-kicker {
521
+ display: block;
522
+ margin-bottom: 4px;
523
+ color: var(--accent-strong);
524
+ font-size: 9px;
525
+ font-weight: 850;
526
+ letter-spacing: 0.13em;
527
+ text-transform: uppercase;
528
+ }
529
+
530
+ h1 {
531
+ font-family: Georgia, "Times New Roman", serif;
532
+ font-size: clamp(28px, 3vw, 42px);
533
+ line-height: 0.95;
534
+ letter-spacing: -0.02em;
535
+ background: linear-gradient(105deg, var(--accent) 4%, var(--blue) 42%, var(--magenta) 82%);
536
+ -webkit-background-clip: text;
537
+ background-clip: text;
538
+ color: transparent;
539
+ }
540
+
541
+ h2 {
542
+ font-size: 18px;
543
+ font-weight: 720;
544
+ letter-spacing: -0.02em;
545
+ }
546
+
547
+ .page-header p {
548
+ margin-top: 5px;
549
+ color: var(--muted);
550
+ font-size: 12px;
551
+ }
552
+
553
+ main { padding-top: 28px; }
554
+
555
+ .status-pill {
556
+ padding: 0;
557
+ border: 0;
558
+ border-radius: 0;
559
+ color: var(--muted);
560
+ font-weight: 400;
561
+ background: transparent;
562
+ box-shadow: none;
563
+ }
564
+
565
+ .panel,
566
+ .generate-card,
567
+ .result-panel {
568
+ border-color: var(--line);
569
+ background: var(--panel);
570
+ box-shadow: 0 20px 64px rgba(32, 36, 46, 0.1);
571
+ backdrop-filter: blur(16px);
572
+ -webkit-backdrop-filter: blur(16px);
573
+ }
574
+
575
+ .source-panel { box-shadow: var(--shadow-cyan); }
576
+ .target-panel { box-shadow: var(--shadow-magenta); }
577
+
578
+ .eyebrow { color: var(--accent-strong); }
579
+
580
+ .icon-button,
581
+ .quiet-button,
582
+ .tool-button,
583
+ .text-button,
584
+ .primary-small,
585
+ .danger-quiet {
586
+ border-color: var(--line);
587
+ border-radius: 999px;
588
+ background: rgba(255, 255, 255, 0.88);
589
+ box-shadow: 0 8px 20px rgba(34, 38, 48, 0.06);
590
+ }
591
+
592
+ .icon-button:hover,
593
+ .quiet-button:hover,
594
+ .tool-button:hover,
595
+ .text-button:hover,
596
+ .primary-small:hover {
597
+ border-color: rgba(37, 213, 244, 0.55);
598
+ background: #ffffff;
599
+ color: var(--accent-strong);
600
+ box-shadow: var(--shadow-cyan);
601
+ }
602
+
603
+ .primary-small {
604
+ border-color: transparent;
605
+ color: #ffffff;
606
+ background: linear-gradient(135deg, var(--accent-strong), var(--blue));
607
+ }
608
+
609
+ .tool-button.active {
610
+ border-color: var(--accent);
611
+ color: var(--accent-strong);
612
+ background: rgba(37, 213, 244, 0.13);
613
+ }
614
+
615
+ .danger-quiet { color: var(--danger); background: transparent; box-shadow: none; }
616
+ .danger-quiet:hover { border-color: rgba(201, 77, 87, 0.25); background: rgba(201, 77, 87, 0.08); }
617
+
618
+ .source-tile,
619
+ .source-upload-tile,
620
+ .target-tile,
621
+ .target-upload-tile { background: #f1f3f7; }
622
+
623
+ .source-tile.selected,
624
+ .target-tile.selected {
625
+ border-color: var(--accent);
626
+ box-shadow: 0 0 0 2px rgba(37, 213, 244, 0.2), var(--shadow-cyan);
627
+ }
628
+
629
+ .source-upload-tile:hover,
630
+ .target-upload-tile:hover {
631
+ border-color: var(--accent);
632
+ color: var(--accent-strong);
633
+ background: rgba(37, 213, 244, 0.07);
634
+ }
635
+
636
+ .canvas-host {
637
+ border-color: #dfe3eb;
638
+ border-radius: 16px;
639
+ background:
640
+ linear-gradient(135deg, rgba(37, 213, 244, 0.06), transparent 34%),
641
+ #11141a;
642
+ }
643
+
644
+ .canvas-host:not(.has-image) {
645
+ border-color: var(--line-strong);
646
+ border-style: dashed;
647
+ background:
648
+ linear-gradient(135deg, rgba(37, 213, 244, 0.07), transparent 42%),
649
+ linear-gradient(315deg, rgba(223, 88, 245, 0.06), transparent 38%),
650
+ #f7f8fb;
651
+ }
652
+
653
+ .canvas-host:not(.has-image) .empty-state { color: var(--muted); }
654
+ .canvas-host:not(.has-image) .empty-state strong { color: var(--text); font-weight: 650; }
655
+
656
+ .mode-badge {
657
+ color: #173145;
658
+ font-weight: 750;
659
+ background: linear-gradient(135deg, rgba(37, 213, 244, 0.92), rgba(115, 163, 255, 0.92));
660
+ }
661
+
662
+ .source-time { color: #555b66; }
663
+
664
+ input,
665
+ textarea,
666
+ select {
667
+ border-color: var(--line);
668
+ border-radius: 11px;
669
+ color: var(--text);
670
+ background: #ffffff;
671
+ }
672
+
673
+ input:focus,
674
+ textarea:focus,
675
+ select:focus {
676
+ border-color: var(--accent);
677
+ box-shadow: 0 0 0 3px rgba(37, 213, 244, 0.14);
678
+ }
679
+
680
+ .generate-card {
681
+ background:
682
+ linear-gradient(135deg, rgba(37, 213, 244, 0.07), transparent 36%),
683
+ linear-gradient(315deg, rgba(223, 88, 245, 0.07), transparent 30%),
684
+ var(--panel);
685
+ }
686
+
687
+ .advanced-grid { background: rgba(247, 248, 251, 0.86); }
688
+
689
+ .generate-button {
690
+ background: linear-gradient(105deg, var(--accent), var(--blue) 48%, var(--magenta));
691
+ box-shadow: 0 14px 34px rgba(115, 163, 255, 0.28);
692
+ }
693
+
694
+ .progress-copy { color: #555b66; }
695
+ .progress-track { background: #e9ecf2; }
696
+ .progress-bar { background: linear-gradient(90deg, var(--accent), var(--magenta)); }
697
+
698
+ .context-menu {
699
+ background: rgba(255, 255, 255, 0.97);
700
+ box-shadow: 0 18px 55px rgba(34, 38, 48, 0.18);
701
+ }
702
+
703
+ .menu-item:hover { background: rgba(37, 213, 244, 0.08); }
704
+
705
+ .drag-ghost {
706
+ border: 0;
707
+ background: transparent;
708
+ box-shadow: none;
709
+ }
710
+
711
+ .toast {
712
+ background: rgba(255, 255, 255, 0.96);
713
+ box-shadow: 0 14px 40px rgba(34, 38, 48, 0.18);
714
+ }
715
+
716
+ /* Small, shape-preserving mask feedback for mapping gestures. */
717
+ .drag-ghost {
718
+ width: 38px;
719
+ height: 38px;
720
+ padding: 0;
721
+ border-radius: 0;
722
+ transform: translate(-72%, -72%);
723
+ opacity: 0.94;
724
+ filter: drop-shadow(0 3px 5px rgba(31, 42, 55, 0.28));
725
+ animation: mask-pickup 150ms cubic-bezier(.2, 1.4, .4, 1) both;
726
+ }
727
+
728
+ .drag-mask-shape,
729
+ .mapping-hint-token,
730
+ .mapping-drop-pop { display: block; overflow: visible; }
731
+ .drag-mask-shape { width: 100%; height: 100%; }
732
+ .drag-mask-shape img,
733
+ .mapping-hint-token img,
734
+ .mapping-drop-pop img { display: block; width: 100%; height: 100%; object-fit: contain; }
735
+
736
+ @keyframes mask-pickup {
737
+ from { opacity: 0; transform: translate(-72%, -72%) scale(.2); }
738
+ to { opacity: .94; transform: translate(-72%, -72%) scale(1); }
739
+ }
740
+
741
+ .mapping-drop-pop {
742
+ position: fixed;
743
+ z-index: 119;
744
+ width: 48px;
745
+ height: 48px;
746
+ pointer-events: none;
747
+ filter: drop-shadow(0 3px 6px rgba(31, 42, 55, 0.24));
748
+ animation: mask-drop 440ms cubic-bezier(.2, 1.3, .35, 1) both;
749
+ }
750
+
751
+ @keyframes mask-drop {
752
+ 0% { opacity: 0; transform: translate(-50%, -50%) scale(.18); }
753
+ 52% { opacity: .95; transform: translate(-50%, -50%) scale(1.24); }
754
+ 100% { opacity: 0; transform: translate(-50%, -50%) scale(.88); }
755
+ }
756
+
757
+ .mapping-hint {
758
+ position: fixed;
759
+ inset: 0;
760
+ z-index: 115;
761
+ pointer-events: none;
762
+ }
763
+
764
+ .mapping-hint-token {
765
+ position: absolute;
766
+ width: 44px;
767
+ height: 44px;
768
+ filter: drop-shadow(0 3px 5px rgba(31, 42, 55, 0.3));
769
+ animation: mapping-hint-drag 2.45s cubic-bezier(.55, 0, .3, 1) both;
770
+ }
771
+
772
+ .mapping-hint-pointer {
773
+ position: absolute;
774
+ width: 24px;
775
+ height: 28px;
776
+ animation: mapping-hint-drag 2.45s cubic-bezier(.55, 0, .3, 1) both;
777
+ }
778
+ .mapping-hint-pointer svg { display: block; width: 100%; height: 100%; overflow: visible; }
779
+ .mapping-hint-pointer path { fill: white; stroke: #26323f; stroke-width: 1.7; stroke-linejoin: round; filter: drop-shadow(0 2px 2px rgba(31, 42, 55, .24)); }
780
+
781
+ .mapping-hint-copy {
782
+ position: fixed;
783
+ left: 50%;
784
+ bottom: 26px;
785
+ padding: 8px 12px;
786
+ border: 1px solid var(--line);
787
+ border-radius: 999px;
788
+ color: var(--text);
789
+ background: rgba(255, 255, 255, .94);
790
+ box-shadow: 0 10px 28px rgba(34, 38, 48, .14);
791
+ font-size: 11px;
792
+ font-weight: 680;
793
+ transform: translateX(-50%);
794
+ animation: mapping-hint-copy 2.45s ease both;
795
+ }
796
+
797
+ @keyframes mapping-hint-drag {
798
+ 0%, 12% { opacity: 0; transform: translate(-50%, -50%) scale(.2); }
799
+ 24% { opacity: .95; transform: translate(-50%, -50%) scale(1); }
800
+ 72% { opacity: .95; transform: translate(calc(var(--hint-x) - 50%), calc(var(--hint-y) - 50%)) scale(1); }
801
+ 86% { opacity: 1; transform: translate(calc(var(--hint-x) - 50%), calc(var(--hint-y) - 50%)) scale(1.3); }
802
+ 100% { opacity: 0; transform: translate(calc(var(--hint-x) - 50%), calc(var(--hint-y) - 50%)) scale(.72); }
803
+ }
804
+
805
+ @keyframes mapping-hint-copy {
806
+ 0%, 8%, 92%, 100% { opacity: 0; }
807
+ 18%, 82% { opacity: 1; }
808
+ }
809
+
810
+ /* Complete light-mode treatment, including transient and media surfaces. */
811
+ .canvas-host,
812
+ .canvas-host video,
813
+ .video-timeline,
814
+ .timeline-mask-preview,
815
+ .generated-tile,
816
+ .result-panel video {
817
+ background: #f3f5f8;
818
+ }
819
+
820
+ .canvas-host { border-color: #dfe3eb; }
821
+ .timeline-before,
822
+ .timeline-after { background: rgba(255, 255, 255, .66); }
823
+ .timeline-playhead { background: var(--accent-strong); box-shadow: 0 0 0 1px rgba(255, 255, 255, .85); }
824
+ .timeline-playhead::before { border-top-color: var(--accent-strong); }
825
+ .timeline-mask-preview { box-shadow: 0 2px 8px rgba(34, 38, 48, .2); }
826
+ .canvas-status { color: var(--muted); background: rgba(255, 255, 255, .9); }
827
+ .context-menu { color: var(--text); background: rgba(255, 255, 255, .98); }
828
+ .menu-item:hover { background: #f1f4f8; }
829
+ .toast { color: var(--text); background: rgba(255, 255, 255, .98); }
830
+
831
+ .media-loader {
832
+ position: absolute;
833
+ inset: 0;
834
+ z-index: 24;
835
+ display: grid;
836
+ place-items: center;
837
+ pointer-events: none;
838
+ border-radius: inherit;
839
+ background: rgba(247, 248, 251, .58);
840
+ backdrop-filter: blur(1.5px);
841
+ -webkit-backdrop-filter: blur(1.5px);
842
+ }
843
+
844
+ .loading-spinner {
845
+ width: 34px;
846
+ height: 34px;
847
+ border: 3px solid rgba(115, 163, 255, .2);
848
+ border-top-color: var(--accent);
849
+ border-right-color: var(--magenta);
850
+ border-radius: 50%;
851
+ box-shadow: 0 5px 18px rgba(115, 163, 255, .18);
852
+ animation: media-spin .72s linear infinite;
853
+ }
854
+
855
+ @keyframes media-spin { to { transform: rotate(360deg); } }
856
+
857
+ .result-media {
858
+ position: relative;
859
+ flex: 1 1 auto;
860
+ display: grid;
861
+ min-width: 0;
862
+ min-height: 0;
863
+ place-items: center;
864
+ overflow: hidden;
865
+ border-radius: 12px;
866
+ }
867
+
868
+ .result-media video {
869
+ width: 100%;
870
+ height: 100%;
871
+ min-height: 0;
872
+ object-fit: contain;
873
+ }
874
+
875
+ @media (prefers-reduced-motion: reduce) {
876
+ .drag-ghost,
877
+ .mapping-drop-pop,
878
+ .mapping-hint-token,
879
+ .mapping-hint-pointer,
880
+ .mapping-hint-copy { animation-duration: 1ms; }
881
+ .loading-spinner { animation: none; }
882
+ }
883
+
884
+ @media (max-width: 620px) {
885
+ .page-header {
886
+ width: 100%;
887
+ padding: 11px;
888
+ }
889
+
890
+ .app-kicker { display: none; }
891
+ }
892
+
893
+ /* Viewport-fitting application shell */
894
+ html,
895
+ body {
896
+ width: 100%;
897
+ height: 100%;
898
+ min-height: 0;
899
+ overflow: hidden;
900
+ }
901
+
902
+ body {
903
+ display: flex;
904
+ flex-direction: column;
905
+ }
906
+
907
+ .page-header {
908
+ flex: 0 0 auto;
909
+ min-height: 0;
910
+ }
911
+
912
+ main {
913
+ display: flex;
914
+ flex: 1 1 auto;
915
+ min-height: 0;
916
+ padding-top: clamp(8px, 2vh, 20px);
917
+ padding-bottom: clamp(8px, 2vh, 20px);
918
+ }
919
+
920
+ .workspace {
921
+ width: 100%;
922
+ height: 100%;
923
+ min-height: 0;
924
+ align-items: stretch;
925
+ gap: clamp(8px, 1.2vw, 18px);
926
+ }
927
+
928
+ .panel,
929
+ .generate-card,
930
+ .target-stack,
931
+ .source-workspace,
932
+ .source-main,
933
+ .target-workspace,
934
+ .target-main {
935
+ min-width: 0;
936
+ min-height: 0;
937
+ }
938
+
939
+ .source-panel,
940
+ .target-panel {
941
+ display: flex;
942
+ flex-direction: column;
943
+ overflow: hidden;
944
+ }
945
+
946
+ .source-workspace,
947
+ .target-workspace {
948
+ flex: 1 1 auto;
949
+ overflow: hidden;
950
+ }
951
+
952
+ .source-main,
953
+ .target-main {
954
+ display: flex;
955
+ flex-direction: column;
956
+ }
957
+
958
+ .canvas-host {
959
+ flex: 1 1 auto;
960
+ width: 100%;
961
+ height: auto;
962
+ min-height: 0;
963
+ max-height: none;
964
+ }
965
+
966
+ .canvas-host canvas,
967
+ .canvas-host video {
968
+ max-width: 100%;
969
+ max-height: 100%;
970
+ }
971
+
972
+ .source-rail,
973
+ .target-rail {
974
+ max-height: 100%;
975
+ min-height: 0;
976
+ }
977
+
978
+ .target-stack {
979
+ display: grid;
980
+ grid-template-rows: minmax(0, 1fr) auto;
981
+ height: 100%;
982
+ }
983
+
984
+ .result-panel:not(.hidden) {
985
+ position: fixed;
986
+ top: clamp(72px, 12vh, 110px);
987
+ left: 50%;
988
+ width: min(620px, calc(100vw - 28px));
989
+ height: min(460px, calc(100vh - 96px));
990
+ transform: translateX(-50%);
991
+ z-index: 70;
992
+ display: flex;
993
+ flex-direction: column;
994
+ margin: 0;
995
+ overflow: hidden;
996
+ box-shadow: 0 28px 90px rgba(32, 36, 46, 0.24), var(--shadow-magenta);
997
+ }
998
+
999
+ .result-actions { display: flex; align-items: center; gap: 10px; }
1000
+ .result-close { width: 28px; height: 28px; padding: 0; font-size: 19px; line-height: 1; }
1001
+ .result-actions > span { color: var(--muted); font-size: 11px; }
1002
+
1003
+ .result-panel video {
1004
+ flex: 1 1 auto;
1005
+ width: auto;
1006
+ max-width: 100%;
1007
+ min-height: 0;
1008
+ object-fit: contain;
1009
+ }
1010
+
1011
+ button,
1012
+ .status-pill,
1013
+ .canvas-status,
1014
+ .mode-badge,
1015
+ .source-tile .tile-label,
1016
+ .target-tile .tile-label {
1017
+ align-items: center;
1018
+ justify-content: center;
1019
+ text-align: center;
1020
+ }
1021
+
1022
+ button,
1023
+ .status-pill,
1024
+ .canvas-status,
1025
+ .mode-badge {
1026
+ display: inline-flex;
1027
+ }
1028
+
1029
+ button {
1030
+ line-height: 1.15;
1031
+ }
1032
+
1033
+ .empty-state {
1034
+ display: flex;
1035
+ flex-direction: column;
1036
+ }
1037
+
1038
+ .source-tile,
1039
+ .target-tile {
1040
+ display: block;
1041
+ }
1042
+
1043
+ .menu-item {
1044
+ justify-content: flex-start;
1045
+ text-align: left;
1046
+ }
1047
+
1048
+ .panel-heading,
1049
+ .result-heading {
1050
+ flex: 0 0 auto;
1051
+ }
1052
+
1053
+ @media (max-height: 820px) {
1054
+ .page-header { padding-block: 8px; }
1055
+ h1 { font-size: clamp(25px, 2.4vw, 34px); }
1056
+ .page-header p { margin-top: 2px; }
1057
+ .panel { padding: 12px; }
1058
+ .panel-heading, .result-heading { margin-bottom: 8px; }
1059
+ .canvas-tools { min-height: 32px; padding-top: 5px; }
1060
+ .target-stack { gap: 9px; }
1061
+ .generate-card { padding: 11px 12px; }
1062
+ .generate-button { height: 40px; margin-top: 8px; }
1063
+ textarea { min-height: 44px; padding-block: 7px; }
1064
+ details { margin-top: 5px; }
1065
+ .generate-hint { margin-top: 5px; }
1066
+ }
1067
+
1068
+ @media (max-width: 980px) {
1069
+ .workspace {
1070
+ grid-template-columns: 1fr;
1071
+ grid-template-rows: minmax(0, 1.05fr) minmax(0, 0.95fr);
1072
+ }
1073
+
1074
+ .source-panel,
1075
+ .target-stack {
1076
+ min-height: 0;
1077
+ }
1078
+
1079
+ .canvas-host,
1080
+ .target-host {
1081
+ min-height: 0;
1082
+ }
1083
+ }
1084
+
1085
+ @media (max-width: 620px) {
1086
+ main {
1087
+ width: calc(100% - 14px);
1088
+ padding-block: 7px;
1089
+ }
1090
+
1091
+ .workspace { gap: 7px; }
1092
+ .panel, .generate-card { padding: 9px; border-radius: 15px; }
1093
+ .panel-heading { margin-bottom: 6px; }
1094
+ h2 { font-size: 15px; }
1095
+ .source-workspace { grid-template-columns: 64px minmax(0, 1fr); gap: 7px; }
1096
+ .target-workspace { grid-template-columns: minmax(0, 1fr) 54px; gap: 7px; }
1097
+ .source-tile, .source-upload-tile { flex-basis: 54px; }
1098
+ .target-tile, .target-upload-tile { flex-basis: 48px; }
1099
+ .canvas-host, .target-host { min-height: 0; border-radius: 11px; }
1100
+ .generate-button { height: 36px; }
1101
+ .prompt-label { margin-bottom: 4px; }
1102
+ textarea { min-height: 38px; }
1103
+ .generate-hint { display: none; }
1104
+ }
1105
+
1106
+ @media (max-height: 650px) {
1107
+ .page-header p,
1108
+ .app-kicker,
1109
+ .shortcut-hint,
1110
+ .generate-hint { display: none; }
1111
+
1112
+ .page-header { padding-block: 5px; }
1113
+ h1 { font-size: 25px; }
1114
+ main { padding-block: 6px; }
1115
+
1116
+ .workspace {
1117
+ grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
1118
+ grid-template-rows: 1fr;
1119
+ }
1120
+
1121
+ .panel { padding: 9px; }
1122
+ .panel-heading { margin-bottom: 5px; }
1123
+ .eyebrow { margin-bottom: 1px; }
1124
+ .canvas-tools { min-height: 29px; }
1125
+ .source-settings { gap: 3px; padding-top: 2px; }
1126
+ .video-timeline { height: 48px; }
1127
+ .generate-card { padding: 8px 10px; }
1128
+ .generate-button { height: 34px; }
1129
+ textarea { min-height: 34px; }
1130
+ }
app/static/video-timeline.js ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function clamp(value, minimum, maximum) {
2
+ return Math.min(maximum, Math.max(minimum, value));
3
+ }
4
+
5
+ function timeLabel(seconds) {
6
+ const minutes = Math.floor(seconds / 60);
7
+ const remainder = Math.max(0, seconds - minutes * 60);
8
+ return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
9
+ }
10
+
11
+ export class VideoTimeline {
12
+ constructor({ root, video, playButton, time, callbacks = {} }) {
13
+ this.root = root;
14
+ this.video = video;
15
+ this.playButton = playButton;
16
+ this.time = time;
17
+ this.callbacks = callbacks;
18
+ this.filmstrip = root.querySelector(".timeline-filmstrip");
19
+ this.maskLayer = root.querySelector(".timeline-masks");
20
+ this.playhead = root.querySelector(".timeline-playhead");
21
+ this.selection = root.querySelector(".timeline-selection");
22
+ this.before = root.querySelector(".timeline-before");
23
+ this.after = root.querySelector(".timeline-after");
24
+ this.leftHandle = root.querySelector('[data-handle="left"]');
25
+ this.rightHandle = root.querySelector('[data-handle="right"]');
26
+ this.source = null;
27
+ this.currentFrame = 0;
28
+ this.trimStart = 0;
29
+ this.trimEnd = 0;
30
+ this.drag = null;
31
+ this.animation = null;
32
+ this.thumbnailSignature = "";
33
+
34
+ playButton.addEventListener("click", () => this.togglePlayback());
35
+ root.addEventListener("pointerdown", (event) => this._pointerDown(event));
36
+ video.addEventListener("play", () => this._playbackChanged(true));
37
+ video.addEventListener("pause", () => this._playbackChanged(false));
38
+ video.addEventListener("ended", () => this._loop());
39
+ ["loadstart", "waiting", "seeking"].forEach((eventName) => {
40
+ video.addEventListener(eventName, () => this.callbacks.onLoading?.(true));
41
+ });
42
+ ["loadeddata", "canplay", "playing", "seeked", "emptied", "error"].forEach((eventName) => {
43
+ video.addEventListener(eventName, () => this.callbacks.onLoading?.(false));
44
+ });
45
+ this.resizeObserver = new ResizeObserver(() => this._renderThumbnails());
46
+ this.resizeObserver.observe(root);
47
+ }
48
+
49
+ setSource(source) {
50
+ const sameMedia = Boolean(
51
+ source && this.source && source.id === this.source.id && source.revision === this.source.revision
52
+ );
53
+ this.source = source;
54
+ this.root.classList.toggle("hidden", !source);
55
+ if (!source) {
56
+ this.callbacks.onLoading?.(false);
57
+ this.video.pause();
58
+ this.video.removeAttribute("src");
59
+ this.filmstrip.replaceChildren();
60
+ this.maskLayer.replaceChildren();
61
+ return;
62
+ }
63
+ this.trimStart = source.trim_start;
64
+ this.trimEnd = source.trim_end;
65
+ const absoluteUrl = new URL(source.video_url, window.location.href).href;
66
+ if (this.video.src !== absoluteUrl) {
67
+ this.video.pause();
68
+ this.video.src = source.video_url;
69
+ this.video.load();
70
+ }
71
+ if (!sameMedia || (this.video.paused && !this.drag)) {
72
+ this.currentFrame = source.current_frame;
73
+ this._setVideoTime(this.currentFrame / source.fps);
74
+ }
75
+ this._renderThumbnails();
76
+ this._renderMasks();
77
+ this._renderGeometry();
78
+ }
79
+
80
+ togglePlayback() {
81
+ if (!this.source) return;
82
+ if (!this.video.paused) {
83
+ this.video.pause();
84
+ this.callbacks.onSeek?.(this.currentFrame, true);
85
+ return;
86
+ }
87
+ if (this.currentFrame < this.trimStart || this.currentFrame >= this.trimEnd) {
88
+ this._seekLocal(this.trimStart);
89
+ }
90
+ this.video.play().catch(() => {});
91
+ }
92
+
93
+ pause() {
94
+ this.video.pause();
95
+ }
96
+
97
+ jumpToMask(mask) {
98
+ this.pause();
99
+ this._seekLocal(mask.frame_index);
100
+ this.callbacks.onSeek?.(mask.frame_index, true);
101
+ }
102
+
103
+ _playbackChanged(playing) {
104
+ this.playButton.innerHTML = playing
105
+ ? '<svg class="playback-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z"></path></svg>'
106
+ : '<svg class="playback-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>';
107
+ this.playButton.setAttribute("aria-label", playing ? "Pause" : "Play");
108
+ this.playButton.title = playing ? "Pause" : "Play";
109
+ this.callbacks.onPlaybackChange?.(playing);
110
+ cancelAnimationFrame(this.animation);
111
+ if (playing) this._tick();
112
+ }
113
+
114
+ _tick() {
115
+ if (!this.source || this.video.paused) return;
116
+ const frame = clamp(
117
+ Math.round(this.video.currentTime * this.source.fps),
118
+ this.trimStart,
119
+ this.trimEnd,
120
+ );
121
+ this.currentFrame = frame;
122
+ if (this.video.currentTime >= (this.trimEnd + 0.75) / this.source.fps) {
123
+ this._loop();
124
+ } else {
125
+ this._renderGeometry();
126
+ }
127
+ this.animation = requestAnimationFrame(() => this._tick());
128
+ }
129
+
130
+ _loop() {
131
+ if (!this.source) return;
132
+ this._seekLocal(this.trimStart);
133
+ if (!this.video.paused) this.video.play().catch(() => {});
134
+ }
135
+
136
+ _seekLocal(frame) {
137
+ if (!this.source) return;
138
+ this.currentFrame = clamp(Math.round(frame), 0, this.source.frame_count - 1);
139
+ this._setVideoTime(this.currentFrame / this.source.fps);
140
+ this._renderGeometry();
141
+ }
142
+
143
+ _setVideoTime(seconds) {
144
+ const apply = () => {
145
+ try { this.video.currentTime = seconds; } catch (_) {}
146
+ };
147
+ if (this.video.readyState >= 1) apply();
148
+ else this.video.addEventListener("loadedmetadata", apply, { once: true });
149
+ }
150
+
151
+ _frameAt(clientX) {
152
+ const rect = this.root.getBoundingClientRect();
153
+ const ratio = clamp((clientX - rect.left) / Math.max(rect.width, 1), 0, 1);
154
+ return Math.round(ratio * Math.max(this.source.frame_count - 1, 0));
155
+ }
156
+
157
+ _pointerDown(event) {
158
+ if (!this.source || event.button !== 0 || event.target.closest(".timeline-mask-preview")) return;
159
+ const handle = event.target.closest(".trim-handle")?.dataset.handle;
160
+ this.pause();
161
+ this.drag = {
162
+ mode: handle || "playhead",
163
+ pointerId: event.pointerId,
164
+ originalStart: this.trimStart,
165
+ originalEnd: this.trimEnd,
166
+ };
167
+ this.root.setPointerCapture(event.pointerId);
168
+ this.root.addEventListener("pointermove", this._boundMove ||= (value) => this._pointerMove(value));
169
+ this.root.addEventListener("pointerup", this._boundUp ||= (value) => this._pointerUp(value));
170
+ this.root.addEventListener("pointercancel", this._boundUp);
171
+ this._pointerMove(event);
172
+ event.preventDefault();
173
+ }
174
+
175
+ _pointerMove(event) {
176
+ if (!this.drag || !this.source) return;
177
+ const frame = this._frameAt(event.clientX);
178
+ const minimum = Math.min(8, this.source.frame_count);
179
+ if (this.drag.mode === "left") {
180
+ this.trimStart = clamp(frame, 0, this.trimEnd - minimum + 1);
181
+ this._seekLocal(Math.max(this.currentFrame, this.trimStart));
182
+ } else if (this.drag.mode === "right") {
183
+ this.trimEnd = clamp(frame, this.trimStart + minimum - 1, this.source.frame_count - 1);
184
+ this._seekLocal(Math.min(this.currentFrame, this.trimEnd));
185
+ } else {
186
+ this._seekLocal(frame);
187
+ this.callbacks.onSeek?.(this.currentFrame, false);
188
+ }
189
+ this._renderGeometry();
190
+ }
191
+
192
+ async _pointerUp(event) {
193
+ if (!this.drag || !this.source) return;
194
+ const drag = this.drag;
195
+ this.drag = null;
196
+ try { this.root.releasePointerCapture(event.pointerId); } catch (_) {}
197
+ this.root.removeEventListener("pointermove", this._boundMove);
198
+ this.root.removeEventListener("pointerup", this._boundUp);
199
+ this.root.removeEventListener("pointercancel", this._boundUp);
200
+ if (drag.mode === "playhead") {
201
+ await this.callbacks.onSeek?.(this.currentFrame, true);
202
+ } else {
203
+ const accepted = await this.callbacks.onTrim?.(this.trimStart, this.trimEnd);
204
+ if (accepted === false) {
205
+ this.trimStart = drag.originalStart;
206
+ this.trimEnd = drag.originalEnd;
207
+ this._seekLocal(clamp(this.currentFrame, this.trimStart, this.trimEnd));
208
+ }
209
+ }
210
+ this._renderGeometry();
211
+ }
212
+
213
+ _ratio(frame) {
214
+ return this.source.frame_count <= 1 ? 0 : frame / (this.source.frame_count - 1);
215
+ }
216
+
217
+ _renderGeometry() {
218
+ if (!this.source) return;
219
+ const start = this._ratio(this.trimStart) * 100;
220
+ const end = this._ratio(this.trimEnd) * 100;
221
+ const current = this._ratio(this.currentFrame) * 100;
222
+ this.selection.style.left = `${start}%`;
223
+ this.selection.style.width = `${Math.max(0, end - start)}%`;
224
+ this.before.style.width = `${start}%`;
225
+ this.after.style.left = `${end}%`;
226
+ this.after.style.width = `${100 - end}%`;
227
+ this.leftHandle.style.left = `${start}%`;
228
+ this.rightHandle.style.left = `${end}%`;
229
+ this.playhead.style.left = `${current}%`;
230
+ this.time.textContent = `${timeLabel(this.currentFrame / this.source.fps)} · frame ${this.currentFrame + 1}/${this.source.frame_count} · ${this.trimEnd - this.trimStart + 1} selected`;
231
+ }
232
+
233
+ _renderThumbnails() {
234
+ if (!this.source || !this.root.clientWidth) return;
235
+ const height = 50;
236
+ const width = Math.max(48, height * this.source.width / this.source.height);
237
+ const count = Math.max(1, Math.floor(this.root.clientWidth / (width + 3)));
238
+ const indices = [];
239
+ for (let index = 0; index < Math.min(count, this.source.frame_count); index += 1) {
240
+ const denominator = Math.max(Math.min(count, this.source.frame_count) - 1, 1);
241
+ indices.push(Math.round(index * (this.source.frame_count - 1) / denominator));
242
+ }
243
+ const signature = `${this.source.id}:${this.source.revision}:${indices.join(",")}`;
244
+ if (signature === this.thumbnailSignature) return;
245
+ this.thumbnailSignature = signature;
246
+ this.filmstrip.replaceChildren(...indices.map((frame) => {
247
+ const item = document.createElement("div");
248
+ item.className = "timeline-thumbnail";
249
+ item.style.width = `${width}px`;
250
+ const image = document.createElement("img");
251
+ image.src = `/api/sessions/${this.source.session_id}/sources/${this.source.id}/frames/${frame}.jpg`;
252
+ image.alt = "";
253
+ item.append(image);
254
+ return item;
255
+ }));
256
+ }
257
+
258
+ _renderMasks() {
259
+ if (!this.source) return;
260
+ this.maskLayer.replaceChildren(...this.source.masks.map((mask, index) => {
261
+ const button = document.createElement("button");
262
+ button.className = "timeline-mask-preview";
263
+ if (mask.frame_index < this.trimStart || mask.frame_index > this.trimEnd) {
264
+ button.classList.add("excluded");
265
+ }
266
+ button.style.left = `${this._ratio(mask.frame_index) * 100}%`;
267
+ button.style.borderColor = mask.color;
268
+ button.style.top = `${3 + (index % 3) * 5}px`;
269
+ const excluded = button.classList.contains("excluded") ? " · outside selected interval" : "";
270
+ button.title = `Mask ${index + 1} · ${timeLabel(mask.frame_index / this.source.fps)}${excluded}`;
271
+ const image = document.createElement("img");
272
+ image.src = mask.preview_url;
273
+ image.alt = "";
274
+ button.append(image);
275
+ button.addEventListener("pointerdown", (event) => event.stopPropagation());
276
+ button.addEventListener("click", (event) => {
277
+ event.stopPropagation();
278
+ this.jumpToMask(mask);
279
+ this.callbacks.onMaskClick?.(mask);
280
+ });
281
+ return button;
282
+ }));
283
+ }
284
+ }
docs/images/scene_composition.png ADDED

Git LFS Details

  • SHA256: eafbc621c145de479748b6376d7e8bf938a2e293fb2a7cae1db97a7051c8d47e
  • Pointer size: 132 Bytes
  • Size of remote file: 7.07 MB
docs/images/title.png ADDED

Git LFS Details

  • SHA256: a41650a9ec3fc10e7eaaedb127ffdd278abea275cfaa810e7cada4827017ef08
  • Pointer size: 131 Bytes
  • Size of remote file: 418 kB
docs/images/what_moves.png ADDED
docs/videos/app_demo.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:db914ecc898ea2100affa5a57709342f62a7dce1806f770e368a8e060cfb209c
3
+ size 1952239
hubconf.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyTorch Hub entrypoints for WhatMoves and its Wan motion adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib as _hashlib
6
+ import json as _json
7
+ from pathlib import PurePosixPath as _PurePosixPath
8
+
9
+ import torch as _torch
10
+
11
+ dependencies = ["torch", "einops", "huggingface_hub"]
12
+
13
+ _WEIGHTS_REPO = "CompVis/WhatMoves"
14
+ _WEIGHTS_REVISION = "31820f6dbaa3f4f535bdb472d44db2c0bb03349c"
15
+ _WAN_BASE_MODEL_ID = "Wan-AI/Wan2.2-I2V-A14B"
16
+ _WAN_BASE_REVISION = "206a9ee1b7bfaaf8f7e4d81335650533490646a3"
17
+ _DEFAULT_VARIANT = "gated_static_step600000"
18
+ _VARIANTS = {
19
+ _DEFAULT_VARIANT: {
20
+ "what_moves": "what_moves/gated_static_step600000.pt",
21
+ "metadata": "wan/gated_static_step600000/metadata.json",
22
+ }
23
+ }
24
+ _WAN_ALLOW_PATTERNS = (
25
+ "high_noise_model/*",
26
+ "low_noise_model/*",
27
+ "Wan2.1_VAE.pth",
28
+ "models_t5_umt5-xxl-enc-bf16.pth",
29
+ "google/umt5-xxl/*",
30
+ )
31
+
32
+
33
+ def _variant_files(variant: str) -> dict[str, str]:
34
+ try:
35
+ return _VARIANTS[variant]
36
+ except KeyError as error:
37
+ choices = ", ".join(sorted(_VARIANTS))
38
+ raise ValueError(
39
+ f"Unknown WhatMoves variant {variant!r}; choose: {choices}"
40
+ ) from error
41
+
42
+
43
+ def _download_file(
44
+ filename: str,
45
+ *,
46
+ repo_id: str,
47
+ revision: str | None,
48
+ cache_dir: str | None,
49
+ token: bool | str | None,
50
+ force_download: bool,
51
+ local_files_only: bool,
52
+ ) -> str:
53
+ from huggingface_hub import hf_hub_download
54
+
55
+ return hf_hub_download(
56
+ repo_id=repo_id,
57
+ filename=filename,
58
+ revision=revision,
59
+ cache_dir=cache_dir,
60
+ token=token,
61
+ force_download=force_download,
62
+ local_files_only=local_files_only,
63
+ )
64
+
65
+
66
+ def _download_kwargs(
67
+ *,
68
+ repo_id: str,
69
+ revision: str | None,
70
+ cache_dir: str | None,
71
+ hf_token: bool | str | None,
72
+ force_download: bool,
73
+ local_files_only: bool,
74
+ ) -> dict[str, object]:
75
+ return {
76
+ "repo_id": repo_id,
77
+ "revision": revision,
78
+ "cache_dir": cache_dir,
79
+ "token": hf_token,
80
+ "force_download": force_download,
81
+ "local_files_only": local_files_only,
82
+ }
83
+
84
+
85
+ def _release_revision(repo_id: str, revision: str | None) -> str | None:
86
+ """Pin official release downloads while leaving custom repositories alone."""
87
+ if revision is None and repo_id == _WEIGHTS_REPO:
88
+ return _WEIGHTS_REVISION
89
+ return revision
90
+
91
+
92
+ def _metadata(
93
+ variant: str,
94
+ *,
95
+ repo_id: str,
96
+ revision: str | None,
97
+ cache_dir: str | None,
98
+ hf_token: bool | str | None,
99
+ force_download: bool,
100
+ local_files_only: bool,
101
+ ) -> dict[str, object]:
102
+ files = _variant_files(variant)
103
+ path = _download_file(
104
+ files["metadata"],
105
+ **_download_kwargs(
106
+ repo_id=repo_id,
107
+ revision=revision,
108
+ cache_dir=cache_dir,
109
+ hf_token=hf_token,
110
+ force_download=force_download,
111
+ local_files_only=local_files_only,
112
+ ),
113
+ )
114
+ with open(path, encoding="utf-8") as file:
115
+ metadata = _json.load(file)
116
+ required = {
117
+ "format": "whatmoves_wan_motion_adapter",
118
+ "format_version": 1,
119
+ "variant": variant,
120
+ }
121
+ mismatched = {
122
+ name: metadata.get(name)
123
+ for name, expected in required.items()
124
+ if metadata.get(name) != expected
125
+ }
126
+ if mismatched:
127
+ raise ValueError(f"Invalid Wan adapter metadata in {path}: {mismatched}")
128
+ return metadata
129
+
130
+
131
+ def _metadata_path(metadata: dict[str, object], name: str) -> str:
132
+ value = metadata.get(name)
133
+ if not isinstance(value, str):
134
+ raise ValueError(f"Wan adapter metadata has no string {name!r}")
135
+ path = _PurePosixPath(value)
136
+ if path.is_absolute() or ".." in path.parts:
137
+ raise ValueError(f"Wan adapter metadata has unsafe {name!r}: {value!r}")
138
+ return value
139
+
140
+
141
+ def _verify_download(
142
+ path: str,
143
+ filename: str,
144
+ metadata: dict[str, object],
145
+ ) -> None:
146
+ hashes = metadata.get("sha256")
147
+ expected = hashes.get(filename) if isinstance(hashes, dict) else None
148
+ if not isinstance(expected, str) or len(expected) != 64:
149
+ raise ValueError(f"Wan adapter metadata has no SHA-256 for {filename!r}")
150
+ digest = _hashlib.sha256()
151
+ with open(path, "rb") as file:
152
+ for chunk in iter(lambda: file.read(8 * 1024 * 1024), b""):
153
+ digest.update(chunk)
154
+ actual = digest.hexdigest()
155
+ if actual != expected:
156
+ raise RuntimeError(
157
+ f"Checksum mismatch for {filename}: expected {expected}, got {actual}"
158
+ )
159
+
160
+
161
+ def what_moves(
162
+ *,
163
+ variant: str = _DEFAULT_VARIANT,
164
+ checkpoint: str | None = None,
165
+ device: str | _torch.device = "cpu",
166
+ dtype: _torch.dtype | None = None,
167
+ repo_id: str = _WEIGHTS_REPO,
168
+ revision: str | None = None,
169
+ cache_dir: str | None = None,
170
+ hf_token: bool | str | None = None,
171
+ force_download: bool = False,
172
+ local_files_only: bool = False,
173
+ verify_checksum: bool = True,
174
+ **model_kwargs,
175
+ ):
176
+ """Load the released localized-motion encoder.
177
+
178
+ By default the checkpoint is downloaded from ``CompVis/WhatMoves`` and
179
+ cached by Hugging Face. Pass ``checkpoint`` to use a local file instead.
180
+ Private repositories work after ``hf auth login`` or with ``HF_TOKEN``.
181
+ """
182
+ _variant_files(variant)
183
+ if checkpoint is None:
184
+ revision = _release_revision(repo_id, revision)
185
+ metadata = _metadata(
186
+ variant,
187
+ repo_id=repo_id,
188
+ revision=revision,
189
+ cache_dir=cache_dir,
190
+ hf_token=hf_token,
191
+ force_download=force_download,
192
+ local_files_only=local_files_only,
193
+ )
194
+ filename = _metadata_path(metadata, "what_moves_checkpoint")
195
+ checkpoint = _download_file(
196
+ filename,
197
+ **_download_kwargs(
198
+ repo_id=repo_id,
199
+ revision=revision,
200
+ cache_dir=cache_dir,
201
+ hf_token=hf_token,
202
+ force_download=force_download,
203
+ local_files_only=local_files_only,
204
+ ),
205
+ )
206
+ if verify_checksum:
207
+ _verify_download(checkpoint, filename, metadata)
208
+
209
+ from what_moves import WhatMoves as _WhatMoves
210
+
211
+ return _WhatMoves.from_pretrained(
212
+ checkpoint,
213
+ device=device,
214
+ dtype=dtype,
215
+ **model_kwargs,
216
+ )
217
+
218
+
219
+ def wan(
220
+ *,
221
+ variant: str = _DEFAULT_VARIANT,
222
+ wan_checkpoint: str | None = None,
223
+ what_moves_checkpoint: str | None = None,
224
+ adapter_checkpoint: str | None = None,
225
+ device: str | _torch.device = "cuda",
226
+ dtype: _torch.dtype = _torch.bfloat16,
227
+ repo_id: str = _WEIGHTS_REPO,
228
+ revision: str | None = None,
229
+ base_model_id: str | None = None,
230
+ base_revision: str | None = None,
231
+ cache_dir: str | None = None,
232
+ hf_token: bool | str | None = None,
233
+ force_download: bool = False,
234
+ local_files_only: bool = False,
235
+ verify_checksum: bool = True,
236
+ **model_kwargs,
237
+ ):
238
+ """Load Wan2.2 I2V-A14B with the matching WhatMoves adapter.
239
+
240
+ Release weights and metadata come from ``CompVis/WhatMoves``. The official
241
+ Wan base snapshot is downloaded automatically unless ``wan_checkpoint`` is
242
+ supplied. Any checkpoint argument can independently override its download.
243
+ """
244
+ _variant_files(variant)
245
+ revision = _release_revision(repo_id, revision)
246
+ metadata = None
247
+ if (
248
+ what_moves_checkpoint is None
249
+ or adapter_checkpoint is None
250
+ or (wan_checkpoint is None and base_model_id is None)
251
+ ):
252
+ metadata = _metadata(
253
+ variant,
254
+ repo_id=repo_id,
255
+ revision=revision,
256
+ cache_dir=cache_dir,
257
+ hf_token=hf_token,
258
+ force_download=force_download,
259
+ local_files_only=local_files_only,
260
+ )
261
+ download_kwargs = _download_kwargs(
262
+ repo_id=repo_id,
263
+ revision=revision,
264
+ cache_dir=cache_dir,
265
+ hf_token=hf_token,
266
+ force_download=force_download,
267
+ local_files_only=local_files_only,
268
+ )
269
+ if what_moves_checkpoint is None:
270
+ filename = _metadata_path(metadata, "what_moves_checkpoint")
271
+ what_moves_checkpoint = _download_file(
272
+ filename,
273
+ **download_kwargs,
274
+ )
275
+ if verify_checksum:
276
+ _verify_download(what_moves_checkpoint, filename, metadata)
277
+ if adapter_checkpoint is None:
278
+ filename = _metadata_path(metadata, "adapter_checkpoint")
279
+ adapter_checkpoint = _download_file(
280
+ filename,
281
+ **download_kwargs,
282
+ )
283
+ if verify_checksum:
284
+ _verify_download(adapter_checkpoint, filename, metadata)
285
+ if wan_checkpoint is None:
286
+ if base_model_id is None:
287
+ base_model_id = _metadata_path(metadata, "base_model_id")
288
+ if base_revision is None and base_model_id == _WAN_BASE_MODEL_ID:
289
+ base_revision = _WAN_BASE_REVISION
290
+ from huggingface_hub import snapshot_download
291
+
292
+ wan_checkpoint = snapshot_download(
293
+ repo_id=base_model_id,
294
+ revision=base_revision,
295
+ cache_dir=cache_dir,
296
+ token=hf_token,
297
+ force_download=force_download,
298
+ local_files_only=local_files_only,
299
+ allow_patterns=list(_WAN_ALLOW_PATTERNS),
300
+ )
301
+
302
+ from what_moves import load_wan_model as _load_wan_model
303
+
304
+ return _load_wan_model(
305
+ wan_checkpoint,
306
+ what_moves_checkpoint,
307
+ adapter_checkpoint,
308
+ device=device,
309
+ dtype=dtype,
310
+ **model_kwargs,
311
+ )
requirements-core.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Minimal dependencies for the WhatMoves motion encoder.
2
+ torch==2.8.0
3
+ einops==0.8.1
4
+ huggingface-hub==0.36.2
5
+ numpy==1.26.4
requirements-wan.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Additional dependencies for Wan motion transfer.
2
+ -r requirements-core.txt
3
+ peft==0.17.0
4
+ diffusers @ git+https://github.com/huggingface/diffusers.git@7ea065c5070a5278259e6f1effa9dccea232e62a
5
+ transformers==4.57.1
6
+ tokenizers==0.22.1
7
+ accelerate==1.10.0
8
+ safetensors==0.5.3
9
+ ftfy==6.3.1
10
+ regex==2024.11.6
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Complete, Hugging Face Spaces-ready WhatMoves installation.
2
+ -r app/requirements.txt
wan/LICENSE.txt 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 [yyyy] [name of copyright owner]
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.
wan/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vendored Wan2.2 runtime
2
+
3
+ This directory contains the minimal inference runtime needed by the WhatMoves
4
+ Wan I2V-A14B adapter. The source files were reduced from the official
5
+ [Wan2.2 repository](https://github.com/Wan-Video/Wan2.2) at
6
+ commit `388807310646ed5f318a99f8e8d9ad28c5b65373`.
7
+
8
+ Wan2.2 is distributed under the Apache License 2.0; see `LICENSE.txt` in this
9
+ directory. Changes are limited to omitting unused pipelines and model variants,
10
+ removing unused imports, and using PyTorch's restricted tensor-only checkpoint
11
+ loader and current autocast API. Attention is routed through Wan's existing
12
+ PyTorch-SDPA fallback when FlashAttention is unavailable. Modified files are
13
+ marked in their headers.
wan/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
2
+ # Modified by the WhatMoves authors: removed unused pipeline imports.
3
+
4
+ """Minimal vendored runtime from the official Wan2.2 repository."""
wan/modules/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
2
+ # Modified by the WhatMoves authors: removed eager re-exports.
3
+
4
+ """Wan2.2 model, text encoder, attention, and VAE modules."""
wan/modules/attention.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
2
+ import torch
3
+
4
+ try:
5
+ import flash_attn_interface
6
+ FLASH_ATTN_3_AVAILABLE = True
7
+ except ModuleNotFoundError:
8
+ FLASH_ATTN_3_AVAILABLE = False
9
+
10
+ try:
11
+ import flash_attn
12
+ FLASH_ATTN_2_AVAILABLE = True
13
+ except ModuleNotFoundError:
14
+ FLASH_ATTN_2_AVAILABLE = False
15
+
16
+ import warnings
17
+
18
+ __all__ = [
19
+ 'flash_attention',
20
+ 'attention',
21
+ ]
22
+
23
+
24
+ def flash_attention(
25
+ q,
26
+ k,
27
+ v,
28
+ q_lens=None,
29
+ k_lens=None,
30
+ dropout_p=0.,
31
+ softmax_scale=None,
32
+ q_scale=None,
33
+ causal=False,
34
+ window_size=(-1, -1),
35
+ deterministic=False,
36
+ dtype=torch.bfloat16,
37
+ version=None,
38
+ ):
39
+ """
40
+ q: [B, Lq, Nq, C1].
41
+ k: [B, Lk, Nk, C1].
42
+ v: [B, Lk, Nk, C2]. Nq must be divisible by Nk.
43
+ q_lens: [B].
44
+ k_lens: [B].
45
+ dropout_p: float. Dropout probability.
46
+ softmax_scale: float. The scaling of QK^T before applying softmax.
47
+ causal: bool. Whether to apply causal attention mask.
48
+ window_size: (left right). If not (-1, -1), apply sliding window local attention.
49
+ deterministic: bool. If True, slightly slower and uses more memory.
50
+ dtype: torch.dtype. Apply when dtype of q/k/v is not float16/bfloat16.
51
+ """
52
+ half_dtypes = (torch.float16, torch.bfloat16)
53
+ assert dtype in half_dtypes
54
+ assert q.device.type == 'cuda' and q.size(-1) <= 256
55
+
56
+ # params
57
+ b, lq, lk, out_dtype = q.size(0), q.size(1), k.size(1), q.dtype
58
+
59
+ def half(x):
60
+ return x if x.dtype in half_dtypes else x.to(dtype)
61
+
62
+ # preprocess query
63
+ if q_lens is None:
64
+ q = half(q.flatten(0, 1))
65
+ q_lens = torch.tensor(
66
+ [lq] * b, dtype=torch.int32).to(
67
+ device=q.device, non_blocking=True)
68
+ else:
69
+ q = half(torch.cat([u[:v] for u, v in zip(q, q_lens)]))
70
+
71
+ # preprocess key, value
72
+ if k_lens is None:
73
+ k = half(k.flatten(0, 1))
74
+ v = half(v.flatten(0, 1))
75
+ k_lens = torch.tensor(
76
+ [lk] * b, dtype=torch.int32).to(
77
+ device=k.device, non_blocking=True)
78
+ else:
79
+ k = half(torch.cat([u[:v] for u, v in zip(k, k_lens)]))
80
+ v = half(torch.cat([u[:v] for u, v in zip(v, k_lens)]))
81
+
82
+ q = q.to(v.dtype)
83
+ k = k.to(v.dtype)
84
+
85
+ if q_scale is not None:
86
+ q = q * q_scale
87
+
88
+ if version is not None and version == 3 and not FLASH_ATTN_3_AVAILABLE:
89
+ warnings.warn(
90
+ 'Flash attention 3 is not available, use flash attention 2 instead.'
91
+ )
92
+
93
+ # apply attention
94
+ if (version is None or version == 3) and FLASH_ATTN_3_AVAILABLE:
95
+ # Note: dropout_p, window_size are not supported in FA3 now.
96
+ x = flash_attn_interface.flash_attn_varlen_func(
97
+ q=q,
98
+ k=k,
99
+ v=v,
100
+ cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum(
101
+ 0, dtype=torch.int32).to(q.device, non_blocking=True),
102
+ cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum(
103
+ 0, dtype=torch.int32).to(q.device, non_blocking=True),
104
+ seqused_q=None,
105
+ seqused_k=None,
106
+ max_seqlen_q=lq,
107
+ max_seqlen_k=lk,
108
+ softmax_scale=softmax_scale,
109
+ causal=causal,
110
+ deterministic=deterministic)[0].unflatten(0, (b, lq))
111
+ else:
112
+ assert FLASH_ATTN_2_AVAILABLE
113
+ x = flash_attn.flash_attn_varlen_func(
114
+ q=q,
115
+ k=k,
116
+ v=v,
117
+ cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum(
118
+ 0, dtype=torch.int32).to(q.device, non_blocking=True),
119
+ cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum(
120
+ 0, dtype=torch.int32).to(q.device, non_blocking=True),
121
+ max_seqlen_q=lq,
122
+ max_seqlen_k=lk,
123
+ dropout_p=dropout_p,
124
+ softmax_scale=softmax_scale,
125
+ causal=causal,
126
+ window_size=window_size,
127
+ deterministic=deterministic).unflatten(0, (b, lq))
128
+
129
+ # output
130
+ return x.type(out_dtype)
131
+
132
+
133
+ def attention(
134
+ q,
135
+ k,
136
+ v,
137
+ q_lens=None,
138
+ k_lens=None,
139
+ dropout_p=0.,
140
+ softmax_scale=None,
141
+ q_scale=None,
142
+ causal=False,
143
+ window_size=(-1, -1),
144
+ deterministic=False,
145
+ dtype=torch.bfloat16,
146
+ fa_version=None,
147
+ ):
148
+ if FLASH_ATTN_2_AVAILABLE or FLASH_ATTN_3_AVAILABLE:
149
+ return flash_attention(
150
+ q=q,
151
+ k=k,
152
+ v=v,
153
+ q_lens=q_lens,
154
+ k_lens=k_lens,
155
+ dropout_p=dropout_p,
156
+ softmax_scale=softmax_scale,
157
+ q_scale=q_scale,
158
+ causal=causal,
159
+ window_size=window_size,
160
+ deterministic=deterministic,
161
+ dtype=dtype,
162
+ version=fa_version,
163
+ )
164
+ else:
165
+ if q_lens is not None or k_lens is not None:
166
+ warnings.warn(
167
+ 'Padding mask is disabled when using scaled_dot_product_attention. It can have a significant impact on performance.'
168
+ )
169
+ attn_mask = None
170
+
171
+ q = q.transpose(1, 2).to(dtype)
172
+ k = k.transpose(1, 2).to(dtype)
173
+ v = v.transpose(1, 2).to(dtype)
174
+
175
+ out = torch.nn.functional.scaled_dot_product_attention(
176
+ q, k, v, attn_mask=attn_mask, is_causal=causal, dropout_p=dropout_p)
177
+
178
+ out = out.transpose(1, 2).contiguous()
179
+ return out
wan/modules/model.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
2
+ # Modified by the WhatMoves authors: routed attention through the upstream
3
+ # PyTorch-SDPA fallback.
4
+ import math
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
9
+ from diffusers.models.modeling_utils import ModelMixin
10
+
11
+ from .attention import attention
12
+
13
+ __all__ = ['WanModel']
14
+
15
+
16
+ def sinusoidal_embedding_1d(dim, position):
17
+ # preprocess
18
+ assert dim % 2 == 0
19
+ half = dim // 2
20
+ position = position.type(torch.float64)
21
+
22
+ # calculation
23
+ sinusoid = torch.outer(
24
+ position, torch.pow(10000, -torch.arange(half).to(position).div(half)))
25
+ x = torch.cat([torch.cos(sinusoid), torch.sin(sinusoid)], dim=1)
26
+ return x
27
+
28
+
29
+ @torch.amp.autocast('cuda', enabled=False)
30
+ def rope_params(max_seq_len, dim, theta=10000):
31
+ assert dim % 2 == 0
32
+ freqs = torch.outer(
33
+ torch.arange(max_seq_len),
34
+ 1.0 / torch.pow(theta,
35
+ torch.arange(0, dim, 2).to(torch.float64).div(dim)))
36
+ freqs = torch.polar(torch.ones_like(freqs), freqs)
37
+ return freqs
38
+
39
+
40
+ @torch.amp.autocast('cuda', enabled=False)
41
+ def rope_apply(x, grid_sizes, freqs):
42
+ n, c = x.size(2), x.size(3) // 2
43
+
44
+ # split freqs
45
+ freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1)
46
+
47
+ # loop over samples
48
+ output = []
49
+ for i, (f, h, w) in enumerate(grid_sizes.tolist()):
50
+ seq_len = f * h * w
51
+
52
+ # precompute multipliers
53
+ x_i = torch.view_as_complex(x[i, :seq_len].to(torch.float64).reshape(
54
+ seq_len, n, -1, 2))
55
+ freqs_i = torch.cat([
56
+ freqs[0][:f].view(f, 1, 1, -1).expand(f, h, w, -1),
57
+ freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1),
58
+ freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1)
59
+ ],
60
+ dim=-1).reshape(seq_len, 1, -1)
61
+
62
+ # apply rotary embedding
63
+ x_i = torch.view_as_real(x_i * freqs_i).flatten(2)
64
+ x_i = torch.cat([x_i, x[i, seq_len:]])
65
+
66
+ # append to collection
67
+ output.append(x_i)
68
+ return torch.stack(output).float()
69
+
70
+
71
+ class WanRMSNorm(nn.Module):
72
+
73
+ def __init__(self, dim, eps=1e-5):
74
+ super().__init__()
75
+ self.dim = dim
76
+ self.eps = eps
77
+ self.weight = nn.Parameter(torch.ones(dim))
78
+
79
+ def forward(self, x):
80
+ r"""
81
+ Args:
82
+ x(Tensor): Shape [B, L, C]
83
+ """
84
+ return self._norm(x.float()).type_as(x) * self.weight
85
+
86
+ def _norm(self, x):
87
+ return x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)
88
+
89
+
90
+ class WanLayerNorm(nn.LayerNorm):
91
+
92
+ def __init__(self, dim, eps=1e-6, elementwise_affine=False):
93
+ super().__init__(dim, elementwise_affine=elementwise_affine, eps=eps)
94
+
95
+ def forward(self, x):
96
+ r"""
97
+ Args:
98
+ x(Tensor): Shape [B, L, C]
99
+ """
100
+ return super().forward(x.float()).type_as(x)
101
+
102
+
103
+ class WanSelfAttention(nn.Module):
104
+
105
+ def __init__(self,
106
+ dim,
107
+ num_heads,
108
+ window_size=(-1, -1),
109
+ qk_norm=True,
110
+ eps=1e-6):
111
+ assert dim % num_heads == 0
112
+ super().__init__()
113
+ self.dim = dim
114
+ self.num_heads = num_heads
115
+ self.head_dim = dim // num_heads
116
+ self.window_size = window_size
117
+ self.qk_norm = qk_norm
118
+ self.eps = eps
119
+
120
+ # layers
121
+ self.q = nn.Linear(dim, dim)
122
+ self.k = nn.Linear(dim, dim)
123
+ self.v = nn.Linear(dim, dim)
124
+ self.o = nn.Linear(dim, dim)
125
+ self.norm_q = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity()
126
+ self.norm_k = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity()
127
+
128
+ def forward(self, x, seq_lens, grid_sizes, freqs):
129
+ r"""
130
+ Args:
131
+ x(Tensor): Shape [B, L, num_heads, C / num_heads]
132
+ seq_lens(Tensor): Shape [B]
133
+ grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W)
134
+ freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2]
135
+ """
136
+ b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim
137
+
138
+ # query, key, value function
139
+ def qkv_fn(x):
140
+ q = self.norm_q(self.q(x)).view(b, s, n, d)
141
+ k = self.norm_k(self.k(x)).view(b, s, n, d)
142
+ v = self.v(x).view(b, s, n, d)
143
+ return q, k, v
144
+
145
+ q, k, v = qkv_fn(x)
146
+
147
+ x = attention(
148
+ q=rope_apply(q, grid_sizes, freqs),
149
+ k=rope_apply(k, grid_sizes, freqs),
150
+ v=v,
151
+ k_lens=seq_lens,
152
+ window_size=self.window_size)
153
+
154
+ # output
155
+ x = x.flatten(2)
156
+ x = self.o(x)
157
+ return x
158
+
159
+
160
+ class WanCrossAttention(WanSelfAttention):
161
+
162
+ def forward(self, x, context, context_lens):
163
+ r"""
164
+ Args:
165
+ x(Tensor): Shape [B, L1, C]
166
+ context(Tensor): Shape [B, L2, C]
167
+ context_lens(Tensor): Shape [B]
168
+ """
169
+ b, n, d = x.size(0), self.num_heads, self.head_dim
170
+
171
+ # compute query, key, value
172
+ q = self.norm_q(self.q(x)).view(b, -1, n, d)
173
+ k = self.norm_k(self.k(context)).view(b, -1, n, d)
174
+ v = self.v(context).view(b, -1, n, d)
175
+
176
+ # compute attention
177
+ x = attention(q, k, v, k_lens=context_lens)
178
+
179
+ # output
180
+ x = x.flatten(2)
181
+ x = self.o(x)
182
+ return x
183
+
184
+
185
+ class WanAttentionBlock(nn.Module):
186
+
187
+ def __init__(self,
188
+ dim,
189
+ ffn_dim,
190
+ num_heads,
191
+ window_size=(-1, -1),
192
+ qk_norm=True,
193
+ cross_attn_norm=False,
194
+ eps=1e-6):
195
+ super().__init__()
196
+ self.dim = dim
197
+ self.ffn_dim = ffn_dim
198
+ self.num_heads = num_heads
199
+ self.window_size = window_size
200
+ self.qk_norm = qk_norm
201
+ self.cross_attn_norm = cross_attn_norm
202
+ self.eps = eps
203
+
204
+ # layers
205
+ self.norm1 = WanLayerNorm(dim, eps)
206
+ self.self_attn = WanSelfAttention(dim, num_heads, window_size, qk_norm,
207
+ eps)
208
+ self.norm3 = WanLayerNorm(
209
+ dim, eps,
210
+ elementwise_affine=True) if cross_attn_norm else nn.Identity()
211
+ self.cross_attn = WanCrossAttention(dim, num_heads, (-1, -1), qk_norm,
212
+ eps)
213
+ self.norm2 = WanLayerNorm(dim, eps)
214
+ self.ffn = nn.Sequential(
215
+ nn.Linear(dim, ffn_dim), nn.GELU(approximate='tanh'),
216
+ nn.Linear(ffn_dim, dim))
217
+
218
+ # modulation
219
+ self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5)
220
+
221
+ def forward(
222
+ self,
223
+ x,
224
+ e,
225
+ seq_lens,
226
+ grid_sizes,
227
+ freqs,
228
+ context,
229
+ context_lens,
230
+ ):
231
+ r"""
232
+ Args:
233
+ x(Tensor): Shape [B, L, C]
234
+ e(Tensor): Shape [B, L1, 6, C]
235
+ seq_lens(Tensor): Shape [B], length of each sequence in batch
236
+ grid_sizes(Tensor): Shape [B, 3], the second dimension contains (F, H, W)
237
+ freqs(Tensor): Rope freqs, shape [1024, C / num_heads / 2]
238
+ """
239
+ assert e.dtype == torch.float32
240
+ with torch.amp.autocast('cuda', dtype=torch.float32):
241
+ e = (self.modulation.unsqueeze(0) + e).chunk(6, dim=2)
242
+ assert e[0].dtype == torch.float32
243
+
244
+ # self-attention
245
+ y = self.self_attn(
246
+ self.norm1(x).float() * (1 + e[1].squeeze(2)) + e[0].squeeze(2),
247
+ seq_lens, grid_sizes, freqs)
248
+ with torch.amp.autocast('cuda', dtype=torch.float32):
249
+ x = x + y * e[2].squeeze(2)
250
+
251
+ # cross-attention & ffn function
252
+ def cross_attn_ffn(x, context, context_lens, e):
253
+ x = x + self.cross_attn(self.norm3(x), context, context_lens)
254
+ y = self.ffn(
255
+ self.norm2(x).float() * (1 + e[4].squeeze(2)) + e[3].squeeze(2))
256
+ with torch.amp.autocast('cuda', dtype=torch.float32):
257
+ x = x + y * e[5].squeeze(2)
258
+ return x
259
+
260
+ x = cross_attn_ffn(x, context, context_lens, e)
261
+ return x
262
+
263
+
264
+ class Head(nn.Module):
265
+
266
+ def __init__(self, dim, out_dim, patch_size, eps=1e-6):
267
+ super().__init__()
268
+ self.dim = dim
269
+ self.out_dim = out_dim
270
+ self.patch_size = patch_size
271
+ self.eps = eps
272
+
273
+ # layers
274
+ out_dim = math.prod(patch_size) * out_dim
275
+ self.norm = WanLayerNorm(dim, eps)
276
+ self.head = nn.Linear(dim, out_dim)
277
+
278
+ # modulation
279
+ self.modulation = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5)
280
+
281
+ def forward(self, x, e):
282
+ r"""
283
+ Args:
284
+ x(Tensor): Shape [B, L1, C]
285
+ e(Tensor): Shape [B, L1, C]
286
+ """
287
+ assert e.dtype == torch.float32
288
+ with torch.amp.autocast('cuda', dtype=torch.float32):
289
+ e = (self.modulation.unsqueeze(0) + e.unsqueeze(2)).chunk(2, dim=2)
290
+ x = (
291
+ self.head(
292
+ self.norm(x) * (1 + e[1].squeeze(2)) + e[0].squeeze(2)))
293
+ return x
294
+
295
+
296
+ class WanModel(ModelMixin, ConfigMixin):
297
+ r"""
298
+ Wan diffusion backbone supporting both text-to-video and image-to-video.
299
+ """
300
+
301
+ ignore_for_config = [
302
+ 'patch_size', 'cross_attn_norm', 'qk_norm', 'text_dim', 'window_size'
303
+ ]
304
+ _no_split_modules = ['WanAttentionBlock']
305
+
306
+ @register_to_config
307
+ def __init__(self,
308
+ model_type='t2v',
309
+ patch_size=(1, 2, 2),
310
+ text_len=512,
311
+ in_dim=16,
312
+ dim=2048,
313
+ ffn_dim=8192,
314
+ freq_dim=256,
315
+ text_dim=4096,
316
+ out_dim=16,
317
+ num_heads=16,
318
+ num_layers=32,
319
+ window_size=(-1, -1),
320
+ qk_norm=True,
321
+ cross_attn_norm=True,
322
+ eps=1e-6):
323
+ r"""
324
+ Initialize the diffusion model backbone.
325
+
326
+ Args:
327
+ model_type (`str`, *optional*, defaults to 't2v'):
328
+ Model variant - 't2v' (text-to-video) or 'i2v' (image-to-video)
329
+ patch_size (`tuple`, *optional*, defaults to (1, 2, 2)):
330
+ 3D patch dimensions for video embedding (t_patch, h_patch, w_patch)
331
+ text_len (`int`, *optional*, defaults to 512):
332
+ Fixed length for text embeddings
333
+ in_dim (`int`, *optional*, defaults to 16):
334
+ Input video channels (C_in)
335
+ dim (`int`, *optional*, defaults to 2048):
336
+ Hidden dimension of the transformer
337
+ ffn_dim (`int`, *optional*, defaults to 8192):
338
+ Intermediate dimension in feed-forward network
339
+ freq_dim (`int`, *optional*, defaults to 256):
340
+ Dimension for sinusoidal time embeddings
341
+ text_dim (`int`, *optional*, defaults to 4096):
342
+ Input dimension for text embeddings
343
+ out_dim (`int`, *optional*, defaults to 16):
344
+ Output video channels (C_out)
345
+ num_heads (`int`, *optional*, defaults to 16):
346
+ Number of attention heads
347
+ num_layers (`int`, *optional*, defaults to 32):
348
+ Number of transformer blocks
349
+ window_size (`tuple`, *optional*, defaults to (-1, -1)):
350
+ Window size for local attention (-1 indicates global attention)
351
+ qk_norm (`bool`, *optional*, defaults to True):
352
+ Enable query/key normalization
353
+ cross_attn_norm (`bool`, *optional*, defaults to False):
354
+ Enable cross-attention normalization
355
+ eps (`float`, *optional*, defaults to 1e-6):
356
+ Epsilon value for normalization layers
357
+ """
358
+
359
+ super().__init__()
360
+
361
+ assert model_type in ['t2v', 'i2v', 'ti2v']
362
+ self.model_type = model_type
363
+
364
+ self.patch_size = patch_size
365
+ self.text_len = text_len
366
+ self.in_dim = in_dim
367
+ self.dim = dim
368
+ self.ffn_dim = ffn_dim
369
+ self.freq_dim = freq_dim
370
+ self.text_dim = text_dim
371
+ self.out_dim = out_dim
372
+ self.num_heads = num_heads
373
+ self.num_layers = num_layers
374
+ self.window_size = window_size
375
+ self.qk_norm = qk_norm
376
+ self.cross_attn_norm = cross_attn_norm
377
+ self.eps = eps
378
+
379
+ # embeddings
380
+ self.patch_embedding = nn.Conv3d(
381
+ in_dim, dim, kernel_size=patch_size, stride=patch_size)
382
+ self.text_embedding = nn.Sequential(
383
+ nn.Linear(text_dim, dim), nn.GELU(approximate='tanh'),
384
+ nn.Linear(dim, dim))
385
+
386
+ self.time_embedding = nn.Sequential(
387
+ nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim))
388
+ self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 6))
389
+
390
+ # blocks
391
+ self.blocks = nn.ModuleList([
392
+ WanAttentionBlock(dim, ffn_dim, num_heads, window_size, qk_norm,
393
+ cross_attn_norm, eps) for _ in range(num_layers)
394
+ ])
395
+
396
+ # head
397
+ self.head = Head(dim, out_dim, patch_size, eps)
398
+
399
+ # buffers (don't use register_buffer otherwise dtype will be changed in to())
400
+ assert (dim % num_heads) == 0 and (dim // num_heads) % 2 == 0
401
+ d = dim // num_heads
402
+ self.freqs = torch.cat([
403
+ rope_params(1024, d - 4 * (d // 6)),
404
+ rope_params(1024, 2 * (d // 6)),
405
+ rope_params(1024, 2 * (d // 6))
406
+ ],
407
+ dim=1)
408
+
409
+ # initialize weights
410
+ self.init_weights()
411
+
412
+ def forward(
413
+ self,
414
+ x,
415
+ t,
416
+ context,
417
+ seq_len,
418
+ y=None,
419
+ ):
420
+ r"""
421
+ Forward pass through the diffusion model
422
+
423
+ Args:
424
+ x (List[Tensor]):
425
+ List of input video tensors, each with shape [C_in, F, H, W]
426
+ t (Tensor):
427
+ Diffusion timesteps tensor of shape [B]
428
+ context (List[Tensor]):
429
+ List of text embeddings each with shape [L, C]
430
+ seq_len (`int`):
431
+ Maximum sequence length for positional encoding
432
+ y (List[Tensor], *optional*):
433
+ Conditional video inputs for image-to-video mode, same shape as x
434
+
435
+ Returns:
436
+ List[Tensor]:
437
+ List of denoised video tensors with original input shapes [C_out, F, H / 8, W / 8]
438
+ """
439
+ if self.model_type == 'i2v':
440
+ assert y is not None
441
+ # params
442
+ device = self.patch_embedding.weight.device
443
+ if self.freqs.device != device:
444
+ self.freqs = self.freqs.to(device)
445
+
446
+ if y is not None:
447
+ x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)]
448
+
449
+ # embeddings
450
+ x = [self.patch_embedding(u.unsqueeze(0)) for u in x]
451
+ grid_sizes = torch.stack(
452
+ [torch.tensor(u.shape[2:], dtype=torch.long) for u in x])
453
+ x = [u.flatten(2).transpose(1, 2) for u in x]
454
+ seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long)
455
+ assert seq_lens.max() <= seq_len
456
+ x = torch.cat([
457
+ torch.cat([u, u.new_zeros(1, seq_len - u.size(1), u.size(2))],
458
+ dim=1) for u in x
459
+ ])
460
+
461
+ # time embeddings
462
+ if t.dim() == 1:
463
+ t = t.expand(t.size(0), seq_len)
464
+ with torch.amp.autocast('cuda', dtype=torch.float32):
465
+ bt = t.size(0)
466
+ t = t.flatten()
467
+ e = self.time_embedding(
468
+ sinusoidal_embedding_1d(self.freq_dim,
469
+ t).unflatten(0, (bt, seq_len)).float())
470
+ e0 = self.time_projection(e).unflatten(2, (6, self.dim))
471
+ assert e.dtype == torch.float32 and e0.dtype == torch.float32
472
+
473
+ # context
474
+ context_lens = None
475
+ context = self.text_embedding(
476
+ torch.stack([
477
+ torch.cat(
478
+ [u, u.new_zeros(self.text_len - u.size(0), u.size(1))])
479
+ for u in context
480
+ ]))
481
+
482
+ # arguments
483
+ kwargs = dict(
484
+ e=e0,
485
+ seq_lens=seq_lens,
486
+ grid_sizes=grid_sizes,
487
+ freqs=self.freqs,
488
+ context=context,
489
+ context_lens=context_lens)
490
+
491
+ for block in self.blocks:
492
+ x = block(x, **kwargs)
493
+
494
+ # head
495
+ x = self.head(x, e)
496
+
497
+ # unpatchify
498
+ x = self.unpatchify(x, grid_sizes)
499
+ return [u.float() for u in x]
500
+
501
+ def unpatchify(self, x, grid_sizes):
502
+ r"""
503
+ Reconstruct video tensors from patch embeddings.
504
+
505
+ Args:
506
+ x (List[Tensor]):
507
+ List of patchified features, each with shape [L, C_out * prod(patch_size)]
508
+ grid_sizes (Tensor):
509
+ Original spatial-temporal grid dimensions before patching,
510
+ shape [B, 3] (3 dimensions correspond to F_patches, H_patches, W_patches)
511
+
512
+ Returns:
513
+ List[Tensor]:
514
+ Reconstructed video tensors with shape [C_out, F, H / 8, W / 8]
515
+ """
516
+
517
+ c = self.out_dim
518
+ out = []
519
+ for u, v in zip(x, grid_sizes.tolist()):
520
+ u = u[:math.prod(v)].view(*v, *self.patch_size, c)
521
+ u = torch.einsum('fhwpqrc->cfphqwr', u)
522
+ u = u.reshape(c, *[i * j for i, j in zip(v, self.patch_size)])
523
+ out.append(u)
524
+ return out
525
+
526
+ def init_weights(self):
527
+ r"""
528
+ Initialize model parameters using Xavier initialization.
529
+ """
530
+
531
+ # basic init
532
+ for m in self.modules():
533
+ if isinstance(m, nn.Linear):
534
+ nn.init.xavier_uniform_(m.weight)
535
+ if m.bias is not None:
536
+ nn.init.zeros_(m.bias)
537
+
538
+ # init embeddings
539
+ nn.init.xavier_uniform_(self.patch_embedding.weight.flatten(1))
540
+ for m in self.text_embedding.modules():
541
+ if isinstance(m, nn.Linear):
542
+ nn.init.normal_(m.weight, std=.02)
543
+ for m in self.time_embedding.modules():
544
+ if isinstance(m, nn.Linear):
545
+ nn.init.normal_(m.weight, std=.02)
546
+
547
+ # init output layer
548
+ nn.init.zeros_(self.head.head.weight)
wan/modules/t5.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modified from transformers.models.t5.modeling_t5
2
+ # Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
3
+ # Modified by the WhatMoves authors: load weights in restricted tensor-only mode.
4
+ import logging
5
+ import math
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+
11
+ from .tokenizers import HuggingfaceTokenizer
12
+
13
+ __all__ = [
14
+ 'T5Model',
15
+ 'T5Encoder',
16
+ 'T5Decoder',
17
+ 'T5EncoderModel',
18
+ ]
19
+
20
+
21
+ def fp16_clamp(x):
22
+ if x.dtype == torch.float16 and torch.isinf(x).any():
23
+ clamp = torch.finfo(x.dtype).max - 1000
24
+ x = torch.clamp(x, min=-clamp, max=clamp)
25
+ return x
26
+
27
+
28
+ def init_weights(m):
29
+ if isinstance(m, T5LayerNorm):
30
+ nn.init.ones_(m.weight)
31
+ elif isinstance(m, T5Model):
32
+ nn.init.normal_(m.token_embedding.weight, std=1.0)
33
+ elif isinstance(m, T5FeedForward):
34
+ nn.init.normal_(m.gate[0].weight, std=m.dim**-0.5)
35
+ nn.init.normal_(m.fc1.weight, std=m.dim**-0.5)
36
+ nn.init.normal_(m.fc2.weight, std=m.dim_ffn**-0.5)
37
+ elif isinstance(m, T5Attention):
38
+ nn.init.normal_(m.q.weight, std=(m.dim * m.dim_attn)**-0.5)
39
+ nn.init.normal_(m.k.weight, std=m.dim**-0.5)
40
+ nn.init.normal_(m.v.weight, std=m.dim**-0.5)
41
+ nn.init.normal_(m.o.weight, std=(m.num_heads * m.dim_attn)**-0.5)
42
+ elif isinstance(m, T5RelativeEmbedding):
43
+ nn.init.normal_(
44
+ m.embedding.weight, std=(2 * m.num_buckets * m.num_heads)**-0.5)
45
+
46
+
47
+ class GELU(nn.Module):
48
+
49
+ def forward(self, x):
50
+ return 0.5 * x * (1.0 + torch.tanh(
51
+ math.sqrt(2.0 / math.pi) * (x + 0.044715 * torch.pow(x, 3.0))))
52
+
53
+
54
+ class T5LayerNorm(nn.Module):
55
+
56
+ def __init__(self, dim, eps=1e-6):
57
+ super(T5LayerNorm, self).__init__()
58
+ self.dim = dim
59
+ self.eps = eps
60
+ self.weight = nn.Parameter(torch.ones(dim))
61
+
62
+ def forward(self, x):
63
+ x = x * torch.rsqrt(x.float().pow(2).mean(dim=-1, keepdim=True) +
64
+ self.eps)
65
+ if self.weight.dtype in [torch.float16, torch.bfloat16]:
66
+ x = x.type_as(self.weight)
67
+ return self.weight * x
68
+
69
+
70
+ class T5Attention(nn.Module):
71
+
72
+ def __init__(self, dim, dim_attn, num_heads, dropout=0.1):
73
+ assert dim_attn % num_heads == 0
74
+ super(T5Attention, self).__init__()
75
+ self.dim = dim
76
+ self.dim_attn = dim_attn
77
+ self.num_heads = num_heads
78
+ self.head_dim = dim_attn // num_heads
79
+
80
+ # layers
81
+ self.q = nn.Linear(dim, dim_attn, bias=False)
82
+ self.k = nn.Linear(dim, dim_attn, bias=False)
83
+ self.v = nn.Linear(dim, dim_attn, bias=False)
84
+ self.o = nn.Linear(dim_attn, dim, bias=False)
85
+ self.dropout = nn.Dropout(dropout)
86
+
87
+ def forward(self, x, context=None, mask=None, pos_bias=None):
88
+ """
89
+ x: [B, L1, C].
90
+ context: [B, L2, C] or None.
91
+ mask: [B, L2] or [B, L1, L2] or None.
92
+ """
93
+ # check inputs
94
+ context = x if context is None else context
95
+ b, n, c = x.size(0), self.num_heads, self.head_dim
96
+
97
+ # compute query, key, value
98
+ q = self.q(x).view(b, -1, n, c)
99
+ k = self.k(context).view(b, -1, n, c)
100
+ v = self.v(context).view(b, -1, n, c)
101
+
102
+ # attention bias
103
+ attn_bias = x.new_zeros(b, n, q.size(1), k.size(1))
104
+ if pos_bias is not None:
105
+ attn_bias += pos_bias
106
+ if mask is not None:
107
+ assert mask.ndim in [2, 3]
108
+ mask = mask.view(b, 1, 1,
109
+ -1) if mask.ndim == 2 else mask.unsqueeze(1)
110
+ attn_bias.masked_fill_(mask == 0, torch.finfo(x.dtype).min)
111
+
112
+ # compute attention (T5 does not use scaling)
113
+ attn = torch.einsum('binc,bjnc->bnij', q, k) + attn_bias
114
+ attn = F.softmax(attn.float(), dim=-1).type_as(attn)
115
+ x = torch.einsum('bnij,bjnc->binc', attn, v)
116
+
117
+ # output
118
+ x = x.reshape(b, -1, n * c)
119
+ x = self.o(x)
120
+ x = self.dropout(x)
121
+ return x
122
+
123
+
124
+ class T5FeedForward(nn.Module):
125
+
126
+ def __init__(self, dim, dim_ffn, dropout=0.1):
127
+ super(T5FeedForward, self).__init__()
128
+ self.dim = dim
129
+ self.dim_ffn = dim_ffn
130
+
131
+ # layers
132
+ self.gate = nn.Sequential(nn.Linear(dim, dim_ffn, bias=False), GELU())
133
+ self.fc1 = nn.Linear(dim, dim_ffn, bias=False)
134
+ self.fc2 = nn.Linear(dim_ffn, dim, bias=False)
135
+ self.dropout = nn.Dropout(dropout)
136
+
137
+ def forward(self, x):
138
+ x = self.fc1(x) * self.gate(x)
139
+ x = self.dropout(x)
140
+ x = self.fc2(x)
141
+ x = self.dropout(x)
142
+ return x
143
+
144
+
145
+ class T5SelfAttention(nn.Module):
146
+
147
+ def __init__(self,
148
+ dim,
149
+ dim_attn,
150
+ dim_ffn,
151
+ num_heads,
152
+ num_buckets,
153
+ shared_pos=True,
154
+ dropout=0.1):
155
+ super(T5SelfAttention, self).__init__()
156
+ self.dim = dim
157
+ self.dim_attn = dim_attn
158
+ self.dim_ffn = dim_ffn
159
+ self.num_heads = num_heads
160
+ self.num_buckets = num_buckets
161
+ self.shared_pos = shared_pos
162
+
163
+ # layers
164
+ self.norm1 = T5LayerNorm(dim)
165
+ self.attn = T5Attention(dim, dim_attn, num_heads, dropout)
166
+ self.norm2 = T5LayerNorm(dim)
167
+ self.ffn = T5FeedForward(dim, dim_ffn, dropout)
168
+ self.pos_embedding = None if shared_pos else T5RelativeEmbedding(
169
+ num_buckets, num_heads, bidirectional=True)
170
+
171
+ def forward(self, x, mask=None, pos_bias=None):
172
+ e = pos_bias if self.shared_pos else self.pos_embedding(
173
+ x.size(1), x.size(1))
174
+ x = fp16_clamp(x + self.attn(self.norm1(x), mask=mask, pos_bias=e))
175
+ x = fp16_clamp(x + self.ffn(self.norm2(x)))
176
+ return x
177
+
178
+
179
+ class T5CrossAttention(nn.Module):
180
+
181
+ def __init__(self,
182
+ dim,
183
+ dim_attn,
184
+ dim_ffn,
185
+ num_heads,
186
+ num_buckets,
187
+ shared_pos=True,
188
+ dropout=0.1):
189
+ super(T5CrossAttention, self).__init__()
190
+ self.dim = dim
191
+ self.dim_attn = dim_attn
192
+ self.dim_ffn = dim_ffn
193
+ self.num_heads = num_heads
194
+ self.num_buckets = num_buckets
195
+ self.shared_pos = shared_pos
196
+
197
+ # layers
198
+ self.norm1 = T5LayerNorm(dim)
199
+ self.self_attn = T5Attention(dim, dim_attn, num_heads, dropout)
200
+ self.norm2 = T5LayerNorm(dim)
201
+ self.cross_attn = T5Attention(dim, dim_attn, num_heads, dropout)
202
+ self.norm3 = T5LayerNorm(dim)
203
+ self.ffn = T5FeedForward(dim, dim_ffn, dropout)
204
+ self.pos_embedding = None if shared_pos else T5RelativeEmbedding(
205
+ num_buckets, num_heads, bidirectional=False)
206
+
207
+ def forward(self,
208
+ x,
209
+ mask=None,
210
+ encoder_states=None,
211
+ encoder_mask=None,
212
+ pos_bias=None):
213
+ e = pos_bias if self.shared_pos else self.pos_embedding(
214
+ x.size(1), x.size(1))
215
+ x = fp16_clamp(x + self.self_attn(self.norm1(x), mask=mask, pos_bias=e))
216
+ x = fp16_clamp(x + self.cross_attn(
217
+ self.norm2(x), context=encoder_states, mask=encoder_mask))
218
+ x = fp16_clamp(x + self.ffn(self.norm3(x)))
219
+ return x
220
+
221
+
222
+ class T5RelativeEmbedding(nn.Module):
223
+
224
+ def __init__(self, num_buckets, num_heads, bidirectional, max_dist=128):
225
+ super(T5RelativeEmbedding, self).__init__()
226
+ self.num_buckets = num_buckets
227
+ self.num_heads = num_heads
228
+ self.bidirectional = bidirectional
229
+ self.max_dist = max_dist
230
+
231
+ # layers
232
+ self.embedding = nn.Embedding(num_buckets, num_heads)
233
+
234
+ def forward(self, lq, lk):
235
+ device = self.embedding.weight.device
236
+ # rel_pos = torch.arange(lk).unsqueeze(0).to(device) - \
237
+ # torch.arange(lq).unsqueeze(1).to(device)
238
+ rel_pos = torch.arange(lk, device=device).unsqueeze(0) - \
239
+ torch.arange(lq, device=device).unsqueeze(1)
240
+ rel_pos = self._relative_position_bucket(rel_pos)
241
+ rel_pos_embeds = self.embedding(rel_pos)
242
+ rel_pos_embeds = rel_pos_embeds.permute(2, 0, 1).unsqueeze(
243
+ 0) # [1, N, Lq, Lk]
244
+ return rel_pos_embeds.contiguous()
245
+
246
+ def _relative_position_bucket(self, rel_pos):
247
+ # preprocess
248
+ if self.bidirectional:
249
+ num_buckets = self.num_buckets // 2
250
+ rel_buckets = (rel_pos > 0).long() * num_buckets
251
+ rel_pos = torch.abs(rel_pos)
252
+ else:
253
+ num_buckets = self.num_buckets
254
+ rel_buckets = 0
255
+ rel_pos = -torch.min(rel_pos, torch.zeros_like(rel_pos))
256
+
257
+ # embeddings for small and large positions
258
+ max_exact = num_buckets // 2
259
+ rel_pos_large = max_exact + (torch.log(rel_pos.float() / max_exact) /
260
+ math.log(self.max_dist / max_exact) *
261
+ (num_buckets - max_exact)).long()
262
+ rel_pos_large = torch.min(
263
+ rel_pos_large, torch.full_like(rel_pos_large, num_buckets - 1))
264
+ rel_buckets += torch.where(rel_pos < max_exact, rel_pos, rel_pos_large)
265
+ return rel_buckets
266
+
267
+
268
+ class T5Encoder(nn.Module):
269
+
270
+ def __init__(self,
271
+ vocab,
272
+ dim,
273
+ dim_attn,
274
+ dim_ffn,
275
+ num_heads,
276
+ num_layers,
277
+ num_buckets,
278
+ shared_pos=True,
279
+ dropout=0.1):
280
+ super(T5Encoder, self).__init__()
281
+ self.dim = dim
282
+ self.dim_attn = dim_attn
283
+ self.dim_ffn = dim_ffn
284
+ self.num_heads = num_heads
285
+ self.num_layers = num_layers
286
+ self.num_buckets = num_buckets
287
+ self.shared_pos = shared_pos
288
+
289
+ # layers
290
+ self.token_embedding = vocab if isinstance(vocab, nn.Embedding) \
291
+ else nn.Embedding(vocab, dim)
292
+ self.pos_embedding = T5RelativeEmbedding(
293
+ num_buckets, num_heads, bidirectional=True) if shared_pos else None
294
+ self.dropout = nn.Dropout(dropout)
295
+ self.blocks = nn.ModuleList([
296
+ T5SelfAttention(dim, dim_attn, dim_ffn, num_heads, num_buckets,
297
+ shared_pos, dropout) for _ in range(num_layers)
298
+ ])
299
+ self.norm = T5LayerNorm(dim)
300
+
301
+ # initialize weights
302
+ self.apply(init_weights)
303
+
304
+ def forward(self, ids, mask=None):
305
+ x = self.token_embedding(ids)
306
+ x = self.dropout(x)
307
+ e = self.pos_embedding(x.size(1),
308
+ x.size(1)) if self.shared_pos else None
309
+ for block in self.blocks:
310
+ x = block(x, mask, pos_bias=e)
311
+ x = self.norm(x)
312
+ x = self.dropout(x)
313
+ return x
314
+
315
+
316
+ class T5Decoder(nn.Module):
317
+
318
+ def __init__(self,
319
+ vocab,
320
+ dim,
321
+ dim_attn,
322
+ dim_ffn,
323
+ num_heads,
324
+ num_layers,
325
+ num_buckets,
326
+ shared_pos=True,
327
+ dropout=0.1):
328
+ super(T5Decoder, self).__init__()
329
+ self.dim = dim
330
+ self.dim_attn = dim_attn
331
+ self.dim_ffn = dim_ffn
332
+ self.num_heads = num_heads
333
+ self.num_layers = num_layers
334
+ self.num_buckets = num_buckets
335
+ self.shared_pos = shared_pos
336
+
337
+ # layers
338
+ self.token_embedding = vocab if isinstance(vocab, nn.Embedding) \
339
+ else nn.Embedding(vocab, dim)
340
+ self.pos_embedding = T5RelativeEmbedding(
341
+ num_buckets, num_heads, bidirectional=False) if shared_pos else None
342
+ self.dropout = nn.Dropout(dropout)
343
+ self.blocks = nn.ModuleList([
344
+ T5CrossAttention(dim, dim_attn, dim_ffn, num_heads, num_buckets,
345
+ shared_pos, dropout) for _ in range(num_layers)
346
+ ])
347
+ self.norm = T5LayerNorm(dim)
348
+
349
+ # initialize weights
350
+ self.apply(init_weights)
351
+
352
+ def forward(self, ids, mask=None, encoder_states=None, encoder_mask=None):
353
+ b, s = ids.size()
354
+
355
+ # causal mask
356
+ if mask is None:
357
+ mask = torch.tril(torch.ones(1, s, s).to(ids.device))
358
+ elif mask.ndim == 2:
359
+ mask = torch.tril(mask.unsqueeze(1).expand(-1, s, -1))
360
+
361
+ # layers
362
+ x = self.token_embedding(ids)
363
+ x = self.dropout(x)
364
+ e = self.pos_embedding(x.size(1),
365
+ x.size(1)) if self.shared_pos else None
366
+ for block in self.blocks:
367
+ x = block(x, mask, encoder_states, encoder_mask, pos_bias=e)
368
+ x = self.norm(x)
369
+ x = self.dropout(x)
370
+ return x
371
+
372
+
373
+ class T5Model(nn.Module):
374
+
375
+ def __init__(self,
376
+ vocab_size,
377
+ dim,
378
+ dim_attn,
379
+ dim_ffn,
380
+ num_heads,
381
+ encoder_layers,
382
+ decoder_layers,
383
+ num_buckets,
384
+ shared_pos=True,
385
+ dropout=0.1):
386
+ super(T5Model, self).__init__()
387
+ self.vocab_size = vocab_size
388
+ self.dim = dim
389
+ self.dim_attn = dim_attn
390
+ self.dim_ffn = dim_ffn
391
+ self.num_heads = num_heads
392
+ self.encoder_layers = encoder_layers
393
+ self.decoder_layers = decoder_layers
394
+ self.num_buckets = num_buckets
395
+
396
+ # layers
397
+ self.token_embedding = nn.Embedding(vocab_size, dim)
398
+ self.encoder = T5Encoder(self.token_embedding, dim, dim_attn, dim_ffn,
399
+ num_heads, encoder_layers, num_buckets,
400
+ shared_pos, dropout)
401
+ self.decoder = T5Decoder(self.token_embedding, dim, dim_attn, dim_ffn,
402
+ num_heads, decoder_layers, num_buckets,
403
+ shared_pos, dropout)
404
+ self.head = nn.Linear(dim, vocab_size, bias=False)
405
+
406
+ # initialize weights
407
+ self.apply(init_weights)
408
+
409
+ def forward(self, encoder_ids, encoder_mask, decoder_ids, decoder_mask):
410
+ x = self.encoder(encoder_ids, encoder_mask)
411
+ x = self.decoder(decoder_ids, decoder_mask, x, encoder_mask)
412
+ x = self.head(x)
413
+ return x
414
+
415
+
416
+ def _t5(name,
417
+ encoder_only=False,
418
+ decoder_only=False,
419
+ return_tokenizer=False,
420
+ tokenizer_kwargs={},
421
+ dtype=torch.float32,
422
+ device='cpu',
423
+ **kwargs):
424
+ # sanity check
425
+ assert not (encoder_only and decoder_only)
426
+
427
+ # params
428
+ if encoder_only:
429
+ model_cls = T5Encoder
430
+ kwargs['vocab'] = kwargs.pop('vocab_size')
431
+ kwargs['num_layers'] = kwargs.pop('encoder_layers')
432
+ _ = kwargs.pop('decoder_layers')
433
+ elif decoder_only:
434
+ model_cls = T5Decoder
435
+ kwargs['vocab'] = kwargs.pop('vocab_size')
436
+ kwargs['num_layers'] = kwargs.pop('decoder_layers')
437
+ _ = kwargs.pop('encoder_layers')
438
+ else:
439
+ model_cls = T5Model
440
+
441
+ # init model
442
+ with torch.device(device):
443
+ model = model_cls(**kwargs)
444
+
445
+ # set device
446
+ model = model.to(dtype=dtype, device=device)
447
+
448
+ # init tokenizer
449
+ if return_tokenizer:
450
+ from .tokenizers import HuggingfaceTokenizer
451
+ tokenizer = HuggingfaceTokenizer(f'google/{name}', **tokenizer_kwargs)
452
+ return model, tokenizer
453
+ else:
454
+ return model
455
+
456
+
457
+ def umt5_xxl(**kwargs):
458
+ cfg = dict(
459
+ vocab_size=256384,
460
+ dim=4096,
461
+ dim_attn=4096,
462
+ dim_ffn=10240,
463
+ num_heads=64,
464
+ encoder_layers=24,
465
+ decoder_layers=24,
466
+ num_buckets=32,
467
+ shared_pos=False,
468
+ dropout=0.1)
469
+ cfg.update(**kwargs)
470
+ return _t5('umt5-xxl', **cfg)
471
+
472
+
473
+ class T5EncoderModel:
474
+
475
+ def __init__(
476
+ self,
477
+ text_len,
478
+ dtype=torch.bfloat16,
479
+ device=torch.cuda.current_device(),
480
+ checkpoint_path=None,
481
+ tokenizer_path=None,
482
+ shard_fn=None,
483
+ ):
484
+ self.text_len = text_len
485
+ self.dtype = dtype
486
+ self.device = device
487
+ self.checkpoint_path = checkpoint_path
488
+ self.tokenizer_path = tokenizer_path
489
+
490
+ # init model
491
+ model = umt5_xxl(
492
+ encoder_only=True,
493
+ return_tokenizer=False,
494
+ dtype=dtype,
495
+ device=device).eval().requires_grad_(False)
496
+ logging.info(f'loading {checkpoint_path}')
497
+ model.load_state_dict(
498
+ torch.load(checkpoint_path, map_location='cpu', weights_only=True))
499
+ self.model = model
500
+ if shard_fn is not None:
501
+ self.model = shard_fn(self.model, sync_module_states=False)
502
+ else:
503
+ self.model.to(self.device)
504
+ # init tokenizer
505
+ self.tokenizer = HuggingfaceTokenizer(
506
+ name=tokenizer_path, seq_len=text_len, clean='whitespace')
507
+
508
+ def __call__(self, texts, device):
509
+ ids, mask = self.tokenizer(
510
+ texts, return_mask=True, add_special_tokens=True)
511
+ ids = ids.to(device)
512
+ mask = mask.to(device)
513
+ seq_lens = mask.gt(0).sum(dim=1).long()
514
+ context = self.model(ids, mask)
515
+ return [u[:v] for u, v in zip(context, seq_lens)]