One 32 GB card (RTX 5090) for the agent; fix the hf download commands; which setup for which GPU

#4
by yycc - opened
COMFYUI.md CHANGED
@@ -13,6 +13,7 @@ starting noise for a seed. Everything else is stock ComfyUI. Tested with ComfyUI
13
 
14
  ```bash
15
  cd ComfyUI
 
16
 
17
  # the base model, as Comfy-Org's single-file repack (about 124 GB)
18
  hf download Comfy-Org/MiniMax-H3 \
@@ -23,7 +24,7 @@ hf download Comfy-Org/MiniMax-H3 \
23
  --local-dir models
24
 
25
  # the LoRAs, the nodes, the workflows and the example inputs from this repository
26
- hf download Viggle/Viggle-Remake --include "comfyui/*" "examples/inputs/*" --local-dir ./Viggle-Remake
27
  cp -r Viggle-Remake/comfyui/ComfyUI-Viggle-Remake custom_nodes/
28
  cp Viggle-Remake/comfyui/viggle_remake_*_comfyui.safetensors models/loras/
29
  ```
@@ -205,7 +206,8 @@ We measured on B200s. A second process held the rest of the card, so ComfyUI saw
205
  - **Why GGUF needs the flag.** ComfyUI-GGUF puts as much of the model on the card as ComfyUI's memory estimate allows, and for MiniMax-H3 that estimate leaves out the reference and keyframe tokens.
206
  - When the weights fit but the activations do not, ComfyUI aborts with `CUDA error: invalid argument` instead of falling back to offloading.
207
  - `--reserve-vram N` keeps N GB free for the activations. If a run aborts, raise N.
208
- - **Why int8 stops at 1280×736 × 226 frames.** Without a CUDA 13 build of PyTorch, comfy-kitchen falls back to a plain int8 matmul. That matmul keeps 32-bit sums for the whole layer and assembles the output in pieces, so one layer briefly needs several times the memory of its output. At 1280×736 × 226 frames this ran out of memory even with 80 GB free, while bf16 + LoRA fit in 80 GB and Q8_0 ran in 24 GB with `--reserve-vram 20`. We have not tested a CUDA 13 build.
 
209
  - **Q4_K_M is the smallest file.** It is visibly further from the reference than int8 (see Quality).
210
 
211
  ### Quality
 
13
 
14
  ```bash
15
  cd ComfyUI
16
+ pip install -U "huggingface_hub>=1.0" # the hf CLI; older versions keep only the last --include
17
 
18
  # the base model, as Comfy-Org's single-file repack (about 124 GB)
19
  hf download Comfy-Org/MiniMax-H3 \
 
24
  --local-dir models
25
 
26
  # the LoRAs, the nodes, the workflows and the example inputs from this repository
27
+ hf download Viggle/Viggle-Remake --include "comfyui/*" --include "examples/inputs/*" --local-dir ./Viggle-Remake
28
  cp -r Viggle-Remake/comfyui/ComfyUI-Viggle-Remake custom_nodes/
29
  cp Viggle-Remake/comfyui/viggle_remake_*_comfyui.safetensors models/loras/
30
  ```
 
206
  - **Why GGUF needs the flag.** ComfyUI-GGUF puts as much of the model on the card as ComfyUI's memory estimate allows, and for MiniMax-H3 that estimate leaves out the reference and keyframe tokens.
207
  - When the weights fit but the activations do not, ComfyUI aborts with `CUDA error: invalid argument` instead of falling back to offloading.
208
  - `--reserve-vram N` keeps N GB free for the activations. If a run aborts, raise N.
209
+ - **Why int8 stops at 1280×736 × 226 frames.** Without a CUDA 13 build of PyTorch, comfy-kitchen falls back to a plain int8 matmul. That matmul keeps 32-bit sums for the whole layer and assembles the output in pieces, so one layer briefly needs several times the memory of its output. At 1280×736 × 226 frames this ran out of memory even with 80 GB free, while bf16 + LoRA fit in 80 GB and Q8_0 ran in 24 GB with `--reserve-vram 20`. We have not tested the graphs above with a CUDA 13 build, but see the next point.
210
+ - **A CUDA 13 build on a 32 GB card.** With torch 2.9.1+cu130 on an RTX 5090, int8 renders 1280×736 at up to 345 frames (about 260 s per step) through the MCP's ComfyUI worker, [`mcp/comfy_worker/`](mcp/comfy_worker), which also runs each block's feed-forward in row chunks and starts ComfyUI with `--vram-headroom 4`.
211
  - **Q4_K_M is the smallest file.** It is visibly further from the reference than int8 (see Quality).
212
 
213
  ### Quality
README.md CHANGED
@@ -53,15 +53,28 @@ swaps, removes and inserts objects, and changes the setting, the weather or the
53
 
54
  > **Just want to use it?** The [Viggle app](https://viggle.ai/meme/app/home?ui=v2) runs Remake for you. The rest of this section is for running it on your own GPUs.
55
 
 
 
 
 
 
 
 
 
 
 
56
  You need the base model (about 144 GB of its files) and this repository's LoRAs and sampler:
57
 
58
  ```bash
 
59
  hf download MiniMaxAI/MiniMax-H3 --local-dir ./MiniMax-H3 \
60
- --include "modular_model_index.json" "transformer/*" "text_encoder/*" "tokenizer/*" \
61
- "processor/*" "vae/*" "audio_vae/*" "scheduler/*" "audio_scheduler/*" "docs/*"
 
62
 
63
  hf download Viggle/Viggle-Remake --local-dir ./Viggle-Remake \
64
- --include "lora/*" "inference/*" "examples/*" "requirements.txt" # 5.5 GB; the whole repo is ~210 GB
 
65
  pip install -r Viggle-Remake/requirements.txt # plus an ffmpeg binary on PATH
66
 
67
  python Viggle-Remake/inference/sample.py --mode swap \
@@ -83,6 +96,11 @@ the LoRA did not load.
83
 
84
  Node pack, workflows, and int8/GGUF files down to 12 GB: [`COMFYUI.md`](COMFYUI.md).
85
 
 
 
 
 
 
86
  ## Agent: the MCP server
87
 
88
  [`mcp/`](mcp) lets a coding agent (Claude Code, codex) remake a whole film: it cuts the windows, paints
 
53
 
54
  > **Just want to use it?** The [Viggle app](https://viggle.ai/meme/app/home?ui=v2) runs Remake for you. The rest of this section is for running it on your own GPUs.
55
 
56
+ **Which setup for your machine:**
57
+
58
+ | your GPU | what to run |
59
+ |---|---|
60
+ | none | the [Viggle app](https://viggle.ai/meme/app/home?ui=v2) |
61
+ | one 12–24 GB card | ComfyUI with an int8 or GGUF file, workflows by hand (no agent): [Smaller GPUs](COMFYUI.md#smaller-gpus-int8-and-gguf) |
62
+ | one 32 GB card (RTX 5090) | the agent with the ComfyUI worker, 1280×736 up to 345 frames, with about 60 GB of RAM: [One 32 GB card](mcp/README.md#one-32-gb-card-the-comfyui-worker) |
63
+ | 80 GB | `sample.py` below, or ComfyUI with bf16 + LoRA: [ComfyUI](COMFYUI.md#comfyui) |
64
+ | 96 GB or more | the same, or the [agent](#agent-the-mcp-server) with `viggle-remake-worker` |
65
+
66
  You need the base model (about 144 GB of its files) and this repository's LoRAs and sampler:
67
 
68
  ```bash
69
+ pip install -U "huggingface_hub>=1.0" # the hf CLI; older versions keep only the last --include
70
  hf download MiniMaxAI/MiniMax-H3 --local-dir ./MiniMax-H3 \
71
+ --include "modular_model_index.json" --include "transformer/*" --include "text_encoder/*" \
72
+ --include "tokenizer/*" --include "processor/*" --include "vae/*" --include "audio_vae/*" \
73
+ --include "scheduler/*" --include "audio_scheduler/*" --include "docs/*"
74
 
75
  hf download Viggle/Viggle-Remake --local-dir ./Viggle-Remake \
76
+ --include "lora/*" --include "inference/*" --include "examples/*" \
77
+ --include "requirements.txt" # 5.5 GB; the whole repo is ~210 GB
78
  pip install -r Viggle-Remake/requirements.txt # plus an ffmpeg binary on PATH
79
 
80
  python Viggle-Remake/inference/sample.py --mode swap \
 
96
 
97
  Node pack, workflows, and int8/GGUF files down to 12 GB: [`COMFYUI.md`](COMFYUI.md).
98
 
99
+ To run the agent below on one 32 GB card such as an RTX 5090, use the ComfyUI render worker in
100
+ [`mcp/comfy_worker/`](mcp/comfy_worker) instead of `viggle-remake-worker`: a 1280×736 window of 345 frames renders in
101
+ about 28 minutes, and White House Funk runs end to end in about 3 hours. See
102
+ [One 32 GB card](mcp/README.md#one-32-gb-card-the-comfyui-worker).
103
+
104
  ## Agent: the MCP server
105
 
106
  [`mcp/`](mcp) lets a coding agent (Claude Code, codex) remake a whole film: it cuts the windows, paints
comfyui/ComfyUI-Viggle-Remake/nodes.py CHANGED
@@ -9,6 +9,7 @@ RemakeConditioning the source clip as <Video 1>, each image as <Picture N> and
9
  clean target audio for the whole denoise
10
  RemakeKeyframeUntil drops the keyframes once sigma falls below `keyframe_until` (0.9 = the last two of six forwards)
11
  RemakeNoise draws the starting noise in the reference script's order, so a seed gives the same video
 
12
 
13
  Load the LoRA with the stock LoraLoaderBypassModelOnly. LoraLoaderModelOnly merges it into the bf16 weights,
14
  where rounding drops part of the update and the output comes out softer.
@@ -211,8 +212,48 @@ class RemakeNoise:
211
  return (Noise(seed, skip),)
212
 
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  NODE_CLASS_MAPPINGS = {"RemakeConditioning": RemakeConditioning, "RemakeKeyframeUntil": RemakeKeyframeUntil,
215
- "RemakeNoise": RemakeNoise}
216
  NODE_DISPLAY_NAME_MAPPINGS = {"RemakeConditioning": "Viggle Remake Conditioning",
217
  "RemakeKeyframeUntil": "Viggle Remake Keyframe Until",
218
- "RemakeNoise": "Viggle Remake Noise"}
 
 
9
  clean target audio for the whole denoise
10
  RemakeKeyframeUntil drops the keyframes once sigma falls below `keyframe_until` (0.9 = the last two of six forwards)
11
  RemakeNoise draws the starting noise in the reference script's order, so a seed gives the same video
12
+ RemakePrefix a hop: the previous take's frames open this render, held clean like the soundtrack
13
 
14
  Load the LoRA with the stock LoraLoaderBypassModelOnly. LoraLoaderModelOnly merges it into the bf16 weights,
15
  where rounding drops part of the update and the output comes out softer.
 
212
  return (Noise(seed, skip),)
213
 
214
 
215
+ class RemakePrefix:
216
+ @classmethod
217
+ def INPUT_TYPES(cls):
218
+ return {"required": {
219
+ "vae": ("VAE",),
220
+ "latent": ("LATENT",),
221
+ "prefix": ("IMAGE", {"tooltip": "The previous take's frames, on this render's canvas."}),
222
+ "prefix_frames": ("INT", {"default": 17, "min": 17, "max": 323, "step": 17,
223
+ "tooltip": "How many of its frames open this render, a multiple of 17."}),
224
+ "prefix_at": ("INT", {"default": -1, "min": -1, "max": 100000,
225
+ "tooltip": "The previous take's frame they start at; -1: its last prefix_frames."}),
226
+ }}
227
+
228
+ RETURN_TYPES = ("LATENT",)
229
+ FUNCTION = "run"
230
+ CATEGORY = "Viggle Remake"
231
+
232
+ def run(self, vae, latent, prefix, prefix_frames, prefix_at):
233
+ # frames [at, at + k) of the previous take are this render's first k frames: their latents go in clean with
234
+ # noise_mask 0, so like the held soundtrack they enter at the condition timestep, are never stepped and come
235
+ # out unchanged (the reference worker's prefix rows)
236
+ k = prefix_frames
237
+ at = len(prefix) - k if prefix_at < 0 else prefix_at
238
+ px = prefix[at:at + k, ..., :3]
239
+ video, audio = latent["samples"].unbind()
240
+ vmask, amask = latent["noise_mask"].unbind()
241
+ n = k // 17 * 5
242
+ assert k % 17 == 0 and n < video.shape[2], f"prefix_frames must be 17m and shorter than the render, got {k}"
243
+ assert tuple(px.shape[:3]) == (k, video.shape[3] * 16, video.shape[4] * 16), \
244
+ f"the prefix must be {k} frames on the {video.shape[4] * 16}x{video.shape[3] * 16} canvas, got {tuple(px.shape)}"
245
+ # the VAE encodes 17-frame clips and drops the last 3 latents: pad to 17m + 5, keep the first 5m
246
+ clean = vae.encode(torch.cat([px, px[-1:].repeat(5, 1, 1, 1)]))[:, :, :n]
247
+ video, vmask = video.clone(), vmask.clone()
248
+ video[:, :, :n] = clean.to(video)
249
+ vmask[:, :, :n] = 0
250
+ return ({**latent, "samples": comfy.nested_tensor.NestedTensor((video, audio)),
251
+ "noise_mask": comfy.nested_tensor.NestedTensor((vmask, amask))},)
252
+
253
+
254
  NODE_CLASS_MAPPINGS = {"RemakeConditioning": RemakeConditioning, "RemakeKeyframeUntil": RemakeKeyframeUntil,
255
+ "RemakeNoise": RemakeNoise, "RemakePrefix": RemakePrefix}
256
  NODE_DISPLAY_NAME_MAPPINGS = {"RemakeConditioning": "Viggle Remake Conditioning",
257
  "RemakeKeyframeUntil": "Viggle Remake Keyframe Until",
258
+ "RemakeNoise": "Viggle Remake Noise",
259
+ "RemakePrefix": "Viggle Remake Prefix"}
mcp/README.md CHANGED
@@ -12,6 +12,15 @@ painted, writes the six-section prompt per render window, queues the renders, re
12
  final video. The server gives it 27 tools for that (28 in pool image mode; see [Tools](#tools)) and enforces the
13
  budgets. The rules the agent follows are in [`AGENTS.md`](viggle_remake_mcp/data/AGENTS.md).
14
 
 
 
 
 
 
 
 
 
 
15
  ## How it fits together
16
 
17
  ```
@@ -57,7 +66,8 @@ budgets. The rules the agent follows are in [`AGENTS.md`](viggle_remake_mcp/data
57
 
58
  ## Install
59
 
60
- Requires Python ≥ 3.10, and `ffmpeg` and `ffprobe` on `PATH`.
 
61
 
62
  ```bash
63
  hf download Viggle/Viggle-Remake --include "mcp/*" --local-dir viggle-remake # under 1 MB plus the example's 210 MB
@@ -173,8 +183,39 @@ One worker per GPU, bf16. A worker measures its card's free memory at start and
173
  A longer one-take becomes a chain of windows, each continuing the previous one's take, so a smaller card renders the same
174
  clip with more seams. Each worker also keeps the text encoder in host RAM: about 58 GiB of RAM per worker.
175
 
176
- On a smaller GPU you would offload or quantize the model yourself; the worker does not. Peak memory per length and the
177
- measurements behind the limits are in [`WORKER_NOTES.md`](WORKER_NOTES.md#memory).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
  ### Writing your own worker
180
 
 
12
  final video. The server gives it 27 tools for that (28 in pool image mode; see [Tools](#tools)) and enforces the
13
  budgets. The rules the agent follows are in [`AGENTS.md`](viggle_remake_mcp/data/AGENTS.md).
14
 
15
+ **What you need:**
16
+ - **an agent**: Claude Code, codex, or any MCP client;
17
+ - **a painter** for the keyframes: either a codex login with a ChatGPT subscription (`REMAKE_PAINT=pool`, no API
18
+ spend), or a key for an OpenAI-compatible images API (the default; see [Cost](#cost));
19
+ - **render workers**: `viggle-remake-worker` on a card with 96 GB or more, or the
20
+ [ComfyUI worker](#one-32-gb-card-the-comfyui-worker) on one 32 GB card such as an RTX 5090 (Linux, about 60 GB of
21
+ RAM). The server itself needs no GPU. Cards below 32 GB are not tested with the agent; use ComfyUI by hand
22
+ ([`COMFYUI.md`](../COMFYUI.md#smaller-gpus-int8-and-gguf)).
23
+
24
  ## How it fits together
25
 
26
  ```
 
66
 
67
  ## Install
68
 
69
+ Requires Python ≥ 3.10, and `ffmpeg` 5.1 or newer and `ffprobe` on `PATH` (Ubuntu 22.04 ships 4.4: use a static
70
+ build).
71
 
72
  ```bash
73
  hf download Viggle/Viggle-Remake --include "mcp/*" --local-dir viggle-remake # under 1 MB plus the example's 210 MB
 
183
  A longer one-take becomes a chain of windows, each continuing the previous one's take, so a smaller card renders the same
184
  clip with more seams. Each worker also keeps the text encoder in host RAM: about 58 GiB of RAM per worker.
185
 
186
+ This worker does not offload or quantize; on a smaller GPU use the ComfyUI worker below. Peak memory per length and
187
+ the measurements behind the limits are in [`WORKER_NOTES.md`](WORKER_NOTES.md#memory).
188
+
189
+ #### One 32 GB card: the ComfyUI worker
190
+
191
+ [`comfy_worker/`](comfy_worker) is a second worker for the same queue, for one consumer card. It imports ComfyUI 0.42.0
192
+ as a library and runs the int8 per-mode transformers from [`int8/`](../int8) with ComfyUI's dynamic VRAM, which
193
+ memory-maps the checkpoints and streams in, layer by layer, whatever does not fit on the card. The server and the agent
194
+ are unchanged.
195
+
196
+ ```bash
197
+ cd viggle-remake/mcp/comfy_worker
198
+ ./setup.sh # once: ComfyUI 0.42.0, torch 2.9.1 + CUDA 13.0, the node pack, the int8 weights, the text encoder
199
+ bash run_worker.sh # in place of viggle-remake-worker, in the foreground; --gpu N picks the card
200
+ ```
201
+
202
+ It needs Linux, an NVIDIA driver for CUDA 13, `uv`, `ffmpeg` 5.1 or newer, `hf`, about 70 GB of disk and 60 GB of RAM.
203
+ Measured on one RTX 5090 (32 GB) with 60 GB of RAM, an int8 swap job, six steps:
204
+
205
+ | canvas | frames | render |
206
+ |---|---|---|
207
+ | 832×480 | 226 | 3 min |
208
+ | 832×480 | 345 | 6 min |
209
+ | 1280×736 | 175 | 8 min |
210
+ | 1280×736 | 226 | 13 min |
211
+ | 1280×736 | 345 | 28 min; with four keyframes, VRAM at most 27.8 GB |
212
+
213
+ A step is almost all attention, so a 345-frame window costs four times a 175-frame one; `--max-736 175` trades more
214
+ seams for speed. White House Funk ran end to end on one 5090 in 2 h 58 min. How it differs from the bf16 worker:
215
+ - the int8 transformer of the job's mode, one at a time; a job of the other mode swaps it;
216
+ - ComfyUI keeps 4 GB of VRAM free (`--vram-headroom 4`);
217
+ - after an OOM or a CUDA error the worker writes the `.err` and exits, and `run_worker.sh` restarts it;
218
+ - hops go through the node pack's `RemakePrefix` node.
219
 
220
  ### Writing your own worker
221
 
mcp/comfy_worker/remake_worker.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Viggle Remake render worker on ComfyUI as a library: one process, no ComfyUI server.
3
+
4
+ ComfyUI/venv/bin/python remake_worker.py [--gpu 0] [--home ~/.viggle-remake] [--max-480 345] [--max-736 345] \
5
+ [-- <ComfyUI flags, e.g. --vram-headroom 6>]
6
+
7
+ Renders the viggle-remake MCP's file queue with the graph of comfyui/workflows/remake_*_api.json: the mode's int8
8
+ transformer -> RemakeKeyframeUntil -> BasicGuider, euler on the mode's six-step sigmas, RemakeConditioning +
9
+ RemakeNoise, and RemakePrefix for a hop. The queue protocol is the official worker's (viggle_remake_mcp/worker.py).
10
+ """
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ if "--gpu" in sys.argv[:-1]: # before torch
16
+ os.environ["CUDA_VISIBLE_DEVICES"] = sys.argv[sys.argv.index("--gpu") + 1]
17
+ COMFY = Path(__file__).resolve().parent / "ComfyUI"
18
+ split = sys.argv.index("--") if "--" in sys.argv else len(sys.argv)
19
+ # --vram-headroom: dynamic VRAM keeps 4 GB free, else a 311-frame 736 window OOMed on a 2.8 GB attention output
20
+ # before the weights make room (a later flag after -- wins)
21
+ ours, sys.argv = sys.argv[1:split], [str(COMFY / "main.py"), "--vram-headroom", "4", *sys.argv[split + 1:]]
22
+ sys.path.insert(0, str(COMFY))
23
+ sys.path.insert(0, str(COMFY / "custom_nodes"))
24
+
25
+ import comfy.options # noqa: E402
26
+
27
+ comfy.options.enable_args_parsing()
28
+ import cuda_malloc # noqa: E402,F401 as ComfyUI's own main does before torch
29
+ import main # noqa: E402,F401 ComfyUI's setup (logging, dynamic VRAM, model paths); its server starts only as __main__
30
+
31
+ import argparse # noqa: E402
32
+ import gc # noqa: E402
33
+ import importlib # noqa: E402
34
+ import json # noqa: E402
35
+ import resource # noqa: E402
36
+ import socket # noqa: E402
37
+ import subprocess # noqa: E402
38
+ import threading # noqa: E402
39
+ import time # noqa: E402
40
+ import traceback # noqa: E402
41
+ from fractions import Fraction # noqa: E402
42
+
43
+ import numpy as np # noqa: E402
44
+ import torch # noqa: E402
45
+ from PIL import Image # noqa: E402
46
+
47
+ import comfy.ldm.minimax.model as H3 # noqa: E402
48
+ import comfy.model_management as mm # noqa: E402
49
+ import comfy.samplers # noqa: E402
50
+ import nodes # noqa: E402
51
+ from comfy_api.latest import InputImpl, Types # noqa: E402
52
+ from comfy_extras.nodes_audio import vae_decode_audio # noqa: E402
53
+ from comfy_extras.nodes_custom_sampler import Guider_Basic # noqa: E402
54
+
55
+ R = importlib.import_module("ComfyUI-Viggle-Remake.nodes")
56
+
57
+ # Each block's MLP over the packed sequence FF_ROWS rows at a time, as the reference worker does: its S x 28672 bf16
58
+ # fc1 output alone is 11 GiB at 345 frames on the 736 canvas, which a 32 GB card does not have. int8 quantizes the
59
+ # activations per row, so the output is the same.
60
+ FF_ROWS = 16384
61
+ _mlp = H3.MLP.forward
62
+
63
+
64
+ def mlp_rows(self, x):
65
+ if x.shape[-2] <= FF_ROWS:
66
+ return _mlp(self, x)
67
+ out = torch.empty_like(x)
68
+ for a in range(0, x.shape[-2], FF_ROWS):
69
+ out[..., a:a + FF_ROWS, :] = _mlp(self, x[..., a:a + FF_ROWS, :])
70
+ return out
71
+
72
+
73
+ H3.MLP.forward = mlp_rows
74
+
75
+ MODES = ("swap", "edit")
76
+ FRAMES = range(90, 346, 17) # 17n + 5, 24 fps
77
+ AREA = {480: 832 * 480, 736: 1280 * 736}
78
+ SIGMAS = {"swap": [1.0, 0.990476, 0.965243, 0.923077, 0.8, 0.631579, 0.0],
79
+ "edit": [1.0, 0.983607, 0.96, 0.923077, 0.857143, 0.705882, 0.0]}
80
+ UNET = {m: f"viggle_remake_{m}_pruned_int8_convrot.safetensors" for m in MODES}
81
+ TE = "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
82
+ VAE, AUDIO_VAE = "minimax_h3_video_vae_fp16.safetensors", "minimax_h3_audio_vae_fp32.safetensors"
83
+
84
+
85
+ def main_loop():
86
+ parser = argparse.ArgumentParser()
87
+ parser.add_argument("--gpu", default=None, help="card index; default: CUDA_VISIBLE_DEVICES as set")
88
+ parser.add_argument("--home", default=os.environ.get("REMAKE_HOME", "~/.viggle-remake"))
89
+ parser.add_argument("--max-480", type=int, default=345, help="longest render at 832x480 (17n+5, 0 = none)")
90
+ parser.add_argument("--max-736", type=int, default=345, help="longest render at 1280x736 (17n+5, 0 = none)")
91
+ args = parser.parse_args(ours)
92
+ gpu = os.environ.get("CUDA_VISIBLE_DEVICES", "0")
93
+ host = socket.gethostname()
94
+ home = Path(args.home).expanduser()
95
+ queue, workers = home / "queue", home / "workers"
96
+ for p in (queue, workers):
97
+ p.mkdir(parents=True, exist_ok=True)
98
+ limit = {480: args.max_480, 736: args.max_736}
99
+ state = {"job": None}
100
+ beat_path, lock = workers / f"{host}_{gpu}.json", threading.Lock()
101
+
102
+ def beat():
103
+ with lock:
104
+ tmp = beat_path.with_suffix(f".{os.getpid()}.tmp")
105
+ tmp.write_text(json.dumps({"t": time.time(), "host": host, "gpu": gpu, "pid": os.getpid(),
106
+ "job": state["job"], "modes": list(MODES),
107
+ "max_frames": {str(k): v for k, v in limit.items()}}))
108
+ tmp.rename(beat_path)
109
+
110
+ def beat_loop():
111
+ while True:
112
+ try:
113
+ beat()
114
+ except OSError: # a transient fs error must not stop the heartbeat
115
+ pass
116
+ time.sleep(10)
117
+
118
+ t0 = time.time()
119
+ clip = nodes.CLIPLoader().load_clip(TE, type="minimax")[0]
120
+ vae = nodes.VAELoader().load_vae(VAE)[0]
121
+ audio_vae = nodes.VAELoader().load_vae(AUDIO_VAE)[0]
122
+ loaded = {}
123
+
124
+ def model(m):
125
+ # one transformer at a time (21 GB each): a mode switch drops the other one first
126
+ if m not in loaded:
127
+ loaded.clear()
128
+ mm.unload_all_models()
129
+ gc.collect()
130
+ mm.soft_empty_cache()
131
+ loaded[m] = nodes.UNETLoader().load_unet(UNET[m], "default")[0]
132
+ return loaded[m]
133
+
134
+ model("swap")
135
+ print(f"loaded in {time.time() - t0:.0f}s; max_frames {limit}", flush=True)
136
+ threading.Thread(target=beat_loop, daemon=True).start()
137
+
138
+ @torch.inference_mode() # as ComfyUI's executor runs every node; without it autograd keeps the VAE's activations
139
+ def render(job):
140
+ m, nf, tier, refs, seed = job["mode"], job["num_frames"], job["tier"], job["refs"], job["seed"]
141
+ assert m in MODES, f"mode must be one of {MODES}, got {m!r}"
142
+ assert nf in FRAMES, f"num_frames must be 17n+5 in 90..345, got {nf}"
143
+ assert tier in limit, f"tier must be one of {list(limit)}, got {tier}"
144
+ if nf > limit[tier]:
145
+ raise ValueError(f"num_frames {nf} exceeds this worker's max {limit[tier]} at tier {tier}")
146
+ assert len(refs) <= 4, f"the node pack takes at most 4 refs, got {len(refs)}"
147
+ pin = job.get("pin") or []
148
+ assert len(pin) in (0, len(refs)) and all(0 <= k < nf for k in pin), (pin, len(refs), nf)
149
+
150
+ source = InputImpl.VideoFromFile(job["cond"]).get_components().images
151
+ h, w = source.shape[1], source.shape[2]
152
+ assert h % 32 == 0 and w % 32 == 0 and h * w <= AREA[tier], \
153
+ f"cond must be multiples of 32 with at most {AREA[tier]} pixels at tier {tier}, got {w}x{h}"
154
+ # the soundtrack, as RemakeConditioning takes it (it pads and cuts to the render)
155
+ pcm = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", job["audio"], "-t", f"{nf / 24:.3f}", "-vn",
156
+ "-f", "f32le", "-ac", "2", "-ar", str(R.AUDIO_SR), "-"],
157
+ capture_output=True, check=True).stdout
158
+ audio = {"waveform": torch.frombuffer(bytearray(pcm), dtype=torch.float32).view(-1, 2).T[None],
159
+ "sample_rate": R.AUDIO_SR}
160
+ ims = [torch.from_numpy(np.array(Image.open(x).convert("RGB"))).float()[None] / 255 for x in refs]
161
+ ims += [None] * (4 - len(ims))
162
+ # short_edge = the cond's own: it is already on its canvas, so nothing is resized
163
+ positive, latent = R.RemakeConditioning().run(clip, vae, audio_vae, source, job["prompt"], ims[0],
164
+ " ".join(map(str, pin)), nf, min(h, w), audio, *ims[1:])
165
+ if job.get("prefix"):
166
+ prev = InputImpl.VideoFromFile(job["prefix"]).get_components().images
167
+ latent = R.RemakePrefix().run(vae, latent, prev, job.get("prefix_frames", 17), job.get("prefix_at", -1))[0]
168
+
169
+ guider = Guider_Basic(R.RemakeKeyframeUntil().run(model(m), job.get("keyframe_until", 0.9))[0])
170
+ guider.set_conds(positive)
171
+ noise = R.RemakeNoise().run(positive, seed)[0]
172
+ samples = guider.sample(noise.generate_noise(latent), latent["samples"], comfy.samplers.sampler_object("euler"),
173
+ torch.tensor(SIGMAS[m]), denoise_mask=latent["noise_mask"], seed=seed)
174
+ out = {"samples": samples.to(mm.intermediate_device())}
175
+ video = InputImpl.VideoFromComponents(Types.VideoComponents(
176
+ images=nodes.VAEDecode().decode(vae, out)[0], audio=vae_decode_audio(audio_vae, out), frame_rate=Fraction(24)))
177
+ dst = Path(job["out"])
178
+ dst.parent.mkdir(parents=True, exist_ok=True)
179
+ tmp = dst.with_name(f".{dst.stem}.{os.getpid()}.mp4")
180
+ video.save_to(str(tmp), format=Types.VideoContainer.MP4, codec=Types.VideoCodec.H264)
181
+ tmp.rename(dst)
182
+
183
+ def live_beats():
184
+ beats = []
185
+ for p in workers.glob("*.json"):
186
+ try:
187
+ hb = json.loads(p.read_text())
188
+ except (OSError, ValueError):
189
+ continue
190
+ if time.time() - hb["t"] < 120:
191
+ beats.append(hb)
192
+ return beats
193
+
194
+ def requeue_orphans(beats):
195
+ live = {hb["job"] for hb in beats}
196
+ for run in queue.glob("*.run"):
197
+ try:
198
+ if time.time() - run.stat().st_mtime > 120 and run.stem not in live:
199
+ run.rename(run.with_suffix(".json"))
200
+ print(f"{run.stem}: orphaned, requeued", flush=True)
201
+ except FileNotFoundError:
202
+ pass
203
+
204
+ def next_job(beats):
205
+ others = [hb for hb in beats if (hb["host"], hb["gpu"]) != (host, gpu)]
206
+ for p in sorted(queue.glob("*.json")):
207
+ try:
208
+ job = json.loads(p.read_text())
209
+ except (OSError, ValueError): # claimed meanwhile, or still being written
210
+ continue
211
+ if job.get("prefix") and not Path(job["prefix"]).exists():
212
+ continue
213
+ tier, nf = job.get("tier"), job.get("num_frames", 0)
214
+ if nf > limit.get(tier, 0) and any(
215
+ job.get("mode") in hb["modes"] and hb["max_frames"].get(str(tier), 0) >= nf for hb in others):
216
+ continue # too long for this card, not for another live worker: leave it to that one
217
+ run = p.with_suffix(".run")
218
+ try: # touch first: rename keeps the mtime, and a .run older than 120 s that no heartbeat names is requeued
219
+ os.utime(p)
220
+ p.rename(run)
221
+ except FileNotFoundError:
222
+ continue
223
+ return run
224
+ return None
225
+
226
+ while True:
227
+ beats = live_beats()
228
+ requeue_orphans(beats)
229
+ run = next_job(beats)
230
+ if run is None:
231
+ time.sleep(2)
232
+ continue
233
+ state["job"] = run.stem
234
+ beat()
235
+ try:
236
+ job = json.loads(run.read_text())
237
+ t = time.time()
238
+ render(job)
239
+ info = {"out": job["out"], "secs": round(time.time() - t, 1),
240
+ "peak_rss_gib": round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 2**20, 1),
241
+ "host": host, "gpu": gpu, "mode": job["mode"], "sigmas": SIGMAS[job["mode"]]}
242
+ tmp = run.with_suffix(f".{os.getpid()}.tmp")
243
+ tmp.write_text(json.dumps(info))
244
+ tmp.rename(run.with_suffix(".done"))
245
+ print(f"{run.stem}: {info}", flush=True)
246
+ except Exception:
247
+ tb = traceback.format_exc()
248
+ run.with_suffix(".err").write_text(tb)
249
+ run.unlink(missing_ok=True)
250
+ print(f"{run.stem}: FAILED\n{tb}", flush=True)
251
+ # the context is gone after a CUDA error, and after an OOM freeing the exception's tensors aborts the
252
+ # process: exit before that happens (run_worker.sh restarts the worker)
253
+ if "CUDA error" in tb or "OutOfMemoryError" in tb:
254
+ os._exit(1)
255
+ run.unlink(missing_ok=True)
256
+ state["job"] = None
257
+ gc.collect()
258
+ mm.soft_empty_cache()
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main_loop()
mcp/comfy_worker/run_worker.sh ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Viggle Remake worker in the foreground; restarted whenever it exits (it exits on purpose after a CUDA error or an OOM).
3
+ # bash run_worker.sh [worker flags] [-- ComfyUI flags] Ctrl-C stops it.
4
+ cd "$(dirname "$0")"
5
+ trap exit INT
6
+ ulimit -c 0 # a crash would otherwise dump a core of the whole address space (minutes, GBs)
7
+ while true; do
8
+ ComfyUI/venv/bin/python remake_worker.py "$@"
9
+ echo "worker exited ($?), restarting in 5 s"
10
+ sleep 5
11
+ done
mcp/comfy_worker/setup.sh ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # One-time install of the ComfyUI render worker, next to this script: ComfyUI 0.42.0 with torch 2.9.1 / CUDA 13.0, the
3
+ # repository's node pack linked in, the int8 transformers and the text encoder and VAEs. SKIP_MODELS=1 skips the weights.
4
+ set -e
5
+ cd "$(dirname "$0")"
6
+ REPO=$(realpath ../..)
7
+ export UV_HTTP_TIMEOUT=600
8
+ [ -d ComfyUI ] || git clone -q --depth 1 --branch v0.42.0 https://github.com/comfyanonymous/ComfyUI.git
9
+ [ -x ComfyUI/venv/bin/python ] || uv venv -q --python 3.12 ComfyUI/venv
10
+ uv pip install -q --python ComfyUI/venv/bin/python torch==2.9.1 torchvision==0.24.1 torchaudio==2.9.1 \
11
+ --index-url https://download.pytorch.org/whl/cu130
12
+ uv pip install -q --python ComfyUI/venv/bin/python -r ComfyUI/requirements.txt
13
+ M=(--include "comfyui/ComfyUI-Viggle-Remake/*")
14
+ [ -n "$SKIP_MODELS" ] || M+=(--include "int8/*")
15
+ hf download Viggle/Viggle-Remake "${M[@]}" --local-dir "$REPO"
16
+ ln -sfn "$REPO/comfyui/ComfyUI-Viggle-Remake" ComfyUI/custom_nodes/ComfyUI-Viggle-Remake
17
+ if [ -z "$SKIP_MODELS" ]; then
18
+ hf download Comfy-Org/MiniMax-H3 text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors \
19
+ vae/minimax_h3_video_vae_fp16.safetensors vae/minimax_h3_audio_vae_fp32.safetensors --local-dir ComfyUI/models
20
+ ln -sf "$REPO"/int8/*.safetensors ComfyUI/models/diffusion_models/
21
+ fi
22
+ echo "done. Start the worker with: bash $PWD/run_worker.sh"
mcp/viggle_remake_mcp/common.py CHANGED
@@ -60,14 +60,14 @@ def frames(video, w, h):
60
  def pick(video, fs, W, H):
61
  """Only frames `fs` of a video, decoded at W x H (a full decode of a 2-minute cond is 3.4 GB)."""
62
  sel = "+".join(f"eq(n\\,{f})" for f in sorted(set(fs)))
63
- raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(video), "-vf", f"select={sel},scale={W}:{H}", "-vsync", "0",
64
  "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
65
  return dict(zip(sorted(set(fs)), np.frombuffer(raw, np.uint8).reshape(-1, H, W, 3)))
66
 
67
 
68
  def seg(video, a, b, W, H):
69
  raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(video), "-vf", f"select=between(n\\,{a}\\,{b - 1}),scale={W}:{H}",
70
- "-vsync", "0", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
71
  return np.frombuffer(raw, np.uint8).reshape(-1, H, W, 3)
72
 
73
 
 
60
  def pick(video, fs, W, H):
61
  """Only frames `fs` of a video, decoded at W x H (a full decode of a 2-minute cond is 3.4 GB)."""
62
  sel = "+".join(f"eq(n\\,{f})" for f in sorted(set(fs)))
63
+ raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(video), "-vf", f"select={sel},scale={W}:{H}", "-fps_mode", "passthrough",
64
  "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
65
  return dict(zip(sorted(set(fs)), np.frombuffer(raw, np.uint8).reshape(-1, H, W, 3)))
66
 
67
 
68
  def seg(video, a, b, W, H):
69
  raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(video), "-vf", f"select=between(n\\,{a}\\,{b - 1}),scale={W}:{H}",
70
+ "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout
71
  return np.frombuffer(raw, np.uint8).reshape(-1, H, W, 3)
72
 
73
 
mcp/viggle_remake_mcp/kit.py CHANGED
@@ -604,7 +604,7 @@ def unbar(d, v, canvas):
604
  if not m:
605
  continue
606
  raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(cond(d, canvas)), "-vf", f"scale={W}:{H},crop={crop(m)},"
607
- "scale=48:48,format=gray", "-vsync", "0", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
608
  capture_output=True, check=True).stdout
609
  dark = np.percentile(np.frombuffer(raw, np.uint8).reshape(-1, 48 * 48)[:n], 98, axis=1) < 14
610
  for i in np.flatnonzero(dark):
 
604
  if not m:
605
  continue
606
  raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(cond(d, canvas)), "-vf", f"scale={W}:{H},crop={crop(m)},"
607
+ "scale=48:48,format=gray", "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
608
  capture_output=True, check=True).stdout
609
  dark = np.percentile(np.frombuffer(raw, np.uint8).reshape(-1, 48 * 48)[:n], 98, axis=1) < 14
610
  for i in np.flatnonzero(dark):