Spaces:
Sleeping
Sleeping
Deploy WhatMoves app
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +6 -0
- .gitignore +11 -0
- LICENSE +21 -0
- README.md +269 -8
- THIRD_PARTY_NOTICES.md +46 -0
- app.py +20 -0
- app/README.md +139 -0
- app/__init__.py +5 -0
- app/__main__.py +36 -0
- app/assets/examples.json +64 -0
- app/assets/masks/center_penguin_f00.png +0 -0
- app/assets/masks/target_bear.png +0 -0
- app/assets/masks/target_dog.png +0 -0
- app/assets/masks/tiger_f00.png +0 -0
- app/assets/sources/penguins.mp4 +3 -0
- app/assets/sources/tiger.mp4 +3 -0
- app/assets/targets/bear_and_dog.png +3 -0
- app/config.py +52 -0
- app/examples.py +135 -0
- app/gradio_app.py +209 -0
- app/inference.py +220 -0
- app/media.py +293 -0
- app/requirements.txt +14 -0
- app/sam_service.py +115 -0
- app/scheduler.py +298 -0
- app/server.py +1275 -0
- app/state.py +314 -0
- app/static/app.js +1389 -0
- app/static/broom-flaticon.png +0 -0
- app/static/favicon.svg +12 -0
- app/static/gradio-client.js +3 -0
- app/static/index.html +190 -0
- app/static/mask-canvas.js +562 -0
- app/static/styles.css +1130 -0
- app/static/video-timeline.js +284 -0
- docs/images/scene_composition.png +3 -0
- docs/images/title.png +3 -0
- docs/images/what_moves.png +0 -0
- docs/videos/app_demo.mp4 +3 -0
- hubconf.py +311 -0
- requirements-core.txt +5 -0
- requirements-wan.txt +10 -0
- requirements.txt +2 -0
- wan/LICENSE.txt +201 -0
- wan/README.md +13 -0
- wan/__init__.py +4 -0
- wan/modules/__init__.py +4 -0
- wan/modules/attention.py +179 -0
- wan/modules/model.py +548 -0
- wan/modules/t5.py +515 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
license: mit
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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
|
docs/images/title.png
ADDED
|
Git LFS Details
|
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)]
|