"""PromptForge H3 Endless Video: the generator. It makes a long MiniMax H3 video part by part and returns it; saving is up to you. It does only the generating. What to make and how comes from small settings nodes (h3_settings.py): the Story (a prompt per part), References (characters), Video Format, Continuity, Sampling, and the optional Second Stage, Live Preview and Speed and Memory. Outputs are the frames, the sound, the prompts that were written and a short report, so they go into any save or post-processing nodes. For every part: 1. the prompt writer writes the part's prompt from the story, the references and the end of the previous part, 2. the prompt is encoded (with the references and the carried-forward frames and sound), 3. the first stage is sampled; with a Second Stage connected the latent is upscaled and sampled again, 4. the latents are decoded with the tiled H3 decoder, the join is smoothed and the part is previewed in the node. The models are released between these steps, so the language model, the diffusion model and the decoders never sit in VRAM together. """ import gc import importlib import json import os import re import threading import time import psutil import torch import torchaudio from aiohttp import web from server import PromptServer import comfy.model_management as model_management import comfy.model_prefetch import comfy.nested_tensor import comfy.ops import comfy.utils import node_helpers from comfy_extras import nodes_audio, nodes_minimax_h3 from comfy_extras.nodes_sparse_attention import BlockSparseAttention from comfy_extras.nodes_model_advanced import ModelAttentionBackend from . import empty_latent, h3_actions, h3_settings, h3_upscaler, minimax_decode, preview_override, sampler SPARSE_DENSE_BLOCKS = "0, 1" FPS = 24 SILENCE_RATE = 32000 GRID = 17 PRESETS = {"T2V": "MiniMax H3 T2VA", "I2V": "MiniMax H3 I2VA", "L2V": "MiniMax H3 L2VA", "FL2V": "MiniMax H3 FL2VA", "REF2V": "MiniMax H3 Ref2VA"} SEAM_FLOOR = 25.0 SEAM_RATIO = 6.0 TRANSITION_TEXT = { h3_settings.STORY_STYLES[0]: "Stay in the same continuous shot: same place, subjects, outfits, lighting and camera, with the action moving on. No cut in the first shot of this part.", h3_settings.STORY_STYLES[1]: "Continue naturally. If the story calls for it you may cut to a new angle, shot or place, but keep the same subjects, outfits and look.", h3_settings.STORY_STYLES[2]: "Open this part with a cut to a new shot (a new angle, framing or place that fits the story). Keep the same subjects, outfits and look.", } def _package(): return importlib.import_module(__package__) _pending = {} @PromptServer.instance.routes.post("/promptforge/review") async def _review_route(request): body = await request.json() slot = _pending.get(str(body.get("node"))) if slot is None: return web.json_response({"error": "nothing is waiting for a review"}, status=404) slot["action"], slot["text"] = str(body.get("action", "approve")), str(body.get("text", "")) slot["event"].set() return web.json_response({}) def nearest_grid(frames): """The frame count on H3's 17k + 5 grid that is closest to `frames`.""" return GRID * max(0, round((frames - 5) / GRID)) + 5 def chunk_feed_forward(model, chunks, threshold=4096): """A copy of the MiniMax H3 model whose feed-forward layers run over the packed tokens in `chunks` pieces (exact: rows are independent).""" patched = model.clone() blocks = getattr(patched.get_model_object("diffusion_model"), "blocks", None) if not blocks or not hasattr(blocks[0], "mlp") or not hasattr(blocks[0].mlp, "fc1"): return model def forward_of(mlp): def forward(x): if x.shape[0] <= threshold: return comfy.ops.linear_input_act(mlp.fc2, mlp.fc1(x), "swiglu") out = torch.empty_like(x) offset = 0 for part in torch.chunk(x, chunks, dim=0): out[offset:offset + part.shape[0]] = comfy.ops.linear_input_act(mlp.fc2, mlp.fc1(part), "swiglu") offset += part.shape[0] return out return forward for index, block in enumerate(blocks): patched.add_object_patch(f"diffusion_model.blocks.{index}.mlp.forward", forward_of(block.mlp)) return patched def stage2_sigmas(steps, start): """The second-stage schedule: `start`, then a straight line from just above it down to 0. For 3 steps from 0.9035 this is 0.9035, 0.6316, 0.3158, 0 (the schedule used by the usual MiniMax H3 two-pass workflows).""" top = start * 1.0486 return [start] + [top * (steps - index) / steps for index in range(1, steps)] + [0.0] def part_boxes(text): """The parts written in the node's part boxes (a JSON list of strings). A box may start with (10s) to give that part its own length.""" try: values = json.loads(text) if text and text.strip() else [] except ValueError: return [] plan = [] for value in values if isinstance(values, list) else []: value = str(value).strip() match = re.match(r"\(\s*(\d+(?:\.\d+)?)\s*s(?:ec(?:onds?)?)?\s*\)\s*", value, re.I) plan.append({"text": value[match.end():].strip() if match else value, "seconds": float(match.group(1)) if match else None}) return plan def part_text(index, total, brief, reference_lines, plan, seconds): """What the Enhancer is asked for one part: the brief as context, this part's idea as the thing to show, the other parts as things to keep out.""" lines = ([f"Whole-video context (do not show all of it in this part): {brief}"] if brief else []) + list(reference_lines) idea = plan[index]["text"] if index < len(plan) else "" if total == 1: lines.append(f"The video is {seconds:g} seconds long. " + (f"The idea: {idea}" if idea else "The user gave no instruction: invent something fitting.")) return "\n".join(lines) if idea: lines.append(f"PART {index + 1} OF {total} ({seconds:g} seconds). This part shows exactly the following, and nothing that belongs to another part: {idea}") lines.append("Show it completely, paced across the whole part. Do not skip ahead to, repeat or summarise the other parts.") else: lines.append(f"PART {index + 1} OF {total} ({seconds:g} seconds). The user gave no instruction for this part: continue the story from the brief naturally.") others = [f"Part {number + 1}: {part['text'][:300]}" for number, part in enumerate(plan) if number != index and part["text"]] if others: lines.append("Other parts, made separately (never show their events here):\n" + "\n".join(others)) return "\n".join(lines) def _section(text, name): match = re.search(rf"^{name}:\s*\n(.*?)(?=\n\s*\n[a-z_]+:\s*\n|\Z)", text, re.S | re.M) return match.group(1).strip() if match else "" def story_note(prompt): """(summary, last shot) of a finished H3 prompt, falling back to its beginning and end.""" summary = _section(prompt, "summary") or prompt[:500] shots = re.findall(r"\[Shot \d+\].*?(?=\[Shot \d+\]|\Z)", _section(prompt, "detailed_description") or prompt, re.S) return summary[:600], (shots[-1].strip() if shots else prompt[-500:])[:700] def continuation_brief(index, total, seconds, notes, last_shot, mode, prefix_seconds, transition): lines = [ f"# Continuation job (part {index + 1} of {total})", f"You are writing the next part of ONE long video that is generated {seconds:g} seconds at a time. Every part starts from the ending of the previous one, so this part must carry on from it, not start over.", ] if notes: lines.append("Story so far:\n" + "\n".join(f"- Part {number}: {text}" for number, text in notes)) lines.append("The previous part ended with: " + last_shot) if mode == "anchor tail": lines.append( f"The target video opens with the existing ending of the previous part ({prefix_seconds:.2f} seconds of footage that is already made). The context images show it. " f"Describe the first shot as starting from exactly what those frames show (same place, subjects, outfits, lighting and camera, mid-motion), and put the new action after {prefix_seconds:.2f} seconds. " "Never cite the context images as