Upload folder using huggingface_hub (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +9 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_ops.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_support.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/master_audio_context.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/motion_context_upstream.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/nodes.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_layout.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_payload.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/probe_node.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_checkpoint_delete.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_persistence.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/project_assets.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_history.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_optimizer.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_migration.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_recovery.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_store.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_usage.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_video_fade.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/run_manager.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/source_av_target.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/tokenizer_compat.cpython-312.pyc +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc +3 -0
- ComfyUI-MiniMaxH3-Context-Loop/asset_store.py +397 -0
- ComfyUI-MiniMaxH3-Context-Loop/assets/minimax-h3-context-loop.svg +98 -0
- ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png +3 -0
- ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.svg +85 -0
- ComfyUI-MiniMaxH3-Context-Loop/assets/workflow-overview.svg +188 -0
- ComfyUI-MiniMaxH3-Context-Loop/audio_track_contract.py +26 -0
- ComfyUI-MiniMaxH3-Context-Loop/av_timing.py +96 -0
- ComfyUI-MiniMaxH3-Context-Loop/chain_nodes.py +0 -0
- ComfyUI-MiniMaxH3-Context-Loop/chapter_resolution.py +97 -0
- ComfyUI-MiniMaxH3-Context-Loop/chapter_snapshot_retirement.py +107 -0
- ComfyUI-MiniMaxH3-Context-Loop/checkpoint_manager.py +1541 -0
- ComfyUI-MiniMaxH3-Context-Loop/checkpoint_variants.py +278 -0
- ComfyUI-MiniMaxH3-Context-Loop/contracts_v05.py +569 -0
- ComfyUI-MiniMaxH3-Context-Loop/deferred_checkpoint_source.py +152 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/ADVANCED_WORKFLOWS.md +246 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/AUDIO_AND_CONTINUITY.md +522 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/COMPATIBILITY.md +132 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/FEATURE_TRACEABILITY.md +81 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/GETTING_STARTED.md +186 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/MASKED_EDITING.md +261 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/MIGRATING_TO_0_5.md +103 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/NODE_REFERENCE.md +152 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/PROJECT_ASSETS.md +137 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/README.md +60 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/REFERENCE_VIDEO_FADE.md +114 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/RUNS_AND_RECOVERY.md +1077 -0
- ComfyUI-MiniMaxH3-Context-Loop/docs/SCENE_AUTHORING.md +195 -0
.gitattributes
CHANGED
|
@@ -1289,3 +1289,12 @@ ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/assets/uniform_vs_adaptive_sbs.m
|
|
| 1289 |
ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/docs/latents/frame0.png filter=lfs diff=lfs merge=lfs -text
|
| 1290 |
ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/docs/latents/frame72.png filter=lfs diff=lfs merge=lfs -text
|
| 1291 |
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/chain_nodes.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1289 |
ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/docs/latents/frame0.png filter=lfs diff=lfs merge=lfs -text
|
| 1290 |
ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/docs/latents/frame72.png filter=lfs diff=lfs merge=lfs -text
|
| 1291 |
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/chain_nodes.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
|
| 1292 |
+
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
|
| 1293 |
+
ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png filter=lfs diff=lfs merge=lfs -text
|
| 1294 |
+
ComfyUI-MiniMaxH3-Context-Loop/docs/assets/minimax-h3-context-loop-0.5-to-0.6-major-improvements.png filter=lfs diff=lfs merge=lfs -text
|
| 1295 |
+
ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/h3_v06_courier_greenhouse_arrival.png filter=lfs diff=lfs merge=lfs -text
|
| 1296 |
+
ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/h3_v06_courier_greenhouse_delivery.png filter=lfs diff=lfs merge=lfs -text
|
| 1297 |
+
ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/jigen_market_garden_doom_last.png filter=lfs diff=lfs merge=lfs -text
|
| 1298 |
+
ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/jigen_market_garden_doom_opening.png filter=lfs diff=lfs merge=lfs -text
|
| 1299 |
+
ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/soldier_crabs_bribie_island_cc0.webm filter=lfs diff=lfs merge=lfs -text
|
| 1300 |
+
ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/soldier_crabs_reference_cc0.png filter=lfs diff=lfs merge=lfs -text
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_ops.cpython-312.pyc
ADDED
|
Binary file (16.9 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_support.cpython-312.pyc
ADDED
|
Binary file (1.49 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/master_audio_context.cpython-312.pyc
ADDED
|
Binary file (25.1 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/motion_context_upstream.cpython-312.pyc
ADDED
|
Binary file (17.3 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/nodes.cpython-312.pyc
ADDED
|
Binary file (54.4 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_layout.cpython-312.pyc
ADDED
|
Binary file (45.9 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_payload.cpython-312.pyc
ADDED
|
Binary file (19.6 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/probe_node.cpython-312.pyc
ADDED
|
Binary file (22.9 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_checkpoint_delete.cpython-312.pyc
ADDED
|
Binary file (24.1 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_persistence.cpython-312.pyc
ADDED
|
Binary file (2.88 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/project_assets.cpython-312.pyc
ADDED
|
Binary file (97.4 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_history.cpython-312.pyc
ADDED
|
Binary file (23 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_optimizer.cpython-312.pyc
ADDED
|
Binary file (26.3 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_migration.cpython-312.pyc
ADDED
|
Binary file (22.5 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_recovery.cpython-312.pyc
ADDED
|
Binary file (26.7 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_store.cpython-312.pyc
ADDED
|
Binary file (9.63 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_usage.cpython-312.pyc
ADDED
|
Binary file (6.58 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_video_fade.cpython-312.pyc
ADDED
|
Binary file (19.8 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/run_manager.cpython-312.pyc
ADDED
|
Binary file (24.2 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/source_av_target.cpython-312.pyc
ADDED
|
Binary file (18 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/tokenizer_compat.cpython-312.pyc
ADDED
|
Binary file (5.35 kB). View file
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:97a887900e16849e99ee7f73dff27148020bcfd63e4c26c37464afe39df2b0ee
|
| 3 |
+
size 167903
|
ComfyUI-MiniMaxH3-Context-Loop/asset_store.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Portable, versioned media bindings for saved H3 chain runs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import shutil
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# The canvas keeps a small number of direct loader sockets. Semantic Anchor
|
| 16 |
+
# Bundle contributes many loader bindings through one dedicated Run Manager
|
| 17 |
+
# socket, so the persisted manifest accepts a larger bounded collection.
|
| 18 |
+
MAX_DIRECT_ASSET_BINDINGS = 12
|
| 19 |
+
MAX_ASSET_BINDINGS = 128
|
| 20 |
+
ASSET_MANIFEST_FORMAT = "h3_chain_assets_v1"
|
| 21 |
+
ASSET_ROLES = ("picture", "video", "audio_reference", "source_track")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _safe_name(value: Any, fallback: str = "asset") -> str:
|
| 25 |
+
text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(value or "").strip())
|
| 26 |
+
text = text.strip("._-")
|
| 27 |
+
return (text or fallback)[:128]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _utc_now() -> str:
|
| 31 |
+
return datetime.now(timezone.utc).isoformat(
|
| 32 |
+
timespec="seconds").replace("+00:00", "Z")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _atomic_json(path: str, value: Any) -> None:
|
| 36 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 37 |
+
temporary = "%s.%s.tmp" % (path, uuid.uuid4().hex)
|
| 38 |
+
try:
|
| 39 |
+
with open(temporary, "w", encoding="utf-8") as handle:
|
| 40 |
+
json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
| 41 |
+
handle.write("\n")
|
| 42 |
+
os.replace(temporary, path)
|
| 43 |
+
finally:
|
| 44 |
+
try:
|
| 45 |
+
os.unlink(temporary)
|
| 46 |
+
except FileNotFoundError:
|
| 47 |
+
pass
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _read_json(path: str) -> Any:
|
| 51 |
+
with open(path, "r", encoding="utf-8") as handle:
|
| 52 |
+
return json.load(handle)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _inside(root: str, path: str) -> bool:
|
| 56 |
+
try:
|
| 57 |
+
return os.path.commonpath(
|
| 58 |
+
(os.path.realpath(root), os.path.realpath(path))) == os.path.realpath(root)
|
| 59 |
+
except ValueError:
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _file_sha256(path: str) -> str:
|
| 64 |
+
digest = hashlib.sha256()
|
| 65 |
+
with open(path, "rb") as handle:
|
| 66 |
+
while True:
|
| 67 |
+
chunk = handle.read(1024 * 1024)
|
| 68 |
+
if not chunk:
|
| 69 |
+
break
|
| 70 |
+
digest.update(chunk)
|
| 71 |
+
return digest.hexdigest()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _role_group(role: str) -> str:
|
| 75 |
+
if role == "picture":
|
| 76 |
+
return "images"
|
| 77 |
+
if role == "video":
|
| 78 |
+
return "video"
|
| 79 |
+
return "audio"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _binding_id(value: Any) -> str:
|
| 83 |
+
text = str(value or "").strip()
|
| 84 |
+
if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", text):
|
| 85 |
+
return text
|
| 86 |
+
return uuid.uuid4().hex
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class RunAssetStore:
|
| 90 |
+
"""Save loader-backed media and materialize missing-input fallbacks."""
|
| 91 |
+
|
| 92 |
+
def __init__(self, output_root: str, input_root: str | None):
|
| 93 |
+
self.output_root = os.path.realpath(os.path.abspath(output_root))
|
| 94 |
+
self.input_root = (os.path.realpath(os.path.abspath(input_root))
|
| 95 |
+
if input_root is not None else None)
|
| 96 |
+
self.chains_root = os.path.join(self.output_root, "h3_chains")
|
| 97 |
+
|
| 98 |
+
def _run_dir(self, run_name: Any) -> tuple[str, str]:
|
| 99 |
+
run = _safe_name(run_name, "")
|
| 100 |
+
if not run:
|
| 101 |
+
raise ValueError("A non-empty H3 chain run_name is required.")
|
| 102 |
+
path = os.path.realpath(os.path.join(self.chains_root, run))
|
| 103 |
+
if not _inside(self.output_root, path):
|
| 104 |
+
raise ValueError("H3 asset run path escapes the output directory.")
|
| 105 |
+
return path, run
|
| 106 |
+
|
| 107 |
+
def _manifest_path(self, run_name: Any) -> tuple[str, str, str]:
|
| 108 |
+
directory, run = self._run_dir(run_name)
|
| 109 |
+
return (os.path.join(directory, "references", "manifest.json"),
|
| 110 |
+
directory, run)
|
| 111 |
+
|
| 112 |
+
def _source_path(self, value: Any) -> str | None:
|
| 113 |
+
if self.input_root is None or not isinstance(value, str):
|
| 114 |
+
return None
|
| 115 |
+
name = value.strip()
|
| 116 |
+
if not name:
|
| 117 |
+
return None
|
| 118 |
+
if name.endswith("[input]"):
|
| 119 |
+
name = name[:-7].rstrip()
|
| 120 |
+
elif name.endswith("[output]") or name.endswith("[temp]"):
|
| 121 |
+
return None
|
| 122 |
+
path = os.path.abspath(os.path.join(self.input_root, name))
|
| 123 |
+
if not _inside(self.input_root, path) or not os.path.isfile(path):
|
| 124 |
+
return None
|
| 125 |
+
return path
|
| 126 |
+
|
| 127 |
+
def _archived_path(self, run_dir: str, relative: Any) -> str | None:
|
| 128 |
+
if not isinstance(relative, str) or not relative:
|
| 129 |
+
return None
|
| 130 |
+
path = os.path.realpath(os.path.join(run_dir, relative))
|
| 131 |
+
if not _inside(run_dir, path) or not os.path.isfile(path):
|
| 132 |
+
return None
|
| 133 |
+
return path
|
| 134 |
+
|
| 135 |
+
def _copy_to_archive(self, source: str, run_dir: str,
|
| 136 |
+
role: str) -> dict[str, Any]:
|
| 137 |
+
group = _role_group(role)
|
| 138 |
+
destination_dir = os.path.join(run_dir, "references", group)
|
| 139 |
+
os.makedirs(destination_dir, exist_ok=True)
|
| 140 |
+
suffix = os.path.splitext(source)[1][:24]
|
| 141 |
+
basename = _safe_name(os.path.splitext(os.path.basename(source))[0])
|
| 142 |
+
temporary = os.path.join(destination_dir, ".%s.tmp" % uuid.uuid4().hex)
|
| 143 |
+
digest = hashlib.sha256()
|
| 144 |
+
size = 0
|
| 145 |
+
try:
|
| 146 |
+
with open(source, "rb") as source_handle, open(temporary, "xb") as target:
|
| 147 |
+
while True:
|
| 148 |
+
chunk = source_handle.read(1024 * 1024)
|
| 149 |
+
if not chunk:
|
| 150 |
+
break
|
| 151 |
+
target.write(chunk)
|
| 152 |
+
digest.update(chunk)
|
| 153 |
+
size += len(chunk)
|
| 154 |
+
sha256 = digest.hexdigest()
|
| 155 |
+
filename = "%s_%s%s" % (sha256, basename, suffix)
|
| 156 |
+
destination = os.path.join(destination_dir, filename)
|
| 157 |
+
if (os.path.isfile(destination)
|
| 158 |
+
and os.path.getsize(destination) == size
|
| 159 |
+
and _file_sha256(destination) == sha256):
|
| 160 |
+
os.unlink(temporary)
|
| 161 |
+
else:
|
| 162 |
+
os.replace(temporary, destination)
|
| 163 |
+
relative = os.path.relpath(destination, run_dir).replace(os.sep, "/")
|
| 164 |
+
return {
|
| 165 |
+
"relative_path": relative,
|
| 166 |
+
"sha256": sha256,
|
| 167 |
+
"size": size,
|
| 168 |
+
"saved_at": _utc_now(),
|
| 169 |
+
"original_basename": os.path.basename(source),
|
| 170 |
+
}
|
| 171 |
+
finally:
|
| 172 |
+
try:
|
| 173 |
+
os.unlink(temporary)
|
| 174 |
+
except FileNotFoundError:
|
| 175 |
+
pass
|
| 176 |
+
|
| 177 |
+
@staticmethod
|
| 178 |
+
def _normalized_binding(raw: Any) -> dict[str, Any] | None:
|
| 179 |
+
if not isinstance(raw, dict):
|
| 180 |
+
return None
|
| 181 |
+
role = str(raw.get("role") or "").strip()
|
| 182 |
+
if role not in ASSET_ROLES:
|
| 183 |
+
return None
|
| 184 |
+
output_slot = raw.get("output_slot", 0)
|
| 185 |
+
node_id = raw.get("node_id")
|
| 186 |
+
try:
|
| 187 |
+
output_slot = int(output_slot)
|
| 188 |
+
except (TypeError, ValueError):
|
| 189 |
+
output_slot = 0
|
| 190 |
+
return {
|
| 191 |
+
"binding_id": _binding_id(raw.get("binding_id")),
|
| 192 |
+
"label": str(raw.get("label") or role).strip()[:160],
|
| 193 |
+
"role": role,
|
| 194 |
+
"node_id": str(node_id) if node_id is not None else "",
|
| 195 |
+
"node_type": str(raw.get("node_type") or "").strip()[:160],
|
| 196 |
+
"node_title": str(raw.get("node_title") or "").strip()[:160],
|
| 197 |
+
"output_slot": max(0, output_slot),
|
| 198 |
+
"output_type": str(raw.get("output_type") or "").strip()[:80],
|
| 199 |
+
"widget_name": str(raw.get("widget_name") or "").strip()[:120],
|
| 200 |
+
"original_value": str(raw.get("original_value") or "").strip()[:4096],
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
def load_manifest(self, run_name: Any) -> dict[str, Any] | None:
|
| 204 |
+
path, _directory, _run = self._manifest_path(run_name)
|
| 205 |
+
if not os.path.isfile(path):
|
| 206 |
+
return None
|
| 207 |
+
document = _read_json(path)
|
| 208 |
+
if (not isinstance(document, dict)
|
| 209 |
+
or document.get("format") != ASSET_MANIFEST_FORMAT
|
| 210 |
+
or not isinstance(document.get("bindings"), list)):
|
| 211 |
+
raise ValueError("Saved H3 reference manifest has an unsupported format.")
|
| 212 |
+
return document
|
| 213 |
+
|
| 214 |
+
def summary(self, run_name: Any) -> dict[str, Any]:
|
| 215 |
+
_manifest_path, run_dir, _run = self._manifest_path(run_name)
|
| 216 |
+
manifest = self.load_manifest(run_name)
|
| 217 |
+
if manifest is None:
|
| 218 |
+
return {"asset_count": 0, "asset_bytes": 0}
|
| 219 |
+
archived = 0
|
| 220 |
+
for binding in manifest.get("bindings", []):
|
| 221 |
+
archive = binding.get("archive") if isinstance(binding, dict) else None
|
| 222 |
+
if isinstance(archive, dict):
|
| 223 |
+
archived += 1
|
| 224 |
+
total = 0
|
| 225 |
+
archive_files = 0
|
| 226 |
+
# Count the actual retained snapshots, including older versions and
|
| 227 |
+
# safe orphaned files left by a removed binding. Recovery data is not
|
| 228 |
+
# deleted implicitly merely to make a manifest smaller.
|
| 229 |
+
for group in ("images", "audio", "video"):
|
| 230 |
+
directory = os.path.join(run_dir, "references", group)
|
| 231 |
+
if not os.path.isdir(directory):
|
| 232 |
+
continue
|
| 233 |
+
with os.scandir(directory) as entries:
|
| 234 |
+
for entry in entries:
|
| 235 |
+
if entry.name.startswith(".") or not entry.is_file(
|
| 236 |
+
follow_symlinks=False):
|
| 237 |
+
continue
|
| 238 |
+
archive_files += 1
|
| 239 |
+
total += max(0, entry.stat(follow_symlinks=False).st_size)
|
| 240 |
+
return {
|
| 241 |
+
"asset_count": len(manifest.get("bindings", [])),
|
| 242 |
+
"archived_asset_count": archived,
|
| 243 |
+
"asset_file_count": archive_files,
|
| 244 |
+
"asset_bytes": total,
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
def save(self, run_name: Any, bindings: Any,
|
| 248 |
+
policies: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 249 |
+
manifest_path, run_dir, run = self._manifest_path(run_name)
|
| 250 |
+
if not isinstance(bindings, list):
|
| 251 |
+
raise ValueError("H3 asset bindings must be a JSON list.")
|
| 252 |
+
if len(bindings) > MAX_ASSET_BINDINGS:
|
| 253 |
+
raise ValueError("H3 Run Manager supports at most %d asset bindings."
|
| 254 |
+
% MAX_ASSET_BINDINGS)
|
| 255 |
+
policies = policies if isinstance(policies, dict) else {}
|
| 256 |
+
enabled = {
|
| 257 |
+
"images": bool(policies.get("images", True)),
|
| 258 |
+
"audio": bool(policies.get("audio", True)),
|
| 259 |
+
"video": bool(policies.get("video", False)),
|
| 260 |
+
}
|
| 261 |
+
try:
|
| 262 |
+
old_manifest = self.load_manifest(run) or {}
|
| 263 |
+
except (OSError, ValueError, json.JSONDecodeError):
|
| 264 |
+
old_manifest = {}
|
| 265 |
+
old_bindings = {
|
| 266 |
+
item.get("binding_id"): item
|
| 267 |
+
for item in old_manifest.get("bindings", [])
|
| 268 |
+
if isinstance(item, dict) and item.get("binding_id")
|
| 269 |
+
}
|
| 270 |
+
saved = []
|
| 271 |
+
warnings = []
|
| 272 |
+
for raw in bindings:
|
| 273 |
+
binding = self._normalized_binding(raw)
|
| 274 |
+
if binding is None:
|
| 275 |
+
warnings.append("Ignored one binding with an unsupported media role.")
|
| 276 |
+
continue
|
| 277 |
+
previous = old_bindings.get(binding["binding_id"], {})
|
| 278 |
+
versions = list(previous.get("versions") or [])
|
| 279 |
+
prior_archive = previous.get("archive")
|
| 280 |
+
same_source = (
|
| 281 |
+
previous.get("original_value") == binding["original_value"])
|
| 282 |
+
source = self._source_path(binding["original_value"])
|
| 283 |
+
group = _role_group(binding["role"])
|
| 284 |
+
archive = (prior_archive if same_source
|
| 285 |
+
and isinstance(prior_archive, dict) else None)
|
| 286 |
+
if (isinstance(prior_archive, dict) and not same_source
|
| 287 |
+
and not any(item.get("sha256") == prior_archive.get("sha256")
|
| 288 |
+
for item in versions if isinstance(item, dict))):
|
| 289 |
+
versions.append(prior_archive)
|
| 290 |
+
if source is not None and enabled[group]:
|
| 291 |
+
next_archive = self._copy_to_archive(source, run_dir, binding["role"])
|
| 292 |
+
if (archive and archive.get("sha256") != next_archive.get("sha256")
|
| 293 |
+
and not any(item.get("sha256") == archive.get("sha256")
|
| 294 |
+
for item in versions if isinstance(item, dict))):
|
| 295 |
+
versions.append(archive)
|
| 296 |
+
archive = next_archive
|
| 297 |
+
elif not enabled[group] and archive:
|
| 298 |
+
if not any(item.get("sha256") == archive.get("sha256")
|
| 299 |
+
for item in versions if isinstance(item, dict)):
|
| 300 |
+
versions.append(archive)
|
| 301 |
+
archive = None
|
| 302 |
+
if source is None:
|
| 303 |
+
warnings.append(
|
| 304 |
+
"%s: original input file is unavailable%s." % (
|
| 305 |
+
binding["label"],
|
| 306 |
+
" and no archived fallback exists" if not archive else ""))
|
| 307 |
+
binding["archive_policy"] = (
|
| 308 |
+
"fallback_copy" if enabled[group] else "path_only")
|
| 309 |
+
binding["source_available_when_saved"] = source is not None
|
| 310 |
+
if archive:
|
| 311 |
+
binding["archive"] = archive
|
| 312 |
+
if versions:
|
| 313 |
+
binding["versions"] = versions
|
| 314 |
+
saved.append(binding)
|
| 315 |
+
|
| 316 |
+
document = {
|
| 317 |
+
"format": ASSET_MANIFEST_FORMAT,
|
| 318 |
+
"run_name": run,
|
| 319 |
+
"updated_at": _utc_now(),
|
| 320 |
+
"policies": enabled,
|
| 321 |
+
"bindings": saved,
|
| 322 |
+
}
|
| 323 |
+
_atomic_json(manifest_path, document)
|
| 324 |
+
summary = self.summary(run)
|
| 325 |
+
return {
|
| 326 |
+
"run_name": run,
|
| 327 |
+
"manifest": os.path.relpath(manifest_path, self.output_root).replace(os.sep, "/"),
|
| 328 |
+
"bindings": saved,
|
| 329 |
+
"warnings": warnings,
|
| 330 |
+
**summary,
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
def _materialize_archive(self, run: str, archived_path: str,
|
| 334 |
+
archive: dict[str, Any]) -> str:
|
| 335 |
+
if self.input_root is None:
|
| 336 |
+
raise ValueError("The ComfyUI input directory is unavailable.")
|
| 337 |
+
os.makedirs(self.input_root, exist_ok=True)
|
| 338 |
+
digest = str(archive.get("sha256") or "")
|
| 339 |
+
basename = _safe_name(
|
| 340 |
+
archive.get("original_basename") or os.path.basename(archived_path))
|
| 341 |
+
filename = "h3_%s_%s_%s" % (run, digest[:16] or "asset", basename)
|
| 342 |
+
destination = os.path.join(self.input_root, filename)
|
| 343 |
+
if not _inside(self.input_root, destination):
|
| 344 |
+
raise ValueError("Restored H3 asset path escapes the input directory.")
|
| 345 |
+
expected = str(archive.get("sha256") or "")
|
| 346 |
+
destination_ready = (os.path.isfile(destination)
|
| 347 |
+
and (not expected
|
| 348 |
+
or _file_sha256(destination) == expected))
|
| 349 |
+
if not destination_ready:
|
| 350 |
+
temporary = "%s.%s.tmp" % (destination, uuid.uuid4().hex)
|
| 351 |
+
try:
|
| 352 |
+
shutil.copy2(archived_path, temporary)
|
| 353 |
+
if expected and _file_sha256(temporary) != expected:
|
| 354 |
+
raise ValueError("Archived fallback failed its SHA-256 check.")
|
| 355 |
+
os.replace(temporary, destination)
|
| 356 |
+
finally:
|
| 357 |
+
try:
|
| 358 |
+
os.unlink(temporary)
|
| 359 |
+
except FileNotFoundError:
|
| 360 |
+
pass
|
| 361 |
+
return os.path.basename(destination)
|
| 362 |
+
|
| 363 |
+
def prepare_restore(self, run_name: Any) -> dict[str, Any]:
|
| 364 |
+
_path, run_dir, run = self._manifest_path(run_name)
|
| 365 |
+
manifest = self.load_manifest(run)
|
| 366 |
+
if manifest is None:
|
| 367 |
+
return {"run_name": run, "bindings": [], "warnings": []}
|
| 368 |
+
restored = []
|
| 369 |
+
warnings = []
|
| 370 |
+
for original in manifest.get("bindings", []):
|
| 371 |
+
if not isinstance(original, dict):
|
| 372 |
+
continue
|
| 373 |
+
binding = dict(original)
|
| 374 |
+
source = self._source_path(binding.get("original_value"))
|
| 375 |
+
if source is not None:
|
| 376 |
+
binding["restore_value"] = binding.get("original_value")
|
| 377 |
+
binding["restore_source"] = "original_input"
|
| 378 |
+
else:
|
| 379 |
+
archive = binding.get("archive")
|
| 380 |
+
archived_path = self._archived_path(
|
| 381 |
+
run_dir, archive.get("relative_path")
|
| 382 |
+
if isinstance(archive, dict) else None)
|
| 383 |
+
if archived_path is None:
|
| 384 |
+
binding["restore_source"] = "missing"
|
| 385 |
+
warnings.append("%s: original and archived files are missing."
|
| 386 |
+
% binding.get("label", binding.get("role", "asset")))
|
| 387 |
+
else:
|
| 388 |
+
try:
|
| 389 |
+
binding["restore_value"] = self._materialize_archive(
|
| 390 |
+
run, archived_path, archive)
|
| 391 |
+
binding["restore_source"] = "archived_fallback"
|
| 392 |
+
except (OSError, ValueError) as exc:
|
| 393 |
+
binding["restore_source"] = "missing"
|
| 394 |
+
warnings.append("%s: %s" % (
|
| 395 |
+
binding.get("label", binding.get("role", "asset")), exc))
|
| 396 |
+
restored.append(binding)
|
| 397 |
+
return {"run_name": run, "bindings": restored, "warnings": warnings}
|
ComfyUI-MiniMaxH3-Context-Loop/assets/minimax-h3-context-loop.svg
ADDED
|
|
ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png
ADDED
|
Git LFS Details
|
ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.svg
ADDED
|
|
ComfyUI-MiniMaxH3-Context-Loop/assets/workflow-overview.svg
ADDED
|
|
ComfyUI-MiniMaxH3-Context-Loop/audio_track_contract.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON-only contracts for grouped project audio; no media or runtime imports."""
|
| 2 |
+
|
| 3 |
+
AUDIO_TRACK_ROLES = ("full_mix", "vocals", "instrumental")
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def audio_track_bindings(value, assets):
|
| 7 |
+
if value is None:
|
| 8 |
+
return None
|
| 9 |
+
if not isinstance(value, dict) or set(value) - set(AUDIO_TRACK_ROLES):
|
| 10 |
+
raise ValueError("Audio tracks must map full_mix, vocals, and instrumental to asset IDs.")
|
| 11 |
+
known = {str(asset.get("id") or ""): asset for asset in assets}
|
| 12 |
+
result = {}
|
| 13 |
+
for role in AUDIO_TRACK_ROLES:
|
| 14 |
+
raw = value.get(role, "")
|
| 15 |
+
if raw is not None and not isinstance(raw, str):
|
| 16 |
+
raise ValueError("Audio track IDs must be strings.")
|
| 17 |
+
asset_id = str(raw or "").strip()
|
| 18 |
+
if asset_id and (asset_id not in known or known[asset_id].get("kind") != "audio"):
|
| 19 |
+
raise ValueError("Audio track %s must reference an existing audio asset in this project." % role)
|
| 20 |
+
result[role] = asset_id
|
| 21 |
+
selected = [asset_id for asset_id in result.values() if asset_id]
|
| 22 |
+
if not selected:
|
| 23 |
+
raise ValueError("Select at least one audio track, or reset to the original single track.")
|
| 24 |
+
if len(selected) != len(set(selected)):
|
| 25 |
+
raise ValueError("The same audio asset cannot be both a mix and a stem, or both stems.")
|
| 26 |
+
return result
|
ComfyUI-MiniMaxH3-Context-Loop/av_timing.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exact MiniMax H3 picture/audio timeline helpers.
|
| 2 |
+
|
| 3 |
+
H3 picture runs live on a 24 fps clock while generated audio latents live on
|
| 4 |
+
a 40 Hz clock. A decoded audio stream can consequently land a few
|
| 5 |
+
milliseconds either side of its exact frame-derived duration. Small grid
|
| 6 |
+
mismatches are time-conformed here instead of being repaired with silence.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from fractions import Fraction
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
import torchaudio
|
| 18 |
+
except ImportError:
|
| 19 |
+
torchaudio = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
_LOG = logging.getLogger("minimax_h3_context_loop.av_timing")
|
| 23 |
+
|
| 24 |
+
# Private AUDIO mapping fields passed only between Loop Trim and Segment Save.
|
| 25 |
+
# Keeping this transport inside the existing AUDIO value avoids another public
|
| 26 |
+
# socket and another copy of the same decoded stream in user workflows.
|
| 27 |
+
AUDIO_WITH_OVERLAP_WAVEFORM_KEY = "_h3_audio_with_overlap_waveform"
|
| 28 |
+
AUDIO_WITH_OVERLAP_FRAMES_KEY = "_h3_audio_with_overlap_frames"
|
| 29 |
+
AUDIO_TRIM_FRAMES_KEY = "_h3_audio_trim_frames"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def sample_boundary_from_seconds(seconds: float, sample_rate: int) -> int:
|
| 33 |
+
"""Return the nearest PCM sample on one absolute time boundary."""
|
| 34 |
+
return int(round(float(seconds) * int(sample_rate)))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def sample_boundary_from_frames(
|
| 38 |
+
frame_position: int, sample_rate: int, fps: int = 24) -> int:
|
| 39 |
+
"""Return the nearest PCM sample on one absolute video-frame boundary."""
|
| 40 |
+
return int(round(int(frame_position) / float(fps) * int(sample_rate)))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def conform_waveform_length(
|
| 44 |
+
waveform, samples: int, label: str,
|
| 45 |
+
max_fractional_change: float = 0.005):
|
| 46 |
+
"""Time-conform a small decoder/grid mismatch to an exact sample span.
|
| 47 |
+
|
| 48 |
+
A larger discrepancy is likely incorrect wiring or timing metadata and is
|
| 49 |
+
rejected instead of being hidden by a large resample.
|
| 50 |
+
"""
|
| 51 |
+
target = int(samples)
|
| 52 |
+
current = int(waveform.shape[-1])
|
| 53 |
+
if current == target:
|
| 54 |
+
return waveform
|
| 55 |
+
if current <= 0 or target <= 0:
|
| 56 |
+
raise ValueError(
|
| 57 |
+
"%s has an invalid audio sample length %d -> %d." %
|
| 58 |
+
(label, current, target))
|
| 59 |
+
|
| 60 |
+
fractional_change = abs(target - current) / float(target)
|
| 61 |
+
if fractional_change > float(max_fractional_change):
|
| 62 |
+
raise ValueError(
|
| 63 |
+
"%s differs from its exact frame timeline by %.3f%% "
|
| 64 |
+
"(%d -> %d samples), too large for H3 audio time-conformance." %
|
| 65 |
+
(label, fractional_change * 100.0, current, target))
|
| 66 |
+
|
| 67 |
+
ratio = Fraction(target, current).limit_denominator(10000)
|
| 68 |
+
if torchaudio is not None:
|
| 69 |
+
conformed = torchaudio.functional.resample(
|
| 70 |
+
waveform, int(ratio.denominator), int(ratio.numerator))
|
| 71 |
+
else:
|
| 72 |
+
shape = tuple(waveform.shape)
|
| 73 |
+
conformed = torch.nn.functional.interpolate(
|
| 74 |
+
waveform.reshape(-1, 1, current),
|
| 75 |
+
size=target,
|
| 76 |
+
mode="linear",
|
| 77 |
+
align_corners=False,
|
| 78 |
+
).reshape(*shape[:-1], target)
|
| 79 |
+
|
| 80 |
+
# A rational approximation or resampler backend may miss by one sample.
|
| 81 |
+
# Correct the residual by interpolation so silence is never introduced.
|
| 82 |
+
if int(conformed.shape[-1]) != target:
|
| 83 |
+
shape = tuple(conformed.shape)
|
| 84 |
+
conformed = torch.nn.functional.interpolate(
|
| 85 |
+
conformed.reshape(-1, 1, int(conformed.shape[-1])),
|
| 86 |
+
size=target,
|
| 87 |
+
mode="linear",
|
| 88 |
+
align_corners=False,
|
| 89 |
+
).reshape(*shape[:-1], target)
|
| 90 |
+
|
| 91 |
+
_LOG.info(
|
| 92 |
+
"%s time-conformed %d -> %d audio samples (%+.4f%%) to match "
|
| 93 |
+
"the exact video timeline",
|
| 94 |
+
label, current, target,
|
| 95 |
+
(target - current) / float(current) * 100.0)
|
| 96 |
+
return conformed
|
ComfyUI-MiniMaxH3-Context-Loop/chain_nodes.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
ComfyUI-MiniMaxH3-Context-Loop/chapter_resolution.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chapter-local geometry; no media mutation or implicit latent resizing."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def normalize_resolution(value, label="Chapter resolution"):
|
| 5 |
+
if value is None:
|
| 6 |
+
return None
|
| 7 |
+
if not isinstance(value, dict) or set(value) != {"width", "height"}:
|
| 8 |
+
raise ValueError(f"{label} must contain width and height, or be null to inherit.")
|
| 9 |
+
result = {}
|
| 10 |
+
for key in ("width", "height"):
|
| 11 |
+
number = value[key]
|
| 12 |
+
if (isinstance(number, bool) or not isinstance(number, int)
|
| 13 |
+
or number < 32 or number > 16384 or number % 32):
|
| 14 |
+
raise ValueError(f"{label} {key} must be a multiple of 32 between 32 and 16384.")
|
| 15 |
+
result[key] = number
|
| 16 |
+
return result
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def scene_resolution(plan, index):
|
| 20 |
+
shot = plan["shots"][int(index) - 1]
|
| 21 |
+
return dict(shot.get("resolution") or {
|
| 22 |
+
key: int(plan["compatibility"][key]) for key in ("width", "height")
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def saved_resolution(segment, metadata=None):
|
| 27 |
+
"""Prefer the immutable scene's recorded geometry, including legacy saves."""
|
| 28 |
+
metadata = metadata or {}
|
| 29 |
+
dependency = segment.get("scene_dependency") or metadata.get("scene_dependency") or {}
|
| 30 |
+
candidates = (
|
| 31 |
+
segment.get("resolution"),
|
| 32 |
+
dependency.get("scopes", {}).get("global_generation"),
|
| 33 |
+
metadata.get("compatibility"),
|
| 34 |
+
)
|
| 35 |
+
for candidate in candidates:
|
| 36 |
+
if isinstance(candidate, dict) and all(key in candidate for key in ("width", "height")):
|
| 37 |
+
return normalize_resolution(
|
| 38 |
+
{key: candidate[key] for key in ("width", "height")},
|
| 39 |
+
"Saved scene resolution")
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def apply_chapter_resolutions(plan, chapters, locked_resolutions):
|
| 44 |
+
"""Resolve one size per chapter, pinning inherited chapters to saved locks.
|
| 45 |
+
|
| 46 |
+
Only chapter-sized geometry is authored. Locking a saved scene prevents a
|
| 47 |
+
Plan-default edit from changing the interpretation of its whole chapter.
|
| 48 |
+
Explicit conflicting chapter edits fail; locks never waive integrity checks.
|
| 49 |
+
"""
|
| 50 |
+
starts = sorted(chapters, key=lambda row: int(row["start_scene"]))
|
| 51 |
+
if not starts or int(starts[0]["start_scene"]) > 1:
|
| 52 |
+
implicit = {"start_scene": 1, "title": "Unassigned",
|
| 53 |
+
"start_scene_id": plan["shots"][0]["id"], "text": ""}
|
| 54 |
+
identifier = "unassigned"
|
| 55 |
+
while any(row["id"] == identifier for row in chapters):
|
| 56 |
+
identifier += "_"
|
| 57 |
+
implicit["id"] = identifier
|
| 58 |
+
starts.insert(0, implicit)
|
| 59 |
+
for offset, chapter in enumerate(starts):
|
| 60 |
+
start = int(chapter["start_scene"])
|
| 61 |
+
end = int(starts[offset + 1]["start_scene"]) - 1 if offset + 1 < len(starts) else len(plan["shots"])
|
| 62 |
+
shots = plan["shots"][start - 1:end]
|
| 63 |
+
explicit = normalize_resolution(chapter.get("resolution"))
|
| 64 |
+
locked = [locked_resolutions[shot["id"]] for shot in shots
|
| 65 |
+
if shot["id"] in locked_resolutions]
|
| 66 |
+
sizes = {(row["width"], row["height"]) for row in locked}
|
| 67 |
+
if len(sizes) > 1:
|
| 68 |
+
raise ValueError(f"{chapter['title']} has locked saved scenes at different resolutions. "
|
| 69 |
+
"Place the resolution boundary between chapters before continuing.")
|
| 70 |
+
pinned = locked[0] if locked else None
|
| 71 |
+
if pinned and explicit and explicit != pinned:
|
| 72 |
+
raise ValueError(f"{chapter['title']} is pinned by locked saved scenes to "
|
| 73 |
+
f"{pinned['width']}x{pinned['height']}. Unlock those scenes "
|
| 74 |
+
"before changing this chapter's resolution; saved files are unchanged.")
|
| 75 |
+
resolution = pinned or explicit
|
| 76 |
+
if resolution:
|
| 77 |
+
if pinned:
|
| 78 |
+
chapter["resolution"] = dict(pinned)
|
| 79 |
+
if chapter not in chapters:
|
| 80 |
+
# Recovery must retain a pin before the first authored marker.
|
| 81 |
+
chapters.insert(0, chapter)
|
| 82 |
+
for shot in shots:
|
| 83 |
+
shot["resolution"] = dict(resolution)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def common_saved_resolution(segments, label="Video assembly"):
|
| 87 |
+
sizes = {tuple(value[key] for key in ("width", "height"))
|
| 88 |
+
for segment in segments
|
| 89 |
+
if (value := saved_resolution(segment)) is not None}
|
| 90 |
+
if len(sizes) > 1:
|
| 91 |
+
raise ValueError(f"{label} contains different chapter resolutions. Export each chapter "
|
| 92 |
+
"separately, then resize to a common output size before joining. "
|
| 93 |
+
"Saved scenes and exports are unchanged.")
|
| 94 |
+
if sizes:
|
| 95 |
+
width, height = next(iter(sizes))
|
| 96 |
+
return {"width": width, "height": height}
|
| 97 |
+
return None
|
ComfyUI-MiniMaxH3-Context-Loop/chapter_snapshot_retirement.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Preview-confirmed retirement of chapter recovery pins, never clip media."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path, PurePosixPath
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
from .checkpoint_manager import (
|
| 9 |
+
CheckpointDeleteBlocked, CheckpointGraphManager, _fingerprint,
|
| 10 |
+
_strict_run_name, checkpoint_run_lock,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ChapterSnapshotManager:
|
| 15 |
+
def __init__(self, output_root):
|
| 16 |
+
self.root = Path(output_root).resolve()
|
| 17 |
+
|
| 18 |
+
def _path(self, address):
|
| 19 |
+
if not isinstance(address, str) or not address:
|
| 20 |
+
raise ValueError("Select a saved chapter snapshot.")
|
| 21 |
+
parts = PurePosixPath(address).parts
|
| 22 |
+
if (PurePosixPath(address).is_absolute() or ".." in parts or
|
| 23 |
+
"\\" in address or str(PurePosixPath(address)) != address):
|
| 24 |
+
raise ValueError("Invalid chapter snapshot address.")
|
| 25 |
+
path = self.root
|
| 26 |
+
for part in parts:
|
| 27 |
+
path /= part
|
| 28 |
+
if path.is_symlink():
|
| 29 |
+
raise ValueError("Snapshot retirement cannot follow symlinks.")
|
| 30 |
+
if not path.resolve().is_relative_to(self.root):
|
| 31 |
+
raise ValueError("Chapter snapshot escapes the output directory.")
|
| 32 |
+
return path
|
| 33 |
+
|
| 34 |
+
def _preview(self, run, address):
|
| 35 |
+
path = self._path(address)
|
| 36 |
+
parts = PurePosixPath(address).parts
|
| 37 |
+
if not (len(parts) == 6 and parts[:3] == ("h3_chains", run, "chapters")
|
| 38 |
+
and parts[4] == "manifests"
|
| 39 |
+
and re.fullmatch(r"[0-9a-f]{32}\.json", parts[5])):
|
| 40 |
+
raise ValueError("Select an immutable chapter snapshot inside this run.")
|
| 41 |
+
if not path.is_file():
|
| 42 |
+
raise FileNotFoundError("The chapter snapshot is no longer available.")
|
| 43 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 44 |
+
if not isinstance(data, dict):
|
| 45 |
+
raise ValueError("Chapter snapshot must be a JSON object.")
|
| 46 |
+
chapter = data.get("chapter")
|
| 47 |
+
if (data.get("format") != "h3_chain_chapter_manifest_v1"
|
| 48 |
+
or data.get("run_name") != run
|
| 49 |
+
or not isinstance(chapter, dict)
|
| 50 |
+
or not isinstance(chapter.get("number"), int)
|
| 51 |
+
or chapter["number"] < 1
|
| 52 |
+
or not parts[3].startswith("%02d_" % chapter["number"])
|
| 53 |
+
or not isinstance(data.get("segments"), list)
|
| 54 |
+
or not data["segments"]
|
| 55 |
+
or any(not isinstance(s, dict) or not isinstance(s.get("index"), int)
|
| 56 |
+
for s in data["segments"])):
|
| 57 |
+
raise ValueError("Invalid chapter snapshot identity.")
|
| 58 |
+
identity = {k: v for k, v in data.items()
|
| 59 |
+
if k not in ("sealed_at", "chapter_manifest_id", "chapter_manifest_path")}
|
| 60 |
+
if (_fingerprint(identity)[:32] != path.stem
|
| 61 |
+
or data.get("chapter_manifest_id") != path.stem
|
| 62 |
+
or data.get("chapter_manifest_path") != address):
|
| 63 |
+
raise ValueError("Chapter snapshot failed its identity check.")
|
| 64 |
+
destination = path.parent.parent / "retired_manifests" / path.name
|
| 65 |
+
retired_address = destination.relative_to(self.root).as_posix()
|
| 66 |
+
self._path(retired_address)
|
| 67 |
+
if destination.exists():
|
| 68 |
+
raise ValueError("A retired copy already exists; it will not be overwritten.")
|
| 69 |
+
active, _ = CheckpointGraphManager(str(self.root)).active_selection(run)
|
| 70 |
+
scenes = [{"scene": s["index"], "revision": s.get("revision", ""),
|
| 71 |
+
"active": active.get(s["index"]) == s.get("revision")}
|
| 72 |
+
for s in data["segments"]]
|
| 73 |
+
stat = path.stat()
|
| 74 |
+
snapshot = _fingerprint({
|
| 75 |
+
"document": data, "active": active,
|
| 76 |
+
"file": [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns],
|
| 77 |
+
"path": address, "retired_path": retired_address,
|
| 78 |
+
})
|
| 79 |
+
return {"ok": True, "allowed": True, "run_name": run,
|
| 80 |
+
"path": address, "retired_path": retired_address,
|
| 81 |
+
"chapter_number": chapter["number"],
|
| 82 |
+
"chapter_title": str(chapter.get("title") or "Chapter %d" % chapter["number"]),
|
| 83 |
+
"chapter_manifest_id": path.stem, "scenes": scenes,
|
| 84 |
+
"snapshot": snapshot,
|
| 85 |
+
"message": "Retiring releases only this snapshot's recovery pins. "
|
| 86 |
+
"Its metadata is archived; no clips, active pointers, "
|
| 87 |
+
"references, processing takes or exports are deleted."}
|
| 88 |
+
|
| 89 |
+
def retirement_preview(self, run_name, address):
|
| 90 |
+
run = _strict_run_name(run_name)
|
| 91 |
+
with checkpoint_run_lock(str(self.root), run):
|
| 92 |
+
return self._preview(run, address)
|
| 93 |
+
|
| 94 |
+
def retire(self, run_name, address, expected_snapshot=""):
|
| 95 |
+
run = _strict_run_name(run_name)
|
| 96 |
+
with checkpoint_run_lock(str(self.root), run):
|
| 97 |
+
preview = self._preview(run, address)
|
| 98 |
+
if not expected_snapshot or expected_snapshot != preview["snapshot"]:
|
| 99 |
+
raise CheckpointDeleteBlocked(
|
| 100 |
+
"Chapter snapshot or active selection changed; preview retirement again.", preview)
|
| 101 |
+
path = self._path(preview["path"])
|
| 102 |
+
destination = self._path(preview["retired_path"])
|
| 103 |
+
destination.parent.mkdir(exist_ok=True)
|
| 104 |
+
os.rename(path, destination)
|
| 105 |
+
return {**preview, "message": "Chapter snapshot %s retired; recovery metadata "
|
| 106 |
+
"kept at %s. No clips were deleted." %
|
| 107 |
+
(preview["chapter_manifest_id"][:8], preview["retired_path"])}
|
ComfyUI-MiniMaxH3-Context-Loop/checkpoint_manager.py
ADDED
|
@@ -0,0 +1,1541 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dependency-aware discovery and deletion for H3 scene checkpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import threading
|
| 11 |
+
import uuid
|
| 12 |
+
from collections import defaultdict, deque
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
_REVISION = re.compile(r"clip_(\d{4})\.([0-9a-f]{32})\.json")
|
| 18 |
+
_ACTIVE = re.compile(r"clip_(\d{4})\.json")
|
| 19 |
+
_ARTIFACT_KEYS = (
|
| 20 |
+
"segment", "checkpoint", "prompt_file", "generated_audio",
|
| 21 |
+
"blend_segment", "revision_metadata",
|
| 22 |
+
)
|
| 23 |
+
_ARTIFACT_KINDS = {
|
| 24 |
+
"segment": "Segment video",
|
| 25 |
+
"checkpoint": "Continuation checkpoint",
|
| 26 |
+
"prompt_file": "Prompt snapshot",
|
| 27 |
+
"generated_audio": "Generated audio",
|
| 28 |
+
"blend_segment": "Blend-ready video",
|
| 29 |
+
"revision_metadata": "Revision metadata",
|
| 30 |
+
"review_preview": "Review preview",
|
| 31 |
+
"active_pointer": "Active scene pointer",
|
| 32 |
+
}
|
| 33 |
+
def checkpoint_audio_context_length(
|
| 34 |
+
continuation: str, visual: int, audio: int,
|
| 35 |
+
generated_continuity: str = "", source_audio_target: str = "") -> int:
|
| 36 |
+
"""Interpret saved predecessor audio using the generation contract.
|
| 37 |
+
|
| 38 |
+
Legacy saves recorded the configured audio span even when AV conditioning
|
| 39 |
+
returned early with no picture prefix. AV modes consume a joint prefix;
|
| 40 |
+
Guide modes can consume audio alone. Unknown modes/policies retain their
|
| 41 |
+
recorded dependency rather than treating missing evidence as independence.
|
| 42 |
+
This must never consult a mutable Plan to reinterpret an existing take.
|
| 43 |
+
"""
|
| 44 |
+
if generated_continuity == "off" or source_audio_target == "locked":
|
| 45 |
+
return 0
|
| 46 |
+
if visual >= 0 and continuation in (
|
| 47 |
+
"masked_av", "tapered_av", "feathered_av", "audio_feathered_av",
|
| 48 |
+
"drift_control_av", "color_stable_drift_av"):
|
| 49 |
+
return visual if audio > 0 else audio
|
| 50 |
+
return audio
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
_RUN_LOCKS: dict[tuple[str, str], threading.RLock] = {}
|
| 54 |
+
_RUN_LOCKS_GUARD = threading.Lock()
|
| 55 |
+
_LOG = logging.getLogger("minimax_h3_context_loop.checkpoints")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _canonical_json(value: Any) -> str:
|
| 59 |
+
return json.dumps(value, ensure_ascii=False, sort_keys=True,
|
| 60 |
+
separators=(",", ":"))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _fingerprint(value: Any) -> str:
|
| 64 |
+
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _safe_name(value: Any) -> str:
|
| 68 |
+
text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(value or "").strip())
|
| 69 |
+
return text.strip("._-")[:96]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _strict_run_name(value: Any) -> str:
|
| 73 |
+
"""Validate mutation targets without silently choosing a different Run."""
|
| 74 |
+
requested = str(value or "").strip()
|
| 75 |
+
normalized = _safe_name(requested)
|
| 76 |
+
if not normalized or requested != normalized:
|
| 77 |
+
raise ValueError("A valid exact H3 chain run_name is required.")
|
| 78 |
+
return normalized
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def checkpoint_run_lock(output_root: str, run_name: Any) -> threading.RLock:
|
| 82 |
+
"""Return the process-local mutation lock shared by save and delete."""
|
| 83 |
+
root = os.path.realpath(os.path.abspath(output_root))
|
| 84 |
+
run = _safe_name(run_name)
|
| 85 |
+
key = (root, run)
|
| 86 |
+
with _RUN_LOCKS_GUARD:
|
| 87 |
+
return _RUN_LOCKS.setdefault(key, threading.RLock())
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def checkpoint_revision_token(scene: Any, segment: Any) -> str:
|
| 91 |
+
"""Return the immutable revision id used by current and legacy saves.
|
| 92 |
+
|
| 93 |
+
Builds before revision sidecars stored the transaction id only in the
|
| 94 |
+
versioned MP4 and safetensors filenames. Both artifacts were committed by
|
| 95 |
+
the same transaction, so a matching filename token is the original
|
| 96 |
+
revision identity rather than a newly invented one.
|
| 97 |
+
"""
|
| 98 |
+
if not isinstance(segment, dict):
|
| 99 |
+
return ""
|
| 100 |
+
try:
|
| 101 |
+
index = int(scene)
|
| 102 |
+
except (TypeError, ValueError):
|
| 103 |
+
return ""
|
| 104 |
+
stored = str(segment.get("revision") or "").strip().lower()
|
| 105 |
+
if re.fullmatch(r"[0-9a-f]{32}", stored):
|
| 106 |
+
return stored
|
| 107 |
+
tokens = set()
|
| 108 |
+
patterns = {
|
| 109 |
+
"segment": r"clip_%04d\.([0-9a-f]{32})\.mp4" % index,
|
| 110 |
+
"checkpoint": r"clip_%04d\.([0-9a-f]{32})\.safetensors" % index,
|
| 111 |
+
}
|
| 112 |
+
for key, pattern in patterns.items():
|
| 113 |
+
name = os.path.basename(str(segment.get(key) or "")).lower()
|
| 114 |
+
match = re.fullmatch(pattern, name)
|
| 115 |
+
if match is not None:
|
| 116 |
+
tokens.add(match.group(1))
|
| 117 |
+
return next(iter(tokens)) if len(tokens) == 1 else ""
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class CheckpointDeleteBlocked(ValueError):
|
| 121 |
+
"""Deletion was safe to inspect but is blocked by checkpoint state."""
|
| 122 |
+
|
| 123 |
+
def __init__(self, message: str, preview: dict[str, Any]):
|
| 124 |
+
super().__init__(message)
|
| 125 |
+
self.preview = preview
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class CheckpointGraphManager:
|
| 129 |
+
"""Build lineage graphs and remove only dependency-free revisions."""
|
| 130 |
+
|
| 131 |
+
def __init__(self, output_root: str):
|
| 132 |
+
self.output_root = os.path.realpath(os.path.abspath(output_root))
|
| 133 |
+
self.chains_root = os.path.realpath(os.path.join(
|
| 134 |
+
self.output_root, "h3_chains"))
|
| 135 |
+
|
| 136 |
+
@staticmethod
|
| 137 |
+
def _inside(root: str, path: str) -> bool:
|
| 138 |
+
try:
|
| 139 |
+
return os.path.commonpath([root, path]) == root
|
| 140 |
+
except ValueError:
|
| 141 |
+
return False
|
| 142 |
+
|
| 143 |
+
def _run_dir(self, run_name: Any) -> tuple[str, str]:
|
| 144 |
+
run = _safe_name(run_name)
|
| 145 |
+
if not run:
|
| 146 |
+
raise ValueError("A non-empty H3 chain run_name is required.")
|
| 147 |
+
path = os.path.realpath(os.path.join(self.chains_root, run))
|
| 148 |
+
if not self._inside(self.output_root, path):
|
| 149 |
+
raise ValueError("H3 checkpoint run path escapes the output directory.")
|
| 150 |
+
return path, run
|
| 151 |
+
|
| 152 |
+
def _artifact_path(self, value: Any) -> str:
|
| 153 |
+
text = str(value or "").strip()
|
| 154 |
+
if not text:
|
| 155 |
+
raise ValueError("Checkpoint artifact path is empty.")
|
| 156 |
+
path = os.path.realpath(
|
| 157 |
+
text if os.path.isabs(text) else os.path.join(self.output_root, text))
|
| 158 |
+
if not self._inside(self.output_root, path):
|
| 159 |
+
raise ValueError("Checkpoint artifact path escapes the output directory.")
|
| 160 |
+
return path
|
| 161 |
+
|
| 162 |
+
def _output_item(self, path: str) -> dict[str, str]:
|
| 163 |
+
relative = os.path.relpath(path, self.output_root)
|
| 164 |
+
return {
|
| 165 |
+
"filename": os.path.basename(relative),
|
| 166 |
+
"subfolder": os.path.dirname(relative),
|
| 167 |
+
"type": "output",
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
@staticmethod
|
| 171 |
+
def _read_json(path: str) -> Any:
|
| 172 |
+
with open(path, "r", encoding="utf-8") as handle:
|
| 173 |
+
return json.load(handle)
|
| 174 |
+
|
| 175 |
+
@staticmethod
|
| 176 |
+
def _atomic_json(path: str, value: Any) -> None:
|
| 177 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 178 |
+
temporary = "%s.write.%s.tmp" % (path, uuid.uuid4().hex)
|
| 179 |
+
try:
|
| 180 |
+
with open(temporary, "w", encoding="utf-8") as handle:
|
| 181 |
+
json.dump(value, handle, ensure_ascii=False, indent=2)
|
| 182 |
+
handle.write("\n")
|
| 183 |
+
handle.flush()
|
| 184 |
+
os.fsync(handle.fileno())
|
| 185 |
+
os.replace(temporary, path)
|
| 186 |
+
finally:
|
| 187 |
+
try:
|
| 188 |
+
os.unlink(temporary)
|
| 189 |
+
except FileNotFoundError:
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
@staticmethod
|
| 193 |
+
def _integer(value: Any, fallback: int = 0) -> int:
|
| 194 |
+
try:
|
| 195 |
+
return int(value)
|
| 196 |
+
except (TypeError, ValueError):
|
| 197 |
+
return int(fallback)
|
| 198 |
+
|
| 199 |
+
@staticmethod
|
| 200 |
+
def _created_at(metadata: dict[str, Any], path: str) -> str:
|
| 201 |
+
stored = str(metadata.get("created_at") or
|
| 202 |
+
metadata.get("segment", {}).get("created_at") or "")
|
| 203 |
+
if stored:
|
| 204 |
+
return stored
|
| 205 |
+
return datetime.fromtimestamp(
|
| 206 |
+
os.path.getmtime(path), timezone.utc).isoformat(
|
| 207 |
+
timespec="seconds").replace("+00:00", "Z")
|
| 208 |
+
|
| 209 |
+
@classmethod
|
| 210 |
+
def _chapter_starts(cls, run_dir: str) -> set[int]:
|
| 211 |
+
"""Read editorial branch roots without making notes executable state."""
|
| 212 |
+
path = os.path.join(run_dir, "editorial.json")
|
| 213 |
+
try:
|
| 214 |
+
document = cls._read_json(path)
|
| 215 |
+
chapters = document.get("chapters")
|
| 216 |
+
if not isinstance(chapters, list):
|
| 217 |
+
return set()
|
| 218 |
+
return {
|
| 219 |
+
int(chapter.get("start_scene", 0))
|
| 220 |
+
for chapter in chapters if isinstance(chapter, dict)
|
| 221 |
+
and int(chapter.get("start_scene", 0)) > 0
|
| 222 |
+
}
|
| 223 |
+
except (OSError, TypeError, ValueError, json.JSONDecodeError,
|
| 224 |
+
AttributeError):
|
| 225 |
+
return set()
|
| 226 |
+
|
| 227 |
+
def _effective_context(
|
| 228 |
+
self, scene: int, segment: dict[str, Any],
|
| 229 |
+
compatibility: dict[str, Any]) -> tuple[str, int, int]:
|
| 230 |
+
"""Return context that actually depended on the preceding checkpoint."""
|
| 231 |
+
continuation = str(
|
| 232 |
+
segment.get("continuation_mode") or
|
| 233 |
+
compatibility.get("continuation_mode") or "guide")
|
| 234 |
+
context = self._integer(
|
| 235 |
+
segment.get("resolved_context_length",
|
| 236 |
+
segment.get("context_length",
|
| 237 |
+
compatibility.get("context_length", 0))))
|
| 238 |
+
has_resolved_audio = "resolved_audio_context_length" in segment
|
| 239 |
+
if has_resolved_audio:
|
| 240 |
+
audio_context = self._integer(
|
| 241 |
+
segment.get("resolved_audio_context_length"))
|
| 242 |
+
elif "audio_context_length" in segment:
|
| 243 |
+
audio_context = self._integer(segment.get("audio_context_length"))
|
| 244 |
+
elif "audio_context_length" in compatibility:
|
| 245 |
+
audio_context = self._integer(
|
| 246 |
+
compatibility.get("audio_context_length"))
|
| 247 |
+
else:
|
| 248 |
+
audio_context = context
|
| 249 |
+
if scene <= 1:
|
| 250 |
+
return continuation, 0, 0
|
| 251 |
+
audio_mode = str(compatibility.get("audio_mode") or "source_track")
|
| 252 |
+
if (not has_resolved_audio and continuation != "masked_av" and
|
| 253 |
+
audio_mode not in (
|
| 254 |
+
"generated_audio", "source_plus_timeline")):
|
| 255 |
+
audio_context = 0
|
| 256 |
+
audio_context = checkpoint_audio_context_length(
|
| 257 |
+
continuation, context, audio_context,
|
| 258 |
+
str(segment.get("generated_continuity") or "").lower(),
|
| 259 |
+
str(segment.get("source_audio_target") or "").lower())
|
| 260 |
+
return continuation, max(0, context), max(0, audio_context)
|
| 261 |
+
|
| 262 |
+
def _adopt_legacy_active_revisions(
|
| 263 |
+
self, checkpoint_dir: str, run: str) -> int:
|
| 264 |
+
"""Create the small revision sidecars omitted by pre-manager saves.
|
| 265 |
+
|
| 266 |
+
Existing video, prompt, audio, and safetensors artifacts remain in
|
| 267 |
+
place. The canonical ``clip_NNNN.json`` pointer is also left byte-for-
|
| 268 |
+
byte unchanged so adopting an old run is non-destructive.
|
| 269 |
+
"""
|
| 270 |
+
if not os.path.isdir(checkpoint_dir):
|
| 271 |
+
return 0
|
| 272 |
+
active: dict[int, tuple[str, dict[str, Any], str]] = {}
|
| 273 |
+
for filename in sorted(os.listdir(checkpoint_dir)):
|
| 274 |
+
match = _ACTIVE.fullmatch(filename)
|
| 275 |
+
if match is None:
|
| 276 |
+
continue
|
| 277 |
+
path = os.path.realpath(os.path.join(checkpoint_dir, filename))
|
| 278 |
+
try:
|
| 279 |
+
metadata = self._read_json(path)
|
| 280 |
+
segment = metadata.get("segment")
|
| 281 |
+
scene = int(match.group(1))
|
| 282 |
+
if (not isinstance(segment, dict) or
|
| 283 |
+
int(segment.get("index", -1)) != scene or
|
| 284 |
+
_safe_name(metadata.get("run_name") or run) != run):
|
| 285 |
+
continue
|
| 286 |
+
revision = checkpoint_revision_token(scene, segment)
|
| 287 |
+
if revision:
|
| 288 |
+
active[scene] = (revision, metadata, path)
|
| 289 |
+
except (OSError, TypeError, ValueError, json.JSONDecodeError,
|
| 290 |
+
AttributeError):
|
| 291 |
+
continue
|
| 292 |
+
|
| 293 |
+
adopted = 0
|
| 294 |
+
for scene in sorted(active):
|
| 295 |
+
revision, metadata, pointer_path = active[scene]
|
| 296 |
+
sidecar = os.path.realpath(os.path.join(
|
| 297 |
+
checkpoint_dir, "clip_%04d.%s.json" % (scene, revision)))
|
| 298 |
+
if os.path.isfile(sidecar):
|
| 299 |
+
continue
|
| 300 |
+
segment = dict(metadata["segment"])
|
| 301 |
+
segment.update({
|
| 302 |
+
"revision": revision,
|
| 303 |
+
"revision_metadata": os.path.relpath(sidecar, self.output_root),
|
| 304 |
+
})
|
| 305 |
+
predecessor = active.get(scene - 1)
|
| 306 |
+
if predecessor is not None and not segment.get(
|
| 307 |
+
"predecessor_revision"):
|
| 308 |
+
predecessor_revision, predecessor_metadata, _pointer = predecessor
|
| 309 |
+
predecessor_segment = predecessor_metadata.get("segment")
|
| 310 |
+
segment["predecessor_revision"] = predecessor_revision
|
| 311 |
+
if isinstance(predecessor_segment, dict):
|
| 312 |
+
predecessor_hash = str(
|
| 313 |
+
predecessor_segment.get("checkpoint_sha256") or "")
|
| 314 |
+
if predecessor_hash:
|
| 315 |
+
segment["predecessor_checkpoint_sha256"] = predecessor_hash
|
| 316 |
+
segment.setdefault(
|
| 317 |
+
"branch_id", str(predecessor_segment.get("branch_id") or
|
| 318 |
+
predecessor_revision))
|
| 319 |
+
snapshot = dict(metadata)
|
| 320 |
+
snapshot["segment"] = segment
|
| 321 |
+
snapshot.setdefault(
|
| 322 |
+
"created_at", self._created_at(metadata, pointer_path))
|
| 323 |
+
snapshot["legacy_adoption"] = {
|
| 324 |
+
"version": 1,
|
| 325 |
+
"source": "active_pointer",
|
| 326 |
+
"source_metadata": os.path.relpath(
|
| 327 |
+
pointer_path, self.output_root),
|
| 328 |
+
}
|
| 329 |
+
self._atomic_json(sidecar, snapshot)
|
| 330 |
+
adopted += 1
|
| 331 |
+
if adopted:
|
| 332 |
+
_LOG.info(
|
| 333 |
+
"H3 Checkpoint Manager adopted %d legacy active revision%s for "
|
| 334 |
+
"run %s without copying media or latent files.",
|
| 335 |
+
adopted, "" if adopted == 1 else "s", run)
|
| 336 |
+
return adopted
|
| 337 |
+
|
| 338 |
+
@classmethod
|
| 339 |
+
def select_active_lineage(
|
| 340 |
+
cls, segments: dict[int, dict[str, Any]], chapter_starts=()
|
| 341 |
+
) -> tuple[dict[int, dict[str, Any]], dict[int, str]]:
|
| 342 |
+
"""Select coherent chapter prefixes, without modifying saved pointers.
|
| 343 |
+
|
| 344 |
+
A single-scene rerender can leave canonical pointers belonging to the
|
| 345 |
+
old branch. Their files remain recoverable, but they are not selected
|
| 346 |
+
until that lineage is explicitly activated or attributed again.
|
| 347 |
+
"""
|
| 348 |
+
active, stale = {}, {}
|
| 349 |
+
roots = {1, *chapter_starts}
|
| 350 |
+
for scene, segment in sorted(segments.items()):
|
| 351 |
+
reason = ""
|
| 352 |
+
if scene not in roots:
|
| 353 |
+
parent = active.get(scene - 1)
|
| 354 |
+
if parent is None:
|
| 355 |
+
reason = "The preceding scene is not on the active chapter branch."
|
| 356 |
+
else:
|
| 357 |
+
expected = str(
|
| 358 |
+
segment.get("predecessor_revision") or "").lower()
|
| 359 |
+
digest = str(
|
| 360 |
+
segment.get("predecessor_checkpoint_sha256") or "")
|
| 361 |
+
if expected and expected != checkpoint_revision_token(
|
| 362 |
+
scene - 1, parent):
|
| 363 |
+
reason = "This take belongs to a different predecessor revision."
|
| 364 |
+
elif digest and digest != str(
|
| 365 |
+
parent.get("checkpoint_sha256") or ""):
|
| 366 |
+
reason = "This take belongs to a different predecessor checkpoint."
|
| 367 |
+
if reason:
|
| 368 |
+
stale[scene] = reason
|
| 369 |
+
else:
|
| 370 |
+
active[scene] = segment
|
| 371 |
+
return active, stale
|
| 372 |
+
|
| 373 |
+
def active_selection(
|
| 374 |
+
self, run_name: Any) -> tuple[dict[int, str], dict[int, str]]:
|
| 375 |
+
"""Cheap, read-only selection shared by recovery and the UI."""
|
| 376 |
+
run_dir, _run = self._run_dir(run_name)
|
| 377 |
+
checkpoint_dir = os.path.join(run_dir, "checkpoints")
|
| 378 |
+
segments = {}
|
| 379 |
+
if os.path.isdir(checkpoint_dir):
|
| 380 |
+
for filename in os.listdir(checkpoint_dir):
|
| 381 |
+
match = _ACTIVE.fullmatch(filename)
|
| 382 |
+
if match is None:
|
| 383 |
+
continue
|
| 384 |
+
try:
|
| 385 |
+
segment = self._read_json(os.path.join(
|
| 386 |
+
checkpoint_dir, filename)).get("segment")
|
| 387 |
+
scene = int(match.group(1))
|
| 388 |
+
if (isinstance(segment, dict) and
|
| 389 |
+
int(segment.get("index", -1)) == scene):
|
| 390 |
+
segments[scene] = segment
|
| 391 |
+
except (OSError, TypeError, ValueError, AttributeError):
|
| 392 |
+
continue
|
| 393 |
+
active, stale = self.select_active_lineage(
|
| 394 |
+
segments, self._chapter_starts(run_dir))
|
| 395 |
+
return {scene: checkpoint_revision_token(scene, segment)
|
| 396 |
+
for scene, segment in active.items()}, stale
|
| 397 |
+
|
| 398 |
+
def _active_revisions(self, checkpoint_dir: str) -> dict[int, str]:
|
| 399 |
+
active: dict[int, str] = {}
|
| 400 |
+
if not os.path.isdir(checkpoint_dir):
|
| 401 |
+
return active
|
| 402 |
+
for filename in os.listdir(checkpoint_dir):
|
| 403 |
+
match = _ACTIVE.fullmatch(filename)
|
| 404 |
+
if match is None:
|
| 405 |
+
continue
|
| 406 |
+
try:
|
| 407 |
+
metadata = self._read_json(os.path.join(checkpoint_dir, filename))
|
| 408 |
+
segment = metadata.get("segment")
|
| 409 |
+
if not isinstance(segment, dict):
|
| 410 |
+
continue
|
| 411 |
+
scene = int(match.group(1))
|
| 412 |
+
if int(segment.get("index", -1)) != scene:
|
| 413 |
+
continue
|
| 414 |
+
revision = checkpoint_revision_token(scene, segment)
|
| 415 |
+
if revision:
|
| 416 |
+
active[scene] = revision
|
| 417 |
+
except (OSError, TypeError, ValueError, json.JSONDecodeError,
|
| 418 |
+
AttributeError):
|
| 419 |
+
continue
|
| 420 |
+
return active
|
| 421 |
+
|
| 422 |
+
def _recorded_dependency_keys(
|
| 423 |
+
self, record: dict[str, Any],
|
| 424 |
+
parent_key: tuple[int, str] | None,
|
| 425 |
+
records: dict[tuple[int, str], dict[str, Any]],
|
| 426 |
+
by_hash: dict[tuple[int, str], list[tuple[int, str]]]
|
| 427 |
+
) -> list[tuple[int, str]]:
|
| 428 |
+
"""Resolve saved visual and generated-audio dependencies exactly."""
|
| 429 |
+
segment = record["_segment"]
|
| 430 |
+
scene = int(record["scene"])
|
| 431 |
+
found: list[tuple[int, str]] = []
|
| 432 |
+
|
| 433 |
+
def add(scene_value: Any, revision_value: Any,
|
| 434 |
+
hash_value: Any) -> None:
|
| 435 |
+
try:
|
| 436 |
+
source_scene = int(scene_value)
|
| 437 |
+
except (TypeError, ValueError):
|
| 438 |
+
return
|
| 439 |
+
revision = str(revision_value or "").lower()
|
| 440 |
+
key = ((source_scene, revision)
|
| 441 |
+
if re.fullmatch(r"[0-9a-f]{32}", revision) else None)
|
| 442 |
+
if key not in records:
|
| 443 |
+
digest = str(hash_value or "")
|
| 444 |
+
candidates = by_hash.get((source_scene, digest), [])
|
| 445 |
+
key = candidates[0] if digest and len(candidates) == 1 else None
|
| 446 |
+
if key in records and key not in found:
|
| 447 |
+
found.append(key)
|
| 448 |
+
|
| 449 |
+
visual_frames = max(0, self._integer(record.get("context_length")))
|
| 450 |
+
if visual_frames:
|
| 451 |
+
visual_blocks = segment.get("visual_context_blocks")
|
| 452 |
+
if isinstance(visual_blocks, list):
|
| 453 |
+
for block in visual_blocks:
|
| 454 |
+
if not isinstance(block, dict):
|
| 455 |
+
continue
|
| 456 |
+
add(block.get("source_scene"),
|
| 457 |
+
block.get("source_revision"),
|
| 458 |
+
block.get("source_checkpoint_sha256"))
|
| 459 |
+
else:
|
| 460 |
+
source_scene = self._integer(
|
| 461 |
+
segment.get("visual_context_source_scene"), scene - 1)
|
| 462 |
+
if source_scene == scene - 1 and not segment.get(
|
| 463 |
+
"visual_context_source_revision"):
|
| 464 |
+
if parent_key in records:
|
| 465 |
+
found.append(parent_key)
|
| 466 |
+
else:
|
| 467 |
+
add(source_scene,
|
| 468 |
+
segment.get("visual_context_source_revision"),
|
| 469 |
+
segment.get(
|
| 470 |
+
"visual_context_source_checkpoint_sha256"))
|
| 471 |
+
lead_frames = max(0, self._integer(
|
| 472 |
+
segment.get("visual_context_lead_frames")))
|
| 473 |
+
if lead_frames:
|
| 474 |
+
add(segment.get("visual_context_lead_source_scene"),
|
| 475 |
+
segment.get("visual_context_lead_source_revision"),
|
| 476 |
+
segment.get(
|
| 477 |
+
"visual_context_lead_checkpoint_sha256"))
|
| 478 |
+
|
| 479 |
+
dependency = record["_metadata"].get("scene_dependency")
|
| 480 |
+
scopes = dependency.get("scopes") if isinstance(dependency, dict) else None
|
| 481 |
+
incoming = (scopes.get("incoming_boundary")
|
| 482 |
+
if isinstance(scopes, dict) else None)
|
| 483 |
+
generated = str(
|
| 484 |
+
segment.get("generated_continuity",
|
| 485 |
+
incoming.get("generated_continuity")
|
| 486 |
+
if isinstance(incoming, dict) else "off") or "off").lower()
|
| 487 |
+
audio_frames = max(0, self._integer(
|
| 488 |
+
segment.get("resolved_audio_context_length",
|
| 489 |
+
incoming.get("audio_context_length"))
|
| 490 |
+
if isinstance(incoming, dict) else
|
| 491 |
+
record.get("audio_context_length")))
|
| 492 |
+
audio_frames = checkpoint_audio_context_length(
|
| 493 |
+
str(record.get("continuation_mode") or ""), visual_frames,
|
| 494 |
+
audio_frames, generated,
|
| 495 |
+
str(segment.get("source_audio_target") or "").lower())
|
| 496 |
+
if generated == "on" and audio_frames and parent_key in records:
|
| 497 |
+
if parent_key not in found:
|
| 498 |
+
found.append(parent_key)
|
| 499 |
+
return found
|
| 500 |
+
|
| 501 |
+
def _scan(self, run_name: Any, *, adopt_legacy: bool = True) -> dict[str, Any]:
|
| 502 |
+
run_dir, run = self._run_dir(run_name)
|
| 503 |
+
checkpoint_dir = os.path.join(run_dir, "checkpoints")
|
| 504 |
+
review_dir = os.path.join(run_dir, "reviews")
|
| 505 |
+
chapter_starts = self._chapter_starts(run_dir)
|
| 506 |
+
if adopt_legacy:
|
| 507 |
+
self._adopt_legacy_active_revisions(checkpoint_dir, run)
|
| 508 |
+
active = self._active_revisions(checkpoint_dir)
|
| 509 |
+
selected, stale = self.active_selection(run)
|
| 510 |
+
records: dict[tuple[int, str], dict[str, Any]] = {}
|
| 511 |
+
if os.path.isdir(checkpoint_dir):
|
| 512 |
+
for filename in sorted(os.listdir(checkpoint_dir)):
|
| 513 |
+
match = _REVISION.fullmatch(filename)
|
| 514 |
+
if match is None:
|
| 515 |
+
continue
|
| 516 |
+
metadata_path = os.path.realpath(os.path.join(
|
| 517 |
+
checkpoint_dir, filename))
|
| 518 |
+
try:
|
| 519 |
+
metadata = self._read_json(metadata_path)
|
| 520 |
+
segment = metadata.get("segment")
|
| 521 |
+
if not isinstance(segment, dict):
|
| 522 |
+
continue
|
| 523 |
+
scene = int(segment.get("index", int(match.group(1))))
|
| 524 |
+
revision = str(
|
| 525 |
+
segment.get("revision") or match.group(2)).lower()
|
| 526 |
+
stored_run = _safe_name(metadata.get("run_name") or run)
|
| 527 |
+
if (scene != int(match.group(1)) or
|
| 528 |
+
revision != match.group(2) or stored_run != run):
|
| 529 |
+
continue
|
| 530 |
+
compatibility = metadata.get("compatibility")
|
| 531 |
+
if not isinstance(compatibility, dict):
|
| 532 |
+
compatibility = {}
|
| 533 |
+
adoption = metadata.get("adoption")
|
| 534 |
+
if not isinstance(adoption, dict):
|
| 535 |
+
adoption = {}
|
| 536 |
+
continuation, context, audio_context = (
|
| 537 |
+
self._effective_context(scene, segment, compatibility))
|
| 538 |
+
segment_path = self._artifact_path(segment.get("segment"))
|
| 539 |
+
checkpoint_path = self._artifact_path(
|
| 540 |
+
segment.get("checkpoint"))
|
| 541 |
+
record = {
|
| 542 |
+
"scene": scene,
|
| 543 |
+
"scene_id": str(
|
| 544 |
+
segment.get("id") or "clip_%04d" % scene),
|
| 545 |
+
"revision": revision,
|
| 546 |
+
"active": selected.get(scene) == revision,
|
| 547 |
+
"pointer_active": active.get(scene) == revision,
|
| 548 |
+
"inactive_reason": (
|
| 549 |
+
stale.get(scene, "")
|
| 550 |
+
if active.get(scene) == revision else ""),
|
| 551 |
+
"ready": (os.path.isfile(segment_path) and
|
| 552 |
+
os.path.isfile(checkpoint_path)),
|
| 553 |
+
"raw_frames": self._integer(
|
| 554 |
+
segment.get("raw_frames")),
|
| 555 |
+
"delivered_frames": self._integer(
|
| 556 |
+
segment.get("delivered_frames")),
|
| 557 |
+
"seed": str(segment.get("seed") or ""),
|
| 558 |
+
"steps": self._integer(segment.get("steps")),
|
| 559 |
+
"created_at": self._created_at(metadata, metadata_path),
|
| 560 |
+
"branch_id": str(segment.get("branch_id") or ""),
|
| 561 |
+
"forked_from_branch_id": str(
|
| 562 |
+
segment.get("forked_from_branch_id") or ""),
|
| 563 |
+
"adopted_from_revision": str(
|
| 564 |
+
segment.get("adopted_from_revision") or
|
| 565 |
+
adoption.get("source_revision") or "").lower(),
|
| 566 |
+
"predecessor_revision": str(
|
| 567 |
+
segment.get("predecessor_revision") or "").lower(),
|
| 568 |
+
"predecessor_checkpoint_sha256": str(
|
| 569 |
+
segment.get("predecessor_checkpoint_sha256") or ""),
|
| 570 |
+
"checkpoint_sha256": str(
|
| 571 |
+
segment.get("checkpoint_sha256") or ""),
|
| 572 |
+
"segment_sha256": str(
|
| 573 |
+
segment.get("segment_sha256") or ""),
|
| 574 |
+
"continuation_mode": continuation,
|
| 575 |
+
"context_length": context,
|
| 576 |
+
"audio_context_length": audio_context,
|
| 577 |
+
"compatibility": {
|
| 578 |
+
key: compatibility[key] for key in (
|
| 579 |
+
"width", "height", "fps", "audio_mode",
|
| 580 |
+
"generation_fingerprint", "encode_mode",
|
| 581 |
+
"anchor_mode", "crop")
|
| 582 |
+
if key in compatibility
|
| 583 |
+
},
|
| 584 |
+
"prompt_preview": re.sub(
|
| 585 |
+
r"\s+", " ", str(
|
| 586 |
+
segment.get("scene_prompt") or
|
| 587 |
+
segment.get("prompt") or "").strip())[:240],
|
| 588 |
+
"prompt": str(segment.get("prompt") or
|
| 589 |
+
segment.get("scene_prompt") or ""),
|
| 590 |
+
"take_kind": str(segment.get("take_kind") or
|
| 591 |
+
"generation"),
|
| 592 |
+
"alternate_of_revision": str(
|
| 593 |
+
segment.get("alternate_of_revision") or "").lower(),
|
| 594 |
+
"alternate_media_mode": str(
|
| 595 |
+
segment.get("alternate_media_mode") or ""),
|
| 596 |
+
"_metadata": metadata,
|
| 597 |
+
"_metadata_path": metadata_path,
|
| 598 |
+
"_segment": segment,
|
| 599 |
+
"_segment_path": segment_path,
|
| 600 |
+
"_checkpoint_path": checkpoint_path,
|
| 601 |
+
"_chapter_root": scene in chapter_starts,
|
| 602 |
+
"_parent": None,
|
| 603 |
+
"_children": [],
|
| 604 |
+
"_dependencies": [],
|
| 605 |
+
"_dependents": [],
|
| 606 |
+
"_lineage_issue": "",
|
| 607 |
+
}
|
| 608 |
+
records[(scene, revision)] = record
|
| 609 |
+
except (OSError, TypeError, ValueError, json.JSONDecodeError,
|
| 610 |
+
AttributeError):
|
| 611 |
+
continue
|
| 612 |
+
|
| 613 |
+
by_hash: dict[tuple[int, str], list[tuple[int, str]]] = defaultdict(list)
|
| 614 |
+
for key, record in records.items():
|
| 615 |
+
digest = record["checkpoint_sha256"]
|
| 616 |
+
if digest:
|
| 617 |
+
by_hash[(record["scene"], digest)].append(key)
|
| 618 |
+
for key, record in records.items():
|
| 619 |
+
scene = record["scene"]
|
| 620 |
+
if scene <= 1:
|
| 621 |
+
continue
|
| 622 |
+
revision = record["predecessor_revision"]
|
| 623 |
+
digest = record["predecessor_checkpoint_sha256"]
|
| 624 |
+
parent_key = (scene - 1, revision) if revision else None
|
| 625 |
+
parent = records.get(parent_key) if parent_key else None
|
| 626 |
+
if parent is None and digest:
|
| 627 |
+
candidates = by_hash.get((scene - 1, digest), [])
|
| 628 |
+
if len(candidates) == 1:
|
| 629 |
+
parent_key = candidates[0]
|
| 630 |
+
parent = records[parent_key]
|
| 631 |
+
if parent is None:
|
| 632 |
+
dependencies = self._recorded_dependency_keys(
|
| 633 |
+
record, None, records, by_hash)
|
| 634 |
+
for dependency_key in dependencies:
|
| 635 |
+
record["_dependencies"].append(dependency_key)
|
| 636 |
+
records[dependency_key]["_dependents"].append(key)
|
| 637 |
+
if not record["_chapter_root"]:
|
| 638 |
+
record["_lineage_issue"] = "missing predecessor"
|
| 639 |
+
continue
|
| 640 |
+
if digest and parent["checkpoint_sha256"] != digest:
|
| 641 |
+
record["_lineage_issue"] = "predecessor hash mismatch"
|
| 642 |
+
# Keep the declared revision edge for conservative cleanup:
|
| 643 |
+
# corrupted lineage must never make its parent look deletable.
|
| 644 |
+
# A chapter start is a branch root even though immutable metadata
|
| 645 |
+
# keeps its original predecessor as provenance. Preserve a
|
| 646 |
+
# deletion dependency only when that scene actually consumed
|
| 647 |
+
# predecessor picture/generated-audio context.
|
| 648 |
+
dependencies = self._recorded_dependency_keys(
|
| 649 |
+
record, parent_key, records, by_hash)
|
| 650 |
+
if not record["_chapter_root"]:
|
| 651 |
+
record["_parent"] = parent_key
|
| 652 |
+
parent["_children"].append(key)
|
| 653 |
+
if parent_key not in dependencies:
|
| 654 |
+
dependencies.append(parent_key)
|
| 655 |
+
for dependency_key in dependencies:
|
| 656 |
+
record["_dependencies"].append(dependency_key)
|
| 657 |
+
records[dependency_key]["_dependents"].append(key)
|
| 658 |
+
|
| 659 |
+
# An editorial alternate is not a lineage child, but its immutable
|
| 660 |
+
# files are meaningful only while the same-scene base revision still
|
| 661 |
+
# exists. Model that as a cleanup dependency so Checkpoint Manager
|
| 662 |
+
# requires deleting unused alternates before their base take.
|
| 663 |
+
for key, record in records.items():
|
| 664 |
+
if record.get("take_kind") != "editorial_alternate":
|
| 665 |
+
continue
|
| 666 |
+
base_key = (record["scene"], record["alternate_of_revision"])
|
| 667 |
+
base = records.get(base_key)
|
| 668 |
+
if base is None:
|
| 669 |
+
if not record["_lineage_issue"]:
|
| 670 |
+
record["_lineage_issue"] = "missing alternate base"
|
| 671 |
+
continue
|
| 672 |
+
if base_key not in record["_dependencies"]:
|
| 673 |
+
record["_dependencies"].append(base_key)
|
| 674 |
+
if key not in base["_dependents"]:
|
| 675 |
+
base["_dependents"].append(key)
|
| 676 |
+
|
| 677 |
+
try:
|
| 678 |
+
review_names = os.listdir(review_dir) if os.path.isdir(
|
| 679 |
+
review_dir) else []
|
| 680 |
+
except OSError:
|
| 681 |
+
review_names = []
|
| 682 |
+
segment_hash_counts: dict[str, int] = defaultdict(int)
|
| 683 |
+
artifact_path_counts: dict[str, int] = defaultdict(int)
|
| 684 |
+
for record in records.values():
|
| 685 |
+
if record["segment_sha256"]:
|
| 686 |
+
segment_hash_counts[record["segment_sha256"]] += 1
|
| 687 |
+
for artifact_key in _ARTIFACT_KEYS:
|
| 688 |
+
artifact_value = record["_segment"].get(artifact_key)
|
| 689 |
+
if not isinstance(artifact_value, str) or not artifact_value:
|
| 690 |
+
continue
|
| 691 |
+
try:
|
| 692 |
+
artifact_path_counts[self._artifact_path(artifact_value)] += 1
|
| 693 |
+
except ValueError:
|
| 694 |
+
continue
|
| 695 |
+
return {
|
| 696 |
+
"run_dir": run_dir,
|
| 697 |
+
"run_name": run,
|
| 698 |
+
"checkpoint_dir": checkpoint_dir,
|
| 699 |
+
"review_dir": review_dir,
|
| 700 |
+
"review_names": review_names,
|
| 701 |
+
"chapter_starts": chapter_starts,
|
| 702 |
+
"records": records,
|
| 703 |
+
"segment_hash_counts": segment_hash_counts,
|
| 704 |
+
"artifact_path_counts": artifact_path_counts,
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
@staticmethod
|
| 708 |
+
def _attribution_source(record: dict[str, Any]) -> str:
|
| 709 |
+
return str(record.get("adopted_from_revision") or
|
| 710 |
+
record.get("revision") or "").lower()
|
| 711 |
+
|
| 712 |
+
def _attributable_without_predecessor(
|
| 713 |
+
self, record: dict[str, Any]) -> bool:
|
| 714 |
+
"""Return whether a saved scene can safely receive another parent."""
|
| 715 |
+
dependency = record["_metadata"].get("scene_dependency")
|
| 716 |
+
if not isinstance(dependency, dict):
|
| 717 |
+
dependency = record["_segment"].get("scene_dependency")
|
| 718 |
+
scopes = dependency.get("scopes") if isinstance(dependency, dict) else None
|
| 719 |
+
incoming = (scopes.get("incoming_boundary")
|
| 720 |
+
if isinstance(scopes, dict) else None)
|
| 721 |
+
segment = record["_segment"]
|
| 722 |
+
incoming = incoming if isinstance(incoming, dict) else {}
|
| 723 |
+
# Resolved values describe the saved take; old scope snapshots can
|
| 724 |
+
# still contain inherited defaults. Never consult today's edited Plan.
|
| 725 |
+
visual = segment.get("resolved_context_length", incoming.get(
|
| 726 |
+
"context_length", record.get("context_length")))
|
| 727 |
+
audio = segment.get("resolved_audio_context_length", incoming.get(
|
| 728 |
+
"audio_context_length", record.get("audio_context_length")))
|
| 729 |
+
generated = str(segment.get("generated_continuity", incoming.get(
|
| 730 |
+
"generated_continuity", ""))).lower()
|
| 731 |
+
try:
|
| 732 |
+
visual, audio = int(visual), int(audio)
|
| 733 |
+
except (TypeError, ValueError):
|
| 734 |
+
return False
|
| 735 |
+
if visual < 0 or audio < 0:
|
| 736 |
+
return False
|
| 737 |
+
compatibility = record["_metadata"].get("compatibility")
|
| 738 |
+
compatibility = compatibility if isinstance(compatibility, dict) else {}
|
| 739 |
+
continuation = str(segment.get("continuation_mode") or
|
| 740 |
+
incoming.get("continuation_mode") or
|
| 741 |
+
compatibility.get("continuation_mode") or
|
| 742 |
+
record.get("continuation_mode") or "")
|
| 743 |
+
audio = checkpoint_audio_context_length(
|
| 744 |
+
continuation, visual, audio, generated,
|
| 745 |
+
str(segment.get("source_audio_target") or "").lower())
|
| 746 |
+
# Unknown legacy audio policy must not silently mean 'off'.
|
| 747 |
+
return visual == 0 and (audio == 0 or (audio > 0 and generated == "off"))
|
| 748 |
+
|
| 749 |
+
def _artifacts(self, scan: dict[str, Any], record: dict[str, Any]
|
| 750 |
+
) -> list[dict[str, Any]]:
|
| 751 |
+
run_dir = scan["run_dir"]
|
| 752 |
+
allowed_roots = [os.path.realpath(os.path.join(run_dir, name)) for name in (
|
| 753 |
+
"segments", "checkpoints", "generated_audio", "blend_segments",
|
| 754 |
+
"reviews")]
|
| 755 |
+
paths: dict[str, tuple[str, bool]] = {
|
| 756 |
+
record["_metadata_path"]: ("revision_metadata", False),
|
| 757 |
+
}
|
| 758 |
+
for key in _ARTIFACT_KEYS:
|
| 759 |
+
value = record["_segment"].get(key)
|
| 760 |
+
if not isinstance(value, str) or not value:
|
| 761 |
+
continue
|
| 762 |
+
path = self._artifact_path(value)
|
| 763 |
+
paths.setdefault(path, (key, False))
|
| 764 |
+
video_hash = record["segment_sha256"][:12]
|
| 765 |
+
if video_hash and os.path.isdir(scan["review_dir"]):
|
| 766 |
+
prefix = "clip_%04d.%s." % (record["scene"], video_hash)
|
| 767 |
+
shared = scan["segment_hash_counts"].get(
|
| 768 |
+
record["segment_sha256"], 0) > 1
|
| 769 |
+
for candidate in scan["review_names"]:
|
| 770 |
+
if candidate.startswith(prefix) and candidate.endswith(
|
| 771 |
+
".review.mp4"):
|
| 772 |
+
path = os.path.realpath(os.path.join(
|
| 773 |
+
scan["review_dir"], candidate))
|
| 774 |
+
paths[path] = ("review_preview", shared)
|
| 775 |
+
|
| 776 |
+
expected_prefix = "clip_%04d.%s" % (
|
| 777 |
+
record["scene"], record["revision"])
|
| 778 |
+
canonical = os.path.realpath(os.path.join(
|
| 779 |
+
scan["checkpoint_dir"], "clip_%04d.json" % record["scene"]))
|
| 780 |
+
artifacts = []
|
| 781 |
+
for path, (kind, shared) in sorted(paths.items()):
|
| 782 |
+
if path == canonical:
|
| 783 |
+
raise ValueError("Refusing to manage an active checkpoint pointer.")
|
| 784 |
+
if not any(self._inside(root, path) for root in allowed_roots):
|
| 785 |
+
raise ValueError("Checkpoint revision owns an unexpected path.")
|
| 786 |
+
path_references = scan["artifact_path_counts"].get(path, 0)
|
| 787 |
+
shared = bool(shared or path_references > 1)
|
| 788 |
+
adopted_prefix = "clip_%04d.%s" % (
|
| 789 |
+
record["scene"], record.get("adopted_from_revision") or "")
|
| 790 |
+
owns_named_path = os.path.basename(path).startswith(expected_prefix)
|
| 791 |
+
adopts_named_path = bool(
|
| 792 |
+
record.get("adopted_from_revision") and
|
| 793 |
+
os.path.basename(path).startswith(adopted_prefix))
|
| 794 |
+
if (not self._inside(scan["review_dir"], path) and
|
| 795 |
+
not owns_named_path and not adopts_named_path):
|
| 796 |
+
raise ValueError(
|
| 797 |
+
"Checkpoint revision references a file owned by another revision.")
|
| 798 |
+
try:
|
| 799 |
+
exists = os.path.isfile(path)
|
| 800 |
+
stat_result = os.stat(path) if exists else None
|
| 801 |
+
except OSError:
|
| 802 |
+
exists = False
|
| 803 |
+
stat_result = None
|
| 804 |
+
size = int(stat_result.st_size) if stat_result is not None else 0
|
| 805 |
+
mtime_ns = (int(stat_result.st_mtime_ns)
|
| 806 |
+
if stat_result is not None else 0)
|
| 807 |
+
artifacts.append({
|
| 808 |
+
"kind": kind,
|
| 809 |
+
"label": _ARTIFACT_KINDS.get(kind, kind.replace("_", " ").title()),
|
| 810 |
+
"path": os.path.relpath(path, self.output_root),
|
| 811 |
+
"exists": exists,
|
| 812 |
+
"size_bytes": size,
|
| 813 |
+
"shared": bool(shared),
|
| 814 |
+
"owned": not shared,
|
| 815 |
+
"_path": path,
|
| 816 |
+
"_mtime_ns": mtime_ns,
|
| 817 |
+
})
|
| 818 |
+
return artifacts
|
| 819 |
+
|
| 820 |
+
def _attribution_slot(
|
| 821 |
+
self, records: dict[tuple[int, str], dict[str, Any]],
|
| 822 |
+
leaf_key: tuple[int, str]) -> dict[str, Any] | None:
|
| 823 |
+
leaf = records[leaf_key]
|
| 824 |
+
scene = int(leaf["scene"]) + 1
|
| 825 |
+
if any(record["scene"] == scene and record["_chapter_root"]
|
| 826 |
+
for record in records.values()):
|
| 827 |
+
return None
|
| 828 |
+
already_attached = {
|
| 829 |
+
self._attribution_source(records[key])
|
| 830 |
+
for key in leaf["_children"] if key in records
|
| 831 |
+
}
|
| 832 |
+
candidates: dict[str, dict[str, Any]] = {}
|
| 833 |
+
blocked = []
|
| 834 |
+
for key, candidate in records.items():
|
| 835 |
+
if key[0] != scene or key == leaf_key or not candidate["ready"]:
|
| 836 |
+
continue
|
| 837 |
+
if candidate.get("take_kind") == "editorial_alternate":
|
| 838 |
+
continue
|
| 839 |
+
if candidate["_parent"] == leaf_key:
|
| 840 |
+
continue
|
| 841 |
+
if not self._attributable_without_predecessor(candidate):
|
| 842 |
+
blocked.append({
|
| 843 |
+
"scene": scene, "revision": candidate["revision"],
|
| 844 |
+
"reason": "Saved predecessor video/audio context is in use, "
|
| 845 |
+
"or the saved metadata cannot prove it was unused. "
|
| 846 |
+
"Changing the current Plan does not change a saved take.",
|
| 847 |
+
})
|
| 848 |
+
continue
|
| 849 |
+
source = self._attribution_source(candidate)
|
| 850 |
+
if not source or source in already_attached:
|
| 851 |
+
continue
|
| 852 |
+
previous = candidates.get(source)
|
| 853 |
+
if previous is None or candidate["revision"] == source:
|
| 854 |
+
candidates[source] = candidate
|
| 855 |
+
if not candidates and not blocked:
|
| 856 |
+
return None
|
| 857 |
+
ordered = sorted(candidates.values(), key=lambda item: (
|
| 858 |
+
str(item.get("created_at") or ""), item["revision"]), reverse=True)
|
| 859 |
+
return {
|
| 860 |
+
"scene": scene,
|
| 861 |
+
"parent_scene": leaf["scene"],
|
| 862 |
+
"parent_revision": leaf["revision"],
|
| 863 |
+
"blocked_candidates": blocked,
|
| 864 |
+
"candidates": [
|
| 865 |
+
{"scene": item["scene"], "revision": item["revision"]}
|
| 866 |
+
for item in ordered
|
| 867 |
+
],
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
def _active_pointer_artifact(
|
| 871 |
+
self, scan: dict[str, Any], record: dict[str, Any]
|
| 872 |
+
) -> dict[str, Any]:
|
| 873 |
+
"""Describe the mutable pointer removed by an active-tip rollback."""
|
| 874 |
+
path = os.path.realpath(os.path.join(
|
| 875 |
+
scan["checkpoint_dir"], "clip_%04d.json" % record["scene"]))
|
| 876 |
+
if not self._inside(scan["checkpoint_dir"], path):
|
| 877 |
+
raise ValueError("Active checkpoint pointer escapes its run directory.")
|
| 878 |
+
exists = os.path.isfile(path)
|
| 879 |
+
stat_result = os.stat(path) if exists else None
|
| 880 |
+
return {
|
| 881 |
+
"kind": "active_pointer",
|
| 882 |
+
"label": _ARTIFACT_KINDS["active_pointer"],
|
| 883 |
+
"path": os.path.relpath(path, self.output_root),
|
| 884 |
+
"exists": exists,
|
| 885 |
+
"size_bytes": int(stat_result.st_size) if stat_result else 0,
|
| 886 |
+
"shared": False,
|
| 887 |
+
"owned": True,
|
| 888 |
+
"_path": path,
|
| 889 |
+
"_mtime_ns": int(stat_result.st_mtime_ns) if stat_result else 0,
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
@staticmethod
|
| 893 |
+
def _descendant_keys(records: dict[tuple[int, str], dict[str, Any]],
|
| 894 |
+
start: tuple[int, str]) -> list[tuple[int, str]]:
|
| 895 |
+
found = []
|
| 896 |
+
queue = deque(records[start]["_dependents"])
|
| 897 |
+
seen = set()
|
| 898 |
+
while queue:
|
| 899 |
+
key = queue.popleft()
|
| 900 |
+
if key in seen or key not in records:
|
| 901 |
+
continue
|
| 902 |
+
seen.add(key)
|
| 903 |
+
found.append(key)
|
| 904 |
+
queue.extend(records[key]["_dependents"])
|
| 905 |
+
return sorted(found, key=lambda item: (item[0], item[1]))
|
| 906 |
+
|
| 907 |
+
@staticmethod
|
| 908 |
+
def _chapter_bounds(scan: dict[str, Any], scene: int) -> tuple[int, int]:
|
| 909 |
+
records = scan["records"]
|
| 910 |
+
maximum = max((int(item[0]) for item in records), default=int(scene))
|
| 911 |
+
starts = sorted({1, *(
|
| 912 |
+
int(item) for item in scan.get("chapter_starts", set())
|
| 913 |
+
if int(item) > 0
|
| 914 |
+
)})
|
| 915 |
+
start = max((item for item in starts if item <= scene), default=1)
|
| 916 |
+
following = next((item for item in starts if item > scene), None)
|
| 917 |
+
return start, (following - 1 if following is not None else maximum)
|
| 918 |
+
|
| 919 |
+
def _public_graph(self, scan: dict[str, Any]) -> dict[str, Any]:
|
| 920 |
+
records = scan["records"]
|
| 921 |
+
lineage_keys = {
|
| 922 |
+
key for key, record in records.items()
|
| 923 |
+
if record.get("take_kind") != "editorial_alternate"
|
| 924 |
+
}
|
| 925 |
+
leaves = [
|
| 926 |
+
key for key in lineage_keys
|
| 927 |
+
if not any(child in lineage_keys
|
| 928 |
+
for child in records[key]["_children"])
|
| 929 |
+
or (records[key]["active"] and not any(
|
| 930 |
+
child in lineage_keys and records[child]["active"]
|
| 931 |
+
for child in records[key]["_children"]))
|
| 932 |
+
]
|
| 933 |
+
branch_paths = []
|
| 934 |
+
memberships: dict[tuple[int, str], list[dict[str, Any]]] = defaultdict(list)
|
| 935 |
+
for leaf_key in leaves:
|
| 936 |
+
path = []
|
| 937 |
+
cursor = leaf_key
|
| 938 |
+
seen = set()
|
| 939 |
+
while cursor in records and cursor not in seen:
|
| 940 |
+
seen.add(cursor)
|
| 941 |
+
if cursor in lineage_keys:
|
| 942 |
+
path.append(cursor)
|
| 943 |
+
cursor = records[cursor]["_parent"]
|
| 944 |
+
path.reverse()
|
| 945 |
+
leaf = records[leaf_key]
|
| 946 |
+
active = bool(path) and all(records[key]["active"] for key in path)
|
| 947 |
+
branch_id = leaf["branch_id"] or leaf["revision"]
|
| 948 |
+
branch = {
|
| 949 |
+
"id": branch_id,
|
| 950 |
+
"label": "Active branch" if active else "Branch %s" % branch_id[:8],
|
| 951 |
+
"active": active,
|
| 952 |
+
"leaf_scene": leaf["scene"],
|
| 953 |
+
"leaf_revision": leaf["revision"],
|
| 954 |
+
"path": [{"scene": key[0], "revision": key[1]} for key in path],
|
| 955 |
+
}
|
| 956 |
+
slot = self._attribution_slot(records, leaf_key)
|
| 957 |
+
if slot is not None:
|
| 958 |
+
branch["attribution_slot"] = slot
|
| 959 |
+
branch_paths.append(branch)
|
| 960 |
+
membership = {"id": branch["id"], "label": branch["label"],
|
| 961 |
+
"active": active}
|
| 962 |
+
for key in path:
|
| 963 |
+
memberships[key].append(membership)
|
| 964 |
+
branch_paths.sort(key=lambda item: (
|
| 965 |
+
not item["active"], -int(item["leaf_scene"]), item["id"]))
|
| 966 |
+
|
| 967 |
+
public_records = []
|
| 968 |
+
artifact_sizes: dict[str, int] = {}
|
| 969 |
+
record_artifact_paths: dict[tuple[int, str], dict[str, int]] = {}
|
| 970 |
+
for key in sorted(records):
|
| 971 |
+
record = records[key]
|
| 972 |
+
artifacts = self._artifacts(scan, record)
|
| 973 |
+
record_artifact_paths[key] = {
|
| 974 |
+
part["path"]: int(part["size_bytes"])
|
| 975 |
+
for part in artifacts if part["exists"]
|
| 976 |
+
}
|
| 977 |
+
artifact_sizes.update(record_artifact_paths[key])
|
| 978 |
+
reviews = [item for item in artifacts
|
| 979 |
+
if item["kind"] == "review_preview" and item["exists"]]
|
| 980 |
+
video = (self._output_item(record["_segment_path"])
|
| 981 |
+
if os.path.isfile(record["_segment_path"]) else None)
|
| 982 |
+
audio = None
|
| 983 |
+
audio_value = record["_segment"].get("generated_audio")
|
| 984 |
+
if isinstance(audio_value, str) and audio_value:
|
| 985 |
+
audio_path = self._artifact_path(audio_value)
|
| 986 |
+
if os.path.isfile(audio_path):
|
| 987 |
+
audio = self._output_item(audio_path)
|
| 988 |
+
parent = record["_parent"]
|
| 989 |
+
children = []
|
| 990 |
+
for child_key in sorted(record["_children"]):
|
| 991 |
+
child = records[child_key]
|
| 992 |
+
if child.get("take_kind") == "editorial_alternate":
|
| 993 |
+
continue
|
| 994 |
+
children.append({
|
| 995 |
+
"scene": child["scene"],
|
| 996 |
+
"scene_id": child["scene_id"],
|
| 997 |
+
"revision": child["revision"],
|
| 998 |
+
"continuation_mode": child["continuation_mode"],
|
| 999 |
+
"context_length": child["context_length"],
|
| 1000 |
+
"audio_context_length": child["audio_context_length"],
|
| 1001 |
+
})
|
| 1002 |
+
item = {name: record[name] for name in (
|
| 1003 |
+
"scene", "scene_id", "revision", "active", "ready",
|
| 1004 |
+
"pointer_active", "inactive_reason",
|
| 1005 |
+
"raw_frames", "delivered_frames", "seed", "steps",
|
| 1006 |
+
"created_at", "branch_id", "forked_from_branch_id",
|
| 1007 |
+
"adopted_from_revision",
|
| 1008 |
+
"predecessor_revision",
|
| 1009 |
+
"predecessor_checkpoint_sha256", "checkpoint_sha256",
|
| 1010 |
+
"continuation_mode", "context_length", "audio_context_length",
|
| 1011 |
+
"compatibility", "prompt_preview", "prompt", "take_kind",
|
| 1012 |
+
"alternate_of_revision", "alternate_media_mode")}
|
| 1013 |
+
item.update({
|
| 1014 |
+
"size_bytes": sum(part["size_bytes"] for part in artifacts),
|
| 1015 |
+
"owned_size_bytes": sum(
|
| 1016 |
+
part["size_bytes"] for part in artifacts if part["owned"]),
|
| 1017 |
+
"shared_size_bytes": sum(
|
| 1018 |
+
part["size_bytes"] for part in artifacts if part["shared"]),
|
| 1019 |
+
"missing_files": [part["label"] for part in artifacts
|
| 1020 |
+
if not part["exists"]],
|
| 1021 |
+
"video": video,
|
| 1022 |
+
"audio": audio,
|
| 1023 |
+
"preview_video": (
|
| 1024 |
+
self._output_item(reviews[-1]["_path"]) if reviews else None),
|
| 1025 |
+
"parent": ({"scene": parent[0], "revision": parent[1]}
|
| 1026 |
+
if parent else None),
|
| 1027 |
+
"dependencies": [
|
| 1028 |
+
{"scene": dependency[0], "revision": dependency[1]}
|
| 1029 |
+
for dependency in record["_dependencies"]
|
| 1030 |
+
],
|
| 1031 |
+
"children": children,
|
| 1032 |
+
"descendant_count": len(self._descendant_keys(records, key)),
|
| 1033 |
+
"branches": memberships.get(key, []),
|
| 1034 |
+
"lineage_status": record["_lineage_issue"] or (
|
| 1035 |
+
"chapter root" if record["_chapter_root"] else
|
| 1036 |
+
"root" if record["scene"] == 1 else "linked"),
|
| 1037 |
+
"metadata_path": os.path.relpath(
|
| 1038 |
+
record["_metadata_path"], self.output_root),
|
| 1039 |
+
})
|
| 1040 |
+
public_records.append(item)
|
| 1041 |
+
try:
|
| 1042 |
+
editorial = self._read_json(os.path.join(
|
| 1043 |
+
scan["run_dir"], "editorial.json"))
|
| 1044 |
+
replacement_rows = editorial.get("replacements", [])
|
| 1045 |
+
except (OSError, TypeError, ValueError, json.JSONDecodeError,
|
| 1046 |
+
AttributeError):
|
| 1047 |
+
replacement_rows = []
|
| 1048 |
+
active_revisions = {
|
| 1049 |
+
int(item["scene"]): str(item["revision"])
|
| 1050 |
+
for item in public_records
|
| 1051 |
+
if item["active"] and item["take_kind"] != "editorial_alternate"
|
| 1052 |
+
}
|
| 1053 |
+
selected_alternates = {
|
| 1054 |
+
(self._integer(item.get("scene")),
|
| 1055 |
+
str(item.get("alternate_revision") or "").lower())
|
| 1056 |
+
for item in replacement_rows if isinstance(item, dict)
|
| 1057 |
+
and str(item.get("base_revision") or "").lower() ==
|
| 1058 |
+
active_revisions.get(self._integer(item.get("scene")), "")
|
| 1059 |
+
}
|
| 1060 |
+
by_key = {
|
| 1061 |
+
(int(item["scene"]), str(item["revision"])): item
|
| 1062 |
+
for item in public_records
|
| 1063 |
+
}
|
| 1064 |
+
for item in public_records:
|
| 1065 |
+
if item["take_kind"] == "editorial_alternate":
|
| 1066 |
+
item["used_in_final_cut"] = (
|
| 1067 |
+
(int(item["scene"]), str(item["revision"]))
|
| 1068 |
+
in selected_alternates)
|
| 1069 |
+
base = by_key.get((
|
| 1070 |
+
int(item["scene"]), str(item["alternate_of_revision"])))
|
| 1071 |
+
if base is not None:
|
| 1072 |
+
base.setdefault("alternates", []).append(item)
|
| 1073 |
+
for item in public_records:
|
| 1074 |
+
if item.get("alternates"):
|
| 1075 |
+
item["alternates"].sort(key=lambda alternate: (
|
| 1076 |
+
not bool(alternate.get("used_in_final_cut")),
|
| 1077 |
+
str(alternate.get("created_at") or "")))
|
| 1078 |
+
scenes = []
|
| 1079 |
+
grouped: dict[int, list[dict[str, Any]]] = defaultdict(list)
|
| 1080 |
+
for item in public_records:
|
| 1081 |
+
grouped[item["scene"]].append(item)
|
| 1082 |
+
for scene in sorted(grouped):
|
| 1083 |
+
revisions = grouped[scene]
|
| 1084 |
+
scene_artifacts: dict[str, int] = {}
|
| 1085 |
+
for revision in revisions:
|
| 1086 |
+
scene_artifacts.update(record_artifact_paths.get(
|
| 1087 |
+
(int(revision["scene"]), str(revision["revision"])), {}))
|
| 1088 |
+
scenes.append({
|
| 1089 |
+
"scene": scene,
|
| 1090 |
+
"scene_id": next((item["scene_id"] for item in revisions
|
| 1091 |
+
if item["active"]), revisions[0]["scene_id"]),
|
| 1092 |
+
"revision_count": len(revisions),
|
| 1093 |
+
"active_revision": next((item["revision"] for item in revisions
|
| 1094 |
+
if item["active"]), ""),
|
| 1095 |
+
"bytes": sum(scene_artifacts.values()),
|
| 1096 |
+
"broken_count": sum(not item["ready"] for item in revisions),
|
| 1097 |
+
})
|
| 1098 |
+
graph_hash = _fingerprint([{
|
| 1099 |
+
"scene": item["scene"], "revision": item["revision"],
|
| 1100 |
+
"active": item["active"], "ready": item["ready"],
|
| 1101 |
+
"parent": item["parent"],
|
| 1102 |
+
"dependencies": item["dependencies"],
|
| 1103 |
+
"size_bytes": item["size_bytes"],
|
| 1104 |
+
} for item in public_records])
|
| 1105 |
+
return {
|
| 1106 |
+
"run_name": scan["run_name"],
|
| 1107 |
+
"graph_hash": graph_hash,
|
| 1108 |
+
"scenes": scenes,
|
| 1109 |
+
"branches": branch_paths,
|
| 1110 |
+
"revisions": public_records,
|
| 1111 |
+
"summary": {
|
| 1112 |
+
"scene_count": len(scenes),
|
| 1113 |
+
"revision_count": len(public_records),
|
| 1114 |
+
"branch_count": len(branch_paths),
|
| 1115 |
+
"bytes": sum(artifact_sizes.values()),
|
| 1116 |
+
"broken_count": sum(not item["ready"] for item in public_records),
|
| 1117 |
+
},
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
def graph(self, run_name: Any, *, adopt_legacy: bool = True) -> dict[str, Any]:
|
| 1121 |
+
run_dir, run = self._run_dir(run_name)
|
| 1122 |
+
if not os.path.isdir(run_dir):
|
| 1123 |
+
raise FileNotFoundError("H3 run %r does not exist." % run)
|
| 1124 |
+
return self._public_graph(self._scan(run, adopt_legacy=adopt_legacy))
|
| 1125 |
+
|
| 1126 |
+
def attribute(
|
| 1127 |
+
self, run_name: Any, parent_scene: Any, parent_revision: Any,
|
| 1128 |
+
candidate_scene: Any, candidate_revision: Any) -> dict[str, Any]:
|
| 1129 |
+
"""Attach an independent saved candidate to another branch tip.
|
| 1130 |
+
|
| 1131 |
+
Only metadata is created. Video, audio, prompt, and checkpoint files
|
| 1132 |
+
remain shared with the original immutable candidate revision.
|
| 1133 |
+
"""
|
| 1134 |
+
run_dir, run = self._run_dir(run_name)
|
| 1135 |
+
parent_scene_number = int(parent_scene)
|
| 1136 |
+
candidate_scene_number = int(candidate_scene)
|
| 1137 |
+
parent_token = str(parent_revision or "").strip().lower()
|
| 1138 |
+
candidate_token = str(candidate_revision or "").strip().lower()
|
| 1139 |
+
if not re.fullmatch(r"[0-9a-f]{32}", parent_token):
|
| 1140 |
+
raise ValueError("Parent revision must be a 32-character revision id.")
|
| 1141 |
+
if not re.fullmatch(r"[0-9a-f]{32}", candidate_token):
|
| 1142 |
+
raise ValueError("Candidate revision must be a 32-character revision id.")
|
| 1143 |
+
if candidate_scene_number != parent_scene_number + 1:
|
| 1144 |
+
raise ValueError(
|
| 1145 |
+
"A candidate can only be attributed to the immediately preceding scene slot.")
|
| 1146 |
+
with checkpoint_run_lock(self.output_root, run):
|
| 1147 |
+
if not os.path.isdir(run_dir):
|
| 1148 |
+
raise FileNotFoundError("H3 run %r does not exist." % run)
|
| 1149 |
+
scan = self._scan(run)
|
| 1150 |
+
records = scan["records"]
|
| 1151 |
+
parent_key = (parent_scene_number, parent_token)
|
| 1152 |
+
candidate_key = (candidate_scene_number, candidate_token)
|
| 1153 |
+
parent = records.get(parent_key)
|
| 1154 |
+
candidate = records.get(candidate_key)
|
| 1155 |
+
if parent is None:
|
| 1156 |
+
raise FileNotFoundError(
|
| 1157 |
+
"Parent scene %d revision %s is no longer available." %
|
| 1158 |
+
(parent_scene_number, parent_token[:8]))
|
| 1159 |
+
if candidate is None:
|
| 1160 |
+
raise FileNotFoundError(
|
| 1161 |
+
"Candidate scene %d revision %s is no longer available." %
|
| 1162 |
+
(candidate_scene_number, candidate_token[:8]))
|
| 1163 |
+
if (parent.get("take_kind") == "editorial_alternate" or
|
| 1164 |
+
candidate.get("take_kind") == "editorial_alternate"):
|
| 1165 |
+
raise ValueError(
|
| 1166 |
+
"Picture-only editorial alternates cannot be attached to "
|
| 1167 |
+
"generation lineage. Select them in Plan Studio's final "
|
| 1168 |
+
"cut instead.")
|
| 1169 |
+
if not parent["ready"] or not candidate["ready"]:
|
| 1170 |
+
raise ValueError(
|
| 1171 |
+
"Both the target branch tip and candidate must have intact video and checkpoint files.")
|
| 1172 |
+
if candidate["_parent"] == parent_key:
|
| 1173 |
+
raise ValueError("This candidate is already attached to that branch.")
|
| 1174 |
+
if not self._attributable_without_predecessor(candidate):
|
| 1175 |
+
raise ValueError(
|
| 1176 |
+
"This candidate consumes predecessor video or generated-audio context and cannot be safely attributed to another branch.")
|
| 1177 |
+
|
| 1178 |
+
source_revision = self._attribution_source(candidate)
|
| 1179 |
+
for record in records.values():
|
| 1180 |
+
adoption = record["_metadata"].get("adoption")
|
| 1181 |
+
if not isinstance(adoption, dict):
|
| 1182 |
+
continue
|
| 1183 |
+
if (record["scene"] == candidate_scene_number and
|
| 1184 |
+
str(adoption.get("source_revision") or "").lower() ==
|
| 1185 |
+
source_revision and
|
| 1186 |
+
str(adoption.get("parent_revision") or "").lower() ==
|
| 1187 |
+
parent_token):
|
| 1188 |
+
return {
|
| 1189 |
+
"ok": True,
|
| 1190 |
+
"created": False,
|
| 1191 |
+
"run_name": run,
|
| 1192 |
+
"scene": record["scene"],
|
| 1193 |
+
"revision": record["revision"],
|
| 1194 |
+
"source_revision": source_revision,
|
| 1195 |
+
"parent_scene": parent_scene_number,
|
| 1196 |
+
"parent_revision": parent_token,
|
| 1197 |
+
"message": "Candidate is already attributed to this branch.",
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
revision = uuid.uuid4().hex
|
| 1201 |
+
created_at = datetime.now(timezone.utc).isoformat(
|
| 1202 |
+
timespec="seconds").replace("+00:00", "Z")
|
| 1203 |
+
metadata = json.loads(_canonical_json(candidate["_metadata"]))
|
| 1204 |
+
segment = metadata.get("segment")
|
| 1205 |
+
if not isinstance(segment, dict):
|
| 1206 |
+
raise ValueError("Candidate revision metadata has no segment record.")
|
| 1207 |
+
metadata_path = os.path.join(
|
| 1208 |
+
scan["checkpoint_dir"],
|
| 1209 |
+
"clip_%04d.%s.json" % (candidate_scene_number, revision))
|
| 1210 |
+
segment.update({
|
| 1211 |
+
"revision": revision,
|
| 1212 |
+
"revision_metadata": os.path.relpath(
|
| 1213 |
+
metadata_path, self.output_root),
|
| 1214 |
+
"predecessor_revision": parent_token,
|
| 1215 |
+
"predecessor_checkpoint_sha256": str(
|
| 1216 |
+
parent.get("checkpoint_sha256") or ""),
|
| 1217 |
+
"branch_id": str(parent.get("branch_id") or parent_token),
|
| 1218 |
+
"forked_from_branch_id": str(
|
| 1219 |
+
candidate.get("branch_id") or candidate_token),
|
| 1220 |
+
"adopted_from_revision": source_revision,
|
| 1221 |
+
"adopted_from_branch_id": str(
|
| 1222 |
+
candidate.get("branch_id") or candidate_token),
|
| 1223 |
+
})
|
| 1224 |
+
metadata["created_at"] = created_at
|
| 1225 |
+
metadata["adoption"] = {
|
| 1226 |
+
"version": 1,
|
| 1227 |
+
"created_at": created_at,
|
| 1228 |
+
"source_scene": candidate_scene_number,
|
| 1229 |
+
"source_revision": source_revision,
|
| 1230 |
+
"candidate_revision": candidate_token,
|
| 1231 |
+
"parent_scene": parent_scene_number,
|
| 1232 |
+
"parent_revision": parent_token,
|
| 1233 |
+
"shared_artifacts": True,
|
| 1234 |
+
}
|
| 1235 |
+
self._atomic_json(metadata_path, metadata)
|
| 1236 |
+
return {
|
| 1237 |
+
"ok": True,
|
| 1238 |
+
"created": True,
|
| 1239 |
+
"run_name": run,
|
| 1240 |
+
"scene": candidate_scene_number,
|
| 1241 |
+
"revision": revision,
|
| 1242 |
+
"source_revision": source_revision,
|
| 1243 |
+
"parent_scene": parent_scene_number,
|
| 1244 |
+
"parent_revision": parent_token,
|
| 1245 |
+
"message": (
|
| 1246 |
+
"Attributed scene %d candidate %s to branch tip %s; saved media and checkpoint files remain shared." %
|
| 1247 |
+
(candidate_scene_number, candidate_token[:8],
|
| 1248 |
+
parent_token[:8])),
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
def _chapter_references(self, scan: dict[str, Any], revision: str,
|
| 1252 |
+
artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 1253 |
+
"""Sealed delivery snapshots pin their recovery inputs, not just MP4s.
|
| 1254 |
+
|
| 1255 |
+
Called under the same Run lock used to publish chapter manifests.
|
| 1256 |
+
Inspect small JSON documents only; never follow chapter symlinks.
|
| 1257 |
+
Unreadable snapshots block cleanup rather than risk losing recovery.
|
| 1258 |
+
"""
|
| 1259 |
+
root = os.path.join(scan["run_dir"], "chapters")
|
| 1260 |
+
paths = {os.path.realpath(item["_path"]) for item in artifacts
|
| 1261 |
+
if item.get("owned")}
|
| 1262 |
+
|
| 1263 |
+
def mentions(value: Any) -> bool:
|
| 1264 |
+
if isinstance(value, dict):
|
| 1265 |
+
# Superseded takes are audit history, not inputs required to
|
| 1266 |
+
# recover this snapshot. Keep every other recovery edge.
|
| 1267 |
+
return any(mentions(item) for key, item in value.items()
|
| 1268 |
+
if key != "supersedes")
|
| 1269 |
+
if isinstance(value, list):
|
| 1270 |
+
return any(mentions(item) for item in value)
|
| 1271 |
+
if not isinstance(value, str):
|
| 1272 |
+
return False
|
| 1273 |
+
if value.lower() == revision:
|
| 1274 |
+
return True
|
| 1275 |
+
relative = value.replace("\\", "/")
|
| 1276 |
+
if not (relative.startswith("h3_chains/") or os.path.isabs(relative)):
|
| 1277 |
+
return False
|
| 1278 |
+
return os.path.realpath(os.path.join(self.output_root, relative)) in paths
|
| 1279 |
+
|
| 1280 |
+
references = []
|
| 1281 |
+
if not os.path.lexists(root):
|
| 1282 |
+
return references
|
| 1283 |
+
try:
|
| 1284 |
+
if os.path.islink(root):
|
| 1285 |
+
raise ValueError("chapter directory is a symlink")
|
| 1286 |
+
with os.scandir(root) as entries:
|
| 1287 |
+
chapters = sorted(entries, key=lambda entry: entry.name)
|
| 1288 |
+
for directory in chapters:
|
| 1289 |
+
if directory.is_symlink():
|
| 1290 |
+
raise ValueError("chapter directory is a symlink: " + directory.name)
|
| 1291 |
+
if not directory.is_dir(follow_symlinks=False):
|
| 1292 |
+
continue
|
| 1293 |
+
manifest_dir = os.path.join(directory.path, "manifests")
|
| 1294 |
+
if not os.path.lexists(manifest_dir):
|
| 1295 |
+
continue
|
| 1296 |
+
if os.path.islink(manifest_dir):
|
| 1297 |
+
raise ValueError("chapter manifests directory is a symlink")
|
| 1298 |
+
with os.scandir(manifest_dir) as entries:
|
| 1299 |
+
snapshots = sorted(entries, key=lambda entry: entry.name)
|
| 1300 |
+
for entry in snapshots:
|
| 1301 |
+
if not re.fullmatch(r"[0-9a-f]{32}\.json", entry.name):
|
| 1302 |
+
continue
|
| 1303 |
+
if entry.is_symlink() or not entry.is_file(follow_symlinks=False):
|
| 1304 |
+
raise ValueError("chapter snapshot is not a regular file")
|
| 1305 |
+
document = self._read_json(entry.path)
|
| 1306 |
+
if (not isinstance(document, dict)
|
| 1307 |
+
or document.get("format") != "h3_chain_chapter_manifest_v1"
|
| 1308 |
+
or document.get("run_name") != scan["run_name"]
|
| 1309 |
+
or not isinstance(document.get("segments"), list)
|
| 1310 |
+
or not document["segments"]
|
| 1311 |
+
or not isinstance(document.get("chapter"), dict)):
|
| 1312 |
+
raise ValueError("invalid chapter snapshot: " + entry.name)
|
| 1313 |
+
if mentions(document):
|
| 1314 |
+
chapter = document["chapter"]
|
| 1315 |
+
references.append({
|
| 1316 |
+
"number": chapter.get("number"),
|
| 1317 |
+
"title": str(chapter.get("title") or directory.name),
|
| 1318 |
+
"snapshot": entry.name[:-5],
|
| 1319 |
+
"path": os.path.relpath(entry.path, self.output_root),
|
| 1320 |
+
})
|
| 1321 |
+
except (OSError, ValueError, TypeError, RecursionError) as error:
|
| 1322 |
+
references.append({"error": "Cannot verify sealed chapter recovery: " + str(error)})
|
| 1323 |
+
return references
|
| 1324 |
+
|
| 1325 |
+
def deletion_preview(self, run_name: Any, scene: Any,
|
| 1326 |
+
revision: Any) -> dict[str, Any]:
|
| 1327 |
+
run_dir, run = self._run_dir(run_name)
|
| 1328 |
+
scene_number = int(scene)
|
| 1329 |
+
token = str(revision or "").strip().lower()
|
| 1330 |
+
if not re.fullmatch(r"[0-9a-f]{32}", token):
|
| 1331 |
+
raise ValueError("Checkpoint revision must be a 32-character revision id.")
|
| 1332 |
+
with checkpoint_run_lock(self.output_root, run):
|
| 1333 |
+
if not os.path.isdir(run_dir):
|
| 1334 |
+
raise FileNotFoundError("H3 run %r does not exist." % run)
|
| 1335 |
+
scan = self._scan(run)
|
| 1336 |
+
key = (scene_number, token)
|
| 1337 |
+
record = scan["records"].get(key)
|
| 1338 |
+
if record is None:
|
| 1339 |
+
raise FileNotFoundError(
|
| 1340 |
+
"Scene %d revision %s is no longer available." %
|
| 1341 |
+
(scene_number, token[:8]))
|
| 1342 |
+
artifacts = self._artifacts(scan, record)
|
| 1343 |
+
chapter_start, chapter_end = self._chapter_bounds(
|
| 1344 |
+
scan, scene_number)
|
| 1345 |
+
descendant_keys = self._descendant_keys(scan["records"], key)
|
| 1346 |
+
dependents = []
|
| 1347 |
+
for child_key in descendant_keys:
|
| 1348 |
+
child = scan["records"][child_key]
|
| 1349 |
+
dependents.append({
|
| 1350 |
+
"scene": child["scene"],
|
| 1351 |
+
"scene_id": child["scene_id"],
|
| 1352 |
+
"revision": child["revision"],
|
| 1353 |
+
"active": child["active"],
|
| 1354 |
+
"direct": key in child["_dependencies"],
|
| 1355 |
+
"leaf": not child["_dependents"],
|
| 1356 |
+
"continuation_mode": child["continuation_mode"],
|
| 1357 |
+
"context_length": child["context_length"],
|
| 1358 |
+
"audio_context_length": child["audio_context_length"],
|
| 1359 |
+
})
|
| 1360 |
+
dependents.sort(key=lambda item: (
|
| 1361 |
+
not item["leaf"], -int(item["scene"]), item["revision"]))
|
| 1362 |
+
chapter_references = self._chapter_references(scan, token, artifacts)
|
| 1363 |
+
blockers = [
|
| 1364 |
+
item.get("error") or (
|
| 1365 |
+
"Sealed Chapter %s (%s), snapshot %s, requires this revision "
|
| 1366 |
+
"or its recovery artifacts. Retire that snapshot explicitly "
|
| 1367 |
+
"if its chapter recovery is no longer needed." %
|
| 1368 |
+
(item["number"], item["title"], item["snapshot"][:8]))
|
| 1369 |
+
for item in chapter_references]
|
| 1370 |
+
try:
|
| 1371 |
+
editorial = self._read_json(os.path.join(
|
| 1372 |
+
scan["run_dir"], "editorial.json"))
|
| 1373 |
+
active_revision = next((
|
| 1374 |
+
str(record.get("revision") or "").lower()
|
| 1375 |
+
for record in scan["records"].values()
|
| 1376 |
+
if int(record.get("scene", 0)) == scene_number
|
| 1377 |
+
and bool(record.get("active"))
|
| 1378 |
+
and str(record.get("take_kind") or "") !=
|
| 1379 |
+
"editorial_alternate"), "")
|
| 1380 |
+
selected_in_cut = any(
|
| 1381 |
+
isinstance(item, dict)
|
| 1382 |
+
and self._integer(item.get("scene")) == scene_number
|
| 1383 |
+
and str(item.get("alternate_revision") or "").lower()
|
| 1384 |
+
== token
|
| 1385 |
+
and str(item.get("base_revision") or "").lower()
|
| 1386 |
+
== active_revision
|
| 1387 |
+
for item in editorial.get("replacements", []))
|
| 1388 |
+
selected_base = any(
|
| 1389 |
+
isinstance(item, dict)
|
| 1390 |
+
and self._integer(item.get("scene")) == scene_number
|
| 1391 |
+
and str(item.get("base_revision") or "").lower() == token
|
| 1392 |
+
and str(item.get("base_revision") or "").lower()
|
| 1393 |
+
== active_revision
|
| 1394 |
+
for item in editorial.get("replacements", []))
|
| 1395 |
+
except (OSError, TypeError, ValueError, json.JSONDecodeError,
|
| 1396 |
+
AttributeError):
|
| 1397 |
+
selected_in_cut = False
|
| 1398 |
+
selected_base = False
|
| 1399 |
+
if selected_in_cut:
|
| 1400 |
+
blockers.append(
|
| 1401 |
+
"This alternate is selected in the final cut. Restore the "
|
| 1402 |
+
"original take in Plan Studio before deleting it.")
|
| 1403 |
+
if selected_base:
|
| 1404 |
+
blockers.append(
|
| 1405 |
+
"This generation revision is the immutable base of the "
|
| 1406 |
+
"selected final-cut alternate. Restore the original take "
|
| 1407 |
+
"in Plan Studio before rolling it back.")
|
| 1408 |
+
later_active = sorted(
|
| 1409 |
+
item["scene"] for item in scan["records"].values()
|
| 1410 |
+
if (item["active"] and item["scene"] > scene_number and
|
| 1411 |
+
item["scene"] <= chapter_end))
|
| 1412 |
+
if record.get("pointer_active") and not record["active"]:
|
| 1413 |
+
blockers.append(
|
| 1414 |
+
"This saved take has a stale active pointer. Make the desired "
|
| 1415 |
+
"chapter branch active before deleting it; its files are retained.")
|
| 1416 |
+
if dependents:
|
| 1417 |
+
blockers.append(
|
| 1418 |
+
"%d later checkpoint revision%s depend%s on it." %
|
| 1419 |
+
(len(dependents), "" if len(dependents) == 1 else "s",
|
| 1420 |
+
"s" if len(dependents) == 1 else ""))
|
| 1421 |
+
if record["active"] and dependents:
|
| 1422 |
+
blockers.append(
|
| 1423 |
+
"This active revision can only be rolled back after its "
|
| 1424 |
+
"later revisions are deleted.")
|
| 1425 |
+
elif record["active"] and later_active:
|
| 1426 |
+
blockers.append(
|
| 1427 |
+
"A later active scene pointer exists at scene %d." %
|
| 1428 |
+
later_active[-1])
|
| 1429 |
+
rollback = bool(record["active"] and not blockers)
|
| 1430 |
+
if rollback:
|
| 1431 |
+
artifacts.append(self._active_pointer_artifact(scan, record))
|
| 1432 |
+
public_files = [{key: value for key, value in item.items()
|
| 1433 |
+
if not key.startswith("_")} for item in artifacts]
|
| 1434 |
+
snapshot = _fingerprint({
|
| 1435 |
+
"run_name": run,
|
| 1436 |
+
"scene": scene_number,
|
| 1437 |
+
"revision": token,
|
| 1438 |
+
"active": record["active"],
|
| 1439 |
+
"rollback": rollback,
|
| 1440 |
+
"chapter_references": chapter_references,
|
| 1441 |
+
"dependents": [(item["scene"], item["revision"])
|
| 1442 |
+
for item in dependents],
|
| 1443 |
+
"files": [(item["path"], item["exists"], item["size_bytes"],
|
| 1444 |
+
item["shared"], artifact["_mtime_ns"])
|
| 1445 |
+
for item, artifact in zip(public_files, artifacts)],
|
| 1446 |
+
})
|
| 1447 |
+
owned = [item for item in public_files if item["owned"]]
|
| 1448 |
+
return {
|
| 1449 |
+
"ok": True,
|
| 1450 |
+
"run_name": run,
|
| 1451 |
+
"scene": scene_number,
|
| 1452 |
+
"scene_id": record["scene_id"],
|
| 1453 |
+
"revision": token,
|
| 1454 |
+
"active": record["active"],
|
| 1455 |
+
"rollback": rollback,
|
| 1456 |
+
"rollback_to_scene": scene_number - 1 if rollback else None,
|
| 1457 |
+
"scope_start_scene": chapter_start,
|
| 1458 |
+
"scope_end_scene": chapter_end,
|
| 1459 |
+
"allowed": not blockers,
|
| 1460 |
+
"blockers": blockers,
|
| 1461 |
+
"dependents": dependents,
|
| 1462 |
+
"chapter_references": chapter_references,
|
| 1463 |
+
"files": public_files,
|
| 1464 |
+
"owned_file_count": sum(item["exists"] for item in owned),
|
| 1465 |
+
"reclaimed_bytes": sum(item["size_bytes"] for item in owned),
|
| 1466 |
+
"snapshot": snapshot,
|
| 1467 |
+
"not_deleted": [
|
| 1468 |
+
"Run Plan and workflow archives",
|
| 1469 |
+
"Archived references and source media",
|
| 1470 |
+
"Prompt revision history",
|
| 1471 |
+
"Final and partial assembled exports",
|
| 1472 |
+
],
|
| 1473 |
+
}
|
| 1474 |
+
|
| 1475 |
+
def delete(self, run_name: Any, scene: Any, revision: Any,
|
| 1476 |
+
expected_snapshot: Any = "") -> dict[str, Any]:
|
| 1477 |
+
run_dir, run = self._run_dir(run_name)
|
| 1478 |
+
with checkpoint_run_lock(self.output_root, run):
|
| 1479 |
+
preview = self.deletion_preview(run, scene, revision)
|
| 1480 |
+
expected = str(expected_snapshot or "")
|
| 1481 |
+
if not preview["allowed"]:
|
| 1482 |
+
raise CheckpointDeleteBlocked(" ".join(preview["blockers"]), preview)
|
| 1483 |
+
if not expected:
|
| 1484 |
+
raise CheckpointDeleteBlocked(
|
| 1485 |
+
"Preview this checkpoint deletion before confirming it.",
|
| 1486 |
+
preview)
|
| 1487 |
+
if expected != preview["snapshot"]:
|
| 1488 |
+
raise CheckpointDeleteBlocked(
|
| 1489 |
+
"Checkpoint files or dependencies changed; preview the deletion again.",
|
| 1490 |
+
preview)
|
| 1491 |
+
scan = self._scan(run)
|
| 1492 |
+
key = (int(scene), str(revision).strip().lower())
|
| 1493 |
+
record = scan["records"][key]
|
| 1494 |
+
managed = self._artifacts(scan, record)
|
| 1495 |
+
if preview["rollback"]:
|
| 1496 |
+
managed.append(self._active_pointer_artifact(scan, record))
|
| 1497 |
+
artifacts = [item for item in managed
|
| 1498 |
+
if item["owned"] and item["exists"]]
|
| 1499 |
+
transaction = uuid.uuid4().hex
|
| 1500 |
+
staged = []
|
| 1501 |
+
try:
|
| 1502 |
+
for artifact in artifacts:
|
| 1503 |
+
path = artifact["_path"]
|
| 1504 |
+
temporary = "%s.delete.%s.tmp" % (path, transaction)
|
| 1505 |
+
os.replace(path, temporary)
|
| 1506 |
+
staged.append((path, temporary, artifact["size_bytes"]))
|
| 1507 |
+
except Exception:
|
| 1508 |
+
for original, temporary, _size in reversed(staged):
|
| 1509 |
+
try:
|
| 1510 |
+
os.replace(temporary, original)
|
| 1511 |
+
except Exception:
|
| 1512 |
+
pass
|
| 1513 |
+
raise
|
| 1514 |
+
failed = []
|
| 1515 |
+
for _original, temporary, _size in staged:
|
| 1516 |
+
try:
|
| 1517 |
+
os.unlink(temporary)
|
| 1518 |
+
except OSError:
|
| 1519 |
+
failed.append(temporary)
|
| 1520 |
+
reclaimed = sum(size for _original, temporary, size in staged
|
| 1521 |
+
if temporary not in failed)
|
| 1522 |
+
return {
|
| 1523 |
+
"ok": True,
|
| 1524 |
+
"run_name": run,
|
| 1525 |
+
"scene": int(scene),
|
| 1526 |
+
"revision": str(revision).strip().lower(),
|
| 1527 |
+
"rollback": bool(preview["rollback"]),
|
| 1528 |
+
"rollback_to_scene": preview["rollback_to_scene"],
|
| 1529 |
+
"deleted_files": len(staged) - len(failed),
|
| 1530 |
+
"reclaimed_bytes": reclaimed,
|
| 1531 |
+
"message": (
|
| 1532 |
+
("Rolled the active chain back through scene %d and " %
|
| 1533 |
+
(int(scene) - 1) if int(scene) > 1 else
|
| 1534 |
+
"Rolled the active chain back to no saved scenes and ") +
|
| 1535 |
+
"deleted scene %d revision %s." %
|
| 1536 |
+
(int(scene), str(revision)[:8])
|
| 1537 |
+
if preview["rollback"] else
|
| 1538 |
+
"Deleted scene %d revision %s." %
|
| 1539 |
+
(int(scene), str(revision)[:8])
|
| 1540 |
+
),
|
| 1541 |
+
}
|
ComfyUI-MiniMaxH3-Context-Loop/checkpoint_variants.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read-only catalogue of saved processing takes, separate from generation lineage.
|
| 2 |
+
|
| 3 |
+
Listing reads JSON and file sizes, never model tensors or multi-GB checksums.
|
| 4 |
+
Execution remains responsible for artifact integrity and latent validation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
STAGES = ("derope", "latent_upscale", "pixel_upscale", "other")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def processing_lineage(segments):
|
| 16 |
+
"""Small immutable addresses, not copies of tensors or source manifests."""
|
| 17 |
+
return [{"scene": int(item["index"]), "revision": item["revision"],
|
| 18 |
+
"metadata_path": item["revision_metadata"],
|
| 19 |
+
"checkpoint_sha256": item["checkpoint_sha256"]}
|
| 20 |
+
for item in segments]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def validate_processing_lineage(value):
|
| 24 |
+
if not isinstance(value, list) or not value:
|
| 25 |
+
raise ValueError("Saved processing branch has no lineage.")
|
| 26 |
+
for item in value:
|
| 27 |
+
if (not isinstance(item, dict) or type(item.get("scene")) is not int or
|
| 28 |
+
item["scene"] < 1 or
|
| 29 |
+
not re.fullmatch(r"[0-9a-f]{32}", str(item.get("revision") or "")) or
|
| 30 |
+
not re.fullmatch(r"[0-9a-f]{64}", str(item.get("checkpoint_sha256") or "")) or
|
| 31 |
+
not isinstance(item.get("metadata_path"), str) or not item["metadata_path"]):
|
| 32 |
+
raise ValueError("Saved processing branch has an invalid revision address.")
|
| 33 |
+
first = value[0]["scene"]
|
| 34 |
+
if [item["scene"] for item in value] != list(range(first, first + len(value))):
|
| 35 |
+
raise ValueError("Saved processing branch is not contiguous.")
|
| 36 |
+
return value
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def processing_stage(config):
|
| 40 |
+
config = config if isinstance(config, dict) else {}
|
| 41 |
+
recipe = config.get("recipe") or {}
|
| 42 |
+
recipe = recipe if isinstance(recipe, dict) else {}
|
| 43 |
+
explicit = recipe.get("stage")
|
| 44 |
+
if explicit in STAGES:
|
| 45 |
+
return explicit
|
| 46 |
+
# The released combined LBH/DeRoPE example records this recipe field.
|
| 47 |
+
# Never infer a processing stage from a user-chosen folder/profile name.
|
| 48 |
+
derope = recipe.get("derope")
|
| 49 |
+
if derope is True or (isinstance(derope, str) and derope.strip().lower()
|
| 50 |
+
not in ("", "false", "off", "none", "disabled")):
|
| 51 |
+
return "derope"
|
| 52 |
+
return {"h3_latent": "latent_upscale", "pixel": "pixel_upscale"}.get(
|
| 53 |
+
config.get("backend"), "other")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def saved_checkpoint_variants(output_root, run_name, originals):
|
| 57 |
+
root = Path(output_root).resolve()
|
| 58 |
+
run = (root / "h3_chains" / run_name).resolve()
|
| 59 |
+
if run.parent != root / "h3_chains":
|
| 60 |
+
raise ValueError("Invalid checkpoint variant run directory.")
|
| 61 |
+
records, warnings, seen, branches = [], [], set(), []
|
| 62 |
+
legacy_profiles = set()
|
| 63 |
+
|
| 64 |
+
def inside(path, parent):
|
| 65 |
+
resolved = path.resolve()
|
| 66 |
+
if not resolved.is_relative_to(parent):
|
| 67 |
+
raise ValueError("Saved processing artifact escapes its profile directory.")
|
| 68 |
+
return resolved
|
| 69 |
+
|
| 70 |
+
def read(path):
|
| 71 |
+
with path.open(encoding="utf-8") as handle:
|
| 72 |
+
value = json.load(handle)
|
| 73 |
+
if not isinstance(value, dict):
|
| 74 |
+
raise ValueError("Saved processing metadata is not an object.")
|
| 75 |
+
return value
|
| 76 |
+
|
| 77 |
+
def media(path):
|
| 78 |
+
rel = path.relative_to(root)
|
| 79 |
+
return {"filename": rel.name, "subfolder": str(rel.parent), "type": "output"}
|
| 80 |
+
|
| 81 |
+
def legacy_branches(profile):
|
| 82 |
+
# Only legacy DeRoPE takes need these larger, embedded-source files.
|
| 83 |
+
# New saves use compact immutable lineage snapshots in their metadata.
|
| 84 |
+
if profile in legacy_profiles:
|
| 85 |
+
return
|
| 86 |
+
legacy_profiles.add(profile)
|
| 87 |
+
manifests = [profile / "upscale_manifest.json"]
|
| 88 |
+
partial = inside(profile / "partial", profile)
|
| 89 |
+
if partial.is_dir():
|
| 90 |
+
manifests.extend(partial.glob("through_clip_*.manifest.json"))
|
| 91 |
+
for path in manifests:
|
| 92 |
+
if not path.is_file():
|
| 93 |
+
continue
|
| 94 |
+
try:
|
| 95 |
+
path = inside(path, profile)
|
| 96 |
+
saved = read(path)
|
| 97 |
+
if (saved.get("format") not in (
|
| 98 |
+
"h3_chain_upscale_manifest_v1", "h3_chain_upscale_partial_manifest_v1") or
|
| 99 |
+
saved.get("run_name") != run_name or saved.get("profile") != profile.name):
|
| 100 |
+
raise ValueError("Invalid processing branch manifest.")
|
| 101 |
+
branches.append({"path": str(path.relative_to(root)), "kind": "manifest",
|
| 102 |
+
"lineage": validate_processing_lineage(processing_lineage(saved["segments"]))})
|
| 103 |
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
| 104 |
+
warnings.append("%s: %s" % (path.name, exc))
|
| 105 |
+
|
| 106 |
+
parents = [run / "upscaled"]
|
| 107 |
+
chapters = run / "chapters"
|
| 108 |
+
if chapters.is_dir():
|
| 109 |
+
parents.extend(path / "upscaled" for path in chapters.iterdir() if path.is_dir())
|
| 110 |
+
for parent in parents:
|
| 111 |
+
try:
|
| 112 |
+
inside(parent, run)
|
| 113 |
+
if not parent.is_dir():
|
| 114 |
+
continue
|
| 115 |
+
profiles = sorted(parent.iterdir())
|
| 116 |
+
except (OSError, ValueError) as exc:
|
| 117 |
+
warnings.append(str(exc))
|
| 118 |
+
continue
|
| 119 |
+
for profile in profiles:
|
| 120 |
+
try:
|
| 121 |
+
profile = inside(profile, run)
|
| 122 |
+
folder = inside(profile / "checkpoints", profile)
|
| 123 |
+
if not folder.is_dir():
|
| 124 |
+
continue
|
| 125 |
+
files = sorted(folder.iterdir())
|
| 126 |
+
except (OSError, ValueError) as exc:
|
| 127 |
+
warnings.append(str(exc))
|
| 128 |
+
continue
|
| 129 |
+
for path in files:
|
| 130 |
+
match = re.fullmatch(r"clip_(\d{4})(?:\.([0-9a-f]{32}))?\.json", path.name)
|
| 131 |
+
if not match:
|
| 132 |
+
continue
|
| 133 |
+
try:
|
| 134 |
+
path = inside(path, profile)
|
| 135 |
+
metadata = read(path)
|
| 136 |
+
if metadata.get("format") != "h3_chain_upscale_segment_v1":
|
| 137 |
+
continue
|
| 138 |
+
if metadata.get("run_name") != run_name or metadata.get("profile") != profile.name:
|
| 139 |
+
raise ValueError("Saved processing metadata belongs to another run/profile.")
|
| 140 |
+
segment = metadata["segment"]
|
| 141 |
+
scene = int(segment["index"])
|
| 142 |
+
revision = str(segment["revision"])
|
| 143 |
+
if (scene != int(match[1]) or not re.fullmatch(r"[0-9a-f]{32}", revision)
|
| 144 |
+
or (match[2] and match[2] != revision)):
|
| 145 |
+
raise ValueError("Saved processing scene/revision does not match its file.")
|
| 146 |
+
canonical = inside(root / segment["revision_metadata"], profile)
|
| 147 |
+
if canonical != folder / ("clip_%04d.%s.json" % (scene, revision)):
|
| 148 |
+
raise ValueError("Saved processing revision address is inconsistent.")
|
| 149 |
+
# Ignore mutable pointer copies; their immutable revision
|
| 150 |
+
# file is the authority and is scanned separately.
|
| 151 |
+
if path != canonical:
|
| 152 |
+
continue
|
| 153 |
+
identity = str(path.relative_to(root))
|
| 154 |
+
if identity in seen:
|
| 155 |
+
continue
|
| 156 |
+
seen.add(identity)
|
| 157 |
+
missing, size, outputs = [], 0, {}
|
| 158 |
+
for field in ("segment", "checkpoint", "generated_audio"):
|
| 159 |
+
address = segment.get(field)
|
| 160 |
+
if not address and field == "generated_audio":
|
| 161 |
+
continue
|
| 162 |
+
if not isinstance(address, str) or not address:
|
| 163 |
+
missing.append(field)
|
| 164 |
+
continue
|
| 165 |
+
artifact = inside(root / address, profile)
|
| 166 |
+
if not artifact.is_file():
|
| 167 |
+
missing.append(field)
|
| 168 |
+
continue
|
| 169 |
+
size += artifact.stat().st_size
|
| 170 |
+
if field != "checkpoint":
|
| 171 |
+
outputs["video" if field == "segment" else "audio"] = media(artifact)
|
| 172 |
+
config = metadata.get("profile_config") or {}
|
| 173 |
+
stage = metadata.get("processing_stage")
|
| 174 |
+
if stage not in STAGES:
|
| 175 |
+
stage = processing_stage(config)
|
| 176 |
+
record = {
|
| 177 |
+
"key": identity, "metadata_path": identity,
|
| 178 |
+
"scene": scene, "scene_id": str(segment.get("id") or ""),
|
| 179 |
+
"revision": revision, "stage": stage, "profile": profile.name,
|
| 180 |
+
"profile_path": str(profile.relative_to(root)),
|
| 181 |
+
"source_manifest_hash": str(metadata.get("source_manifest_hash") or ""),
|
| 182 |
+
"source_revision": str(segment.get("source_revision") or ""),
|
| 183 |
+
"source_checkpoint_sha256": str(segment.get("source_checkpoint_sha256") or ""),
|
| 184 |
+
"checkpoint_sha256": str(segment.get("checkpoint_sha256") or ""),
|
| 185 |
+
"created_at": str(segment.get("created_at") or ""),
|
| 186 |
+
"width": segment.get("width"), "height": segment.get("height"),
|
| 187 |
+
"raw_frames": segment.get("raw_frames"),
|
| 188 |
+
"delivered_frames": segment.get("delivered_frames"),
|
| 189 |
+
"latent_saved": segment.get("latent_saved") is True,
|
| 190 |
+
"latent_layout": str(segment.get("latent_layout") or "omitted"),
|
| 191 |
+
"context_steps": segment.get("context_steps", 0),
|
| 192 |
+
"audio_route": str(segment.get("audio_route") or "unknown"),
|
| 193 |
+
"prompt": str(segment.get("prompt") or ""),
|
| 194 |
+
"ready": not missing, "missing_files": missing, "size_bytes": size,
|
| 195 |
+
**outputs,
|
| 196 |
+
}
|
| 197 |
+
records.append(record)
|
| 198 |
+
if metadata.get("processing_lineage"):
|
| 199 |
+
branches.append({"path": identity, "kind": "metadata",
|
| 200 |
+
"lineage": validate_processing_lineage(metadata["processing_lineage"])})
|
| 201 |
+
elif record["stage"] == "derope":
|
| 202 |
+
legacy_branches(profile)
|
| 203 |
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
| 204 |
+
warnings.append("%s: %s" % (path.name, exc))
|
| 205 |
+
|
| 206 |
+
# Match the exact source revision AND saved checkpoint identity. A rerun
|
| 207 |
+
# with the same scene number is not the same take. Follow derived sources
|
| 208 |
+
# transitively so a future DeRoPE -> upscale take stays beside its original.
|
| 209 |
+
sources = {}
|
| 210 |
+
for original in originals:
|
| 211 |
+
if original.get("take_kind") == "editorial_alternate":
|
| 212 |
+
continue
|
| 213 |
+
key = (int(original["scene"]), str(original["revision"]),
|
| 214 |
+
str(original.get("checkpoint_sha256") or ""))
|
| 215 |
+
if key[2]:
|
| 216 |
+
sources.setdefault(key, []).append(original)
|
| 217 |
+
alias = str(original.get("adopted_from_revision") or "")
|
| 218 |
+
if alias and key[2]:
|
| 219 |
+
sources.setdefault((key[0], alias, key[2]), []).append(original)
|
| 220 |
+
# ALT is not a generation branch, but its processed pictures still belong
|
| 221 |
+
# beside the immutable base scene. Match the ALT's own checkpoint hash;
|
| 222 |
+
# never attach an arbitrary same-scene processed take to the active base.
|
| 223 |
+
bases = {(int(item["scene"]), str(item["revision"])): item
|
| 224 |
+
for item in originals if item.get("take_kind") != "editorial_alternate"}
|
| 225 |
+
for alternate in originals:
|
| 226 |
+
if alternate.get("take_kind") != "editorial_alternate":
|
| 227 |
+
continue
|
| 228 |
+
base = bases.get((int(alternate["scene"]), str(alternate.get("alternate_of_revision") or "")))
|
| 229 |
+
digest = str(alternate.get("checkpoint_sha256") or "")
|
| 230 |
+
if base is not None and digest:
|
| 231 |
+
sources.setdefault((int(alternate["scene"]), str(alternate["revision"]), digest), []).append(base)
|
| 232 |
+
derived = {}
|
| 233 |
+
for record in records:
|
| 234 |
+
key = (record["scene"], record["revision"], record["checkpoint_sha256"])
|
| 235 |
+
if key[2]:
|
| 236 |
+
derived.setdefault(key, []).append(record)
|
| 237 |
+
|
| 238 |
+
def origins(record, visited):
|
| 239 |
+
key = (record["scene"], record["source_revision"], record["source_checkpoint_sha256"])
|
| 240 |
+
if not key[1] or not key[2] or key in visited:
|
| 241 |
+
return []
|
| 242 |
+
if key in sources:
|
| 243 |
+
return sources[key]
|
| 244 |
+
return [origin for parent in derived.get(key, [])
|
| 245 |
+
for origin in origins(parent, visited | {key})]
|
| 246 |
+
|
| 247 |
+
for record in records:
|
| 248 |
+
matches = {(int(item["scene"]), str(item["revision"]))
|
| 249 |
+
for item in origins(record, set())}
|
| 250 |
+
record["originals"] = [{"scene": scene, "revision": revision}
|
| 251 |
+
for scene, revision in sorted(matches)]
|
| 252 |
+
record["source_status"] = "linked" if matches else "original unavailable or source mismatch"
|
| 253 |
+
records.sort(key=lambda item: (item["scene"], item["created_at"], item["key"]), reverse=True)
|
| 254 |
+
# Independent pixel cleanup can leave a hole in an immutable historical
|
| 255 |
+
# lineage. Keep every surviving take visible, but don't offer that history
|
| 256 |
+
# as an executable complete branch or silently substitute a newer take.
|
| 257 |
+
available = {record["key"]: (record["scene"], record["revision"], record["checkpoint_sha256"])
|
| 258 |
+
for record in records if record["ready"]}
|
| 259 |
+
branches = [branch for branch in branches if all(
|
| 260 |
+
available.get(item["metadata_path"]) == (item["scene"], item["revision"], item["checkpoint_sha256"])
|
| 261 |
+
for item in branch["lineage"])]
|
| 262 |
+
for record in records:
|
| 263 |
+
matches = [branch for branch in branches if any(
|
| 264 |
+
item.get("metadata_path") == record["key"] and
|
| 265 |
+
item.get("checkpoint_sha256") == record["checkpoint_sha256"]
|
| 266 |
+
for item in branch["lineage"] if isinstance(item, dict))]
|
| 267 |
+
# Remove strict prefixes and duplicate snapshots. Two distinct leaves
|
| 268 |
+
# are ambiguous: the user must click a take unique to the desired leaf.
|
| 269 |
+
leaves = {}
|
| 270 |
+
for branch in matches:
|
| 271 |
+
lineage = branch["lineage"]
|
| 272 |
+
if not any(len(other["lineage"]) > len(lineage) and
|
| 273 |
+
other["lineage"][:len(lineage)] == lineage for other in matches):
|
| 274 |
+
key = json.dumps(lineage, sort_keys=True)
|
| 275 |
+
if key not in leaves or branch["kind"] == "metadata":
|
| 276 |
+
leaves[key] = branch
|
| 277 |
+
record["processing_branch"] = next(iter(leaves.values())) if len(leaves) == 1 else None
|
| 278 |
+
return {"variants": records, "warnings": warnings}
|
ComfyUI-MiniMaxH3-Context-Loop/contracts_v05.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stable data-contract declarations for the 0.5 workflow UX migration.
|
| 2 |
+
|
| 3 |
+
This module is deliberately independent from ComfyUI, PyTorch, and the node
|
| 4 |
+
implementations. It freezes the vocabulary and compatibility translations
|
| 5 |
+
before runtime nodes start consuming the new contracts.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
SOURCE_TIMELINE_VERSION = "h3_source_timeline_v1"
|
| 14 |
+
AUDIO_POLICY_VERSION = "h3_audio_policy_v1"
|
| 15 |
+
TRANSITION_POLICY_VERSION = "h3_transition_policy_v1"
|
| 16 |
+
CHAIN_POLICY_VERSION = "h3_chain_policy_v1"
|
| 17 |
+
LIP_SYNC_OPTIONS_VERSION = "h3_lip_sync_options_v1"
|
| 18 |
+
SCENE_DEPENDENCY_VERSION = "h3_scene_dependency_v1"
|
| 19 |
+
PREFLIGHT_VERSION = "h3_preflight_v1"
|
| 20 |
+
|
| 21 |
+
FINAL_AUDIO_POLICIES = ("generated", "source", "none")
|
| 22 |
+
SOURCE_REFERENCE_POLICIES = ("off", "on")
|
| 23 |
+
GENERATED_CONTINUITY_POLICIES = ("off", "on")
|
| 24 |
+
SOURCE_AUDIO_TARGET_POLICIES = ("off", "locked")
|
| 25 |
+
PAIRED_AUDIO_POLICIES = ("off", "embedded")
|
| 26 |
+
PRIMARY_TRANSITION_PRESETS = ("cut", "guide", "hard_av", "soft_av")
|
| 27 |
+
GENERATION_SCENE_PROFILES = {
|
| 28 |
+
"Visual continuity": "guide",
|
| 29 |
+
"Independent scenes": "cut",
|
| 30 |
+
"Hard picture + protected audio": "hard_av",
|
| 31 |
+
"Hard picture + smooth audio": "soft_av",
|
| 32 |
+
}
|
| 33 |
+
GENERATION_AUDIO_PROFILES = {
|
| 34 |
+
"Generate audio": ("generated", "off", "on", False),
|
| 35 |
+
"Generate fresh audio per scene": ("generated", "off", "off", False),
|
| 36 |
+
"Lip-sync to source audio": ("source", "off", "off", True),
|
| 37 |
+
"Generate audio from source guide": ("generated", "on", "off", False),
|
| 38 |
+
"Use source soundtrack only": ("source", "off", "off", False),
|
| 39 |
+
"No final audio": ("none", "off", "off", False),
|
| 40 |
+
}
|
| 41 |
+
ADVANCED_TRANSITION_PRESETS = (
|
| 42 |
+
"cut", "guide", "tone_guide", "latent_guide", "detail_guide",
|
| 43 |
+
"detail_av", "drift_av", "color_drift_av", "hard_av", "soft_av",
|
| 44 |
+
)
|
| 45 |
+
DEFAULT_AUDIO_CONTEXT_LENGTH = 22
|
| 46 |
+
CONTEXT_SPATIAL_PROXY_MODES = ("off", "rgb_5_6", "latent_5_6")
|
| 47 |
+
CONTINUATION_POLICIES = (
|
| 48 |
+
"guide", "tone_carry_guide", "latent_guide", "tapered_guide",
|
| 49 |
+
"masked_av", "tapered_av", "feathered_av", "audio_feathered_av",
|
| 50 |
+
"drift_control_av", "color_stable_drift_av")
|
| 51 |
+
TRANSITION_CONTEXT_LENGTHS = (
|
| 52 |
+
0, 1, 5, 22, 39, 56, 73, 90, 107, 124,
|
| 53 |
+
141, 158, 175, 192, 209, 226, 243,
|
| 54 |
+
)
|
| 55 |
+
AV_TRANSITION_CONTEXT_LENGTHS = (39, 90, 141, 192, 243)
|
| 56 |
+
VIDEO_ONLY_AV_TRANSITION_CONTEXT_LENGTHS = tuple(
|
| 57 |
+
value for value in TRANSITION_CONTEXT_LENGTHS if value >= 5
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Experimental one-shot latent-context recipe adapted from beijinren's
|
| 61 |
+
# ComfyUI-H3-Context-Noise. Keep every generation-significant value in the
|
| 62 |
+
# scene dependency contract so changing the recipe cannot silently resume a
|
| 63 |
+
# scene rendered with an older taper.
|
| 64 |
+
DETAIL_AV_RECIPE = {
|
| 65 |
+
"version": "h3_detail_av_latent_taper_v2",
|
| 66 |
+
"context_frames": 39,
|
| 67 |
+
"video_steps": 12,
|
| 68 |
+
"alpha": 0.30,
|
| 69 |
+
"alpha_end": 0.00,
|
| 70 |
+
"ramp_steps": 4,
|
| 71 |
+
"noise_scale": "match_latent_std",
|
| 72 |
+
"seed_xor": 0xD37A11,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# Experimental recursive AV treatment. Unlike Detail AV, this does not bake
|
| 76 |
+
# static noise into the copied predecessor. At each model evaluation it uses
|
| 77 |
+
# the sampler's existing noise field and advances the video prefix to the next
|
| 78 |
+
# scheduler sigma. The oldest eight of the 12 carried video steps receive the
|
| 79 |
+
# complete matched-noise ratio; the newest four taper .75/.50/.25/.00 so the
|
| 80 |
+
# generated-future boundary remains exact. Audio retains the selected Audio
|
| 81 |
+
# Policy's normal hard/open mask.
|
| 82 |
+
DRIFT_CONTROL_AV_RECIPE = {
|
| 83 |
+
"version": "h3_drift_control_av_v1",
|
| 84 |
+
"context_frames": 39,
|
| 85 |
+
"video_steps": 12,
|
| 86 |
+
"matched_steps": 8,
|
| 87 |
+
"taper_steps": 4,
|
| 88 |
+
"sigma_rule": "next_schedule_sigma_over_current_sigma",
|
| 89 |
+
"mask_quantization": 256,
|
| 90 |
+
"audio": "unchanged_policy_mask",
|
| 91 |
+
"validated_steps": 20,
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
# Optional scene-one color anchor layered onto Drift-Control AV. Only the
|
| 95 |
+
# disposable copied video prefix is touched. Two encodes isolate the desired
|
| 96 |
+
# RGB change from ordinary VAE reconstruction error:
|
| 97 |
+
# E(graded decode) - E(original decode). The low-frequency delta fades from
|
| 98 |
+
# zero at the old overlap edge to full strength beside the generated future.
|
| 99 |
+
# Saved predecessor video/audio latents and the complete audio path remain
|
| 100 |
+
# immutable.
|
| 101 |
+
LATENT_COLOR_CARRY_RECIPE = {
|
| 102 |
+
"version": "h3_latent_color_delta_v1",
|
| 103 |
+
"context_frames": 39,
|
| 104 |
+
"video_steps": 12,
|
| 105 |
+
"anchor": "first_generated_scene_delivered_tail",
|
| 106 |
+
"source": "current_predecessor_delivered_tail",
|
| 107 |
+
"strength": 0.50,
|
| 108 |
+
"max_luma_shift_code_values": 6.0,
|
| 109 |
+
"max_saturation_change": 0.06,
|
| 110 |
+
"spatial_lowpass_kernel": 3,
|
| 111 |
+
"temporal_taper": "full_prefix_smoothstep_zero_to_one",
|
| 112 |
+
"delta_rule": "E(graded_D(z))-E(D(z))",
|
| 113 |
+
"audio": "unchanged",
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
# Boundary-only low-grid experiment reconstructed from a mixed-resolution
|
| 117 |
+
# chain: a 1376x768 target carried a predecessor generated at 1152x640. The
|
| 118 |
+
# Guide recipe spatially reduces the complete saved predecessor video latent,
|
| 119 |
+
# decodes that disposable 5/6 latent at its native 1152x640 canvas, then lets
|
| 120 |
+
# Motion Context restore only the requested RGB tail to the target canvas.
|
| 121 |
+
# This deliberately keeps the nonlinear low-grid VAE decode that a simple RGB
|
| 122 |
+
# resize cannot reproduce. AV keeps its cheaper latent down/up prefix filter.
|
| 123 |
+
# Generated scenes and checkpoint/assembly artifacts keep the Plan canvas.
|
| 124 |
+
CONTEXT_SPATIAL_PROXY_RECIPE = {
|
| 125 |
+
"version": "h3_context_spatial_proxy_v2",
|
| 126 |
+
"scale_numerator": 5,
|
| 127 |
+
"scale_denominator": 6,
|
| 128 |
+
"pixel_alignment": 32,
|
| 129 |
+
"guide_source": "saved_predecessor_video_latent",
|
| 130 |
+
"guide_latent_downsample": "area",
|
| 131 |
+
"guide_decode": "full_low_grid_stream",
|
| 132 |
+
"guide_tail": "delivered_frames",
|
| 133 |
+
"guide_restore": "motion_context_lanczos",
|
| 134 |
+
"latent_downsample": "area",
|
| 135 |
+
"latent_restore": "bilinear",
|
| 136 |
+
"preserve_latent_statistics": False,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
LEGACY_AUDIO_MODE_POLICIES = {
|
| 140 |
+
"source_track": {
|
| 141 |
+
"final_audio": "source",
|
| 142 |
+
"source_reference": "on",
|
| 143 |
+
"generated_continuity": "off",
|
| 144 |
+
},
|
| 145 |
+
"generated_audio": {
|
| 146 |
+
"final_audio": "generated",
|
| 147 |
+
"source_reference": "off",
|
| 148 |
+
"generated_continuity": "on",
|
| 149 |
+
},
|
| 150 |
+
"source_plus_timeline": {
|
| 151 |
+
"final_audio": "source",
|
| 152 |
+
"source_reference": "on",
|
| 153 |
+
"generated_continuity": "on",
|
| 154 |
+
},
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
TRANSITION_PRESETS = {
|
| 158 |
+
"cut": {
|
| 159 |
+
"continuation_mode": "guide",
|
| 160 |
+
"context_length": 0,
|
| 161 |
+
"label": "Cut / Independent",
|
| 162 |
+
},
|
| 163 |
+
"guide": {
|
| 164 |
+
"continuation_mode": "guide",
|
| 165 |
+
"context_length": 22,
|
| 166 |
+
"label": "Guided transition",
|
| 167 |
+
},
|
| 168 |
+
"tone_guide": {
|
| 169 |
+
"continuation_mode": "tone_carry_guide",
|
| 170 |
+
"context_length": 22,
|
| 171 |
+
"label": "Tone-carry guided transition",
|
| 172 |
+
},
|
| 173 |
+
"latent_guide": {
|
| 174 |
+
"continuation_mode": "latent_guide",
|
| 175 |
+
"context_length": 22,
|
| 176 |
+
"label": "Latent-guided transition",
|
| 177 |
+
},
|
| 178 |
+
"detail_guide": {
|
| 179 |
+
"continuation_mode": "tapered_guide",
|
| 180 |
+
"context_length": 22,
|
| 181 |
+
"label": "Detail-preserving guided transition",
|
| 182 |
+
},
|
| 183 |
+
"detail_av": {
|
| 184 |
+
"continuation_mode": "tapered_av",
|
| 185 |
+
"context_length": 39,
|
| 186 |
+
"label": "Detail-preserving AV continuation (experimental)",
|
| 187 |
+
},
|
| 188 |
+
"drift_av": {
|
| 189 |
+
"continuation_mode": "drift_control_av",
|
| 190 |
+
"context_length": 39,
|
| 191 |
+
"label": "Drift-Control AV continuation (experimental)",
|
| 192 |
+
},
|
| 193 |
+
"color_drift_av": {
|
| 194 |
+
"continuation_mode": "color_stable_drift_av",
|
| 195 |
+
"context_length": 39,
|
| 196 |
+
"label": "Color-Stable Drift AV continuation (experimental)",
|
| 197 |
+
},
|
| 198 |
+
"hard_av": {
|
| 199 |
+
"continuation_mode": "masked_av",
|
| 200 |
+
"context_length": 39,
|
| 201 |
+
"label": "Hard continuation",
|
| 202 |
+
},
|
| 203 |
+
"soft_av": {
|
| 204 |
+
"continuation_mode": "audio_feathered_av",
|
| 205 |
+
"context_length": 39,
|
| 206 |
+
"label": "Soft continuation (hard picture, soft audio)",
|
| 207 |
+
},
|
| 208 |
+
"audio_feather_av": {
|
| 209 |
+
"continuation_mode": "audio_feathered_av",
|
| 210 |
+
"context_length": 39,
|
| 211 |
+
"label": "Audio-feathered continuation (legacy alias)",
|
| 212 |
+
},
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
DEPENDENCY_SCOPES = (
|
| 216 |
+
"global_generation",
|
| 217 |
+
"scene_generation",
|
| 218 |
+
"incoming_boundary",
|
| 219 |
+
"assembly_only",
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def migrate_legacy_audio_mode(mode: str) -> dict[str, str]:
|
| 224 |
+
"""Return a new independent audio-policy record for a 0.4 mode."""
|
| 225 |
+
try:
|
| 226 |
+
policy = LEGACY_AUDIO_MODE_POLICIES[str(mode)]
|
| 227 |
+
except KeyError as exc:
|
| 228 |
+
raise ValueError("Unknown legacy H3 audio mode %r." % mode) from exc
|
| 229 |
+
return {"version": AUDIO_POLICY_VERSION, **policy}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def migrate_continuation_mode(mode: str) -> str:
|
| 233 |
+
"""Map retired experimental implementations to a supported mode."""
|
| 234 |
+
value = str(mode)
|
| 235 |
+
if value == "feathered_av_rgb":
|
| 236 |
+
return "feathered_av"
|
| 237 |
+
return value
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def audio_policy(
|
| 241 |
+
final_audio: str,
|
| 242 |
+
source_reference: str,
|
| 243 |
+
generated_continuity: str,
|
| 244 |
+
source_audio_target: str | bool = "off",
|
| 245 |
+
lip_sync_options: dict[str, Any] | None = None,
|
| 246 |
+
) -> dict[str, Any]:
|
| 247 |
+
"""Validate and return one independent 0.5 audio-policy record."""
|
| 248 |
+
final = str(final_audio)
|
| 249 |
+
source = str(source_reference)
|
| 250 |
+
continuity = str(generated_continuity)
|
| 251 |
+
if isinstance(source_audio_target, bool):
|
| 252 |
+
target = "locked" if source_audio_target else "off"
|
| 253 |
+
else:
|
| 254 |
+
target = str(source_audio_target or "off").strip().lower()
|
| 255 |
+
if final not in FINAL_AUDIO_POLICIES:
|
| 256 |
+
raise ValueError("Unknown H3 final-audio policy %r." % final_audio)
|
| 257 |
+
if source not in SOURCE_REFERENCE_POLICIES:
|
| 258 |
+
raise ValueError(
|
| 259 |
+
"Unknown H3 source-reference policy %r." % source_reference)
|
| 260 |
+
if continuity not in GENERATED_CONTINUITY_POLICIES:
|
| 261 |
+
raise ValueError(
|
| 262 |
+
"Unknown H3 generated-continuity policy %r." %
|
| 263 |
+
generated_continuity)
|
| 264 |
+
if target not in SOURCE_AUDIO_TARGET_POLICIES:
|
| 265 |
+
raise ValueError(
|
| 266 |
+
"Unknown H3 source-audio-target policy %r." %
|
| 267 |
+
source_audio_target)
|
| 268 |
+
# A clean source waveform occupying the complete target audio latent is
|
| 269 |
+
# already the strongest possible audio condition. Keeping a separate
|
| 270 |
+
# Ref2VA audio bank or a predecessor audio prefix would describe competing
|
| 271 |
+
# clocks, so the compact switch resolves both axes off canonically.
|
| 272 |
+
if target == "locked":
|
| 273 |
+
source = "off"
|
| 274 |
+
continuity = "off"
|
| 275 |
+
policy = {
|
| 276 |
+
"version": AUDIO_POLICY_VERSION,
|
| 277 |
+
"final_audio": final,
|
| 278 |
+
"source_reference": source,
|
| 279 |
+
"generated_continuity": continuity,
|
| 280 |
+
}
|
| 281 |
+
# Preserve the byte-for-byte v1 spelling for existing workflows and
|
| 282 |
+
# checkpoints when the new opt-in behavior is disabled.
|
| 283 |
+
if target != "off":
|
| 284 |
+
policy["source_audio_target"] = target
|
| 285 |
+
if target == "locked" and lip_sync_options is not None:
|
| 286 |
+
policy["lip_sync_options"] = masked_song_options(lip_sync_options)
|
| 287 |
+
return policy
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def masked_song_options(
|
| 291 |
+
value: dict[str, Any] | None = None,
|
| 292 |
+
*,
|
| 293 |
+
preroll_seconds: float = 1.0,
|
| 294 |
+
lookahead_seconds: float = 0.2,
|
| 295 |
+
audio_denoise: float = 0.0,
|
| 296 |
+
gap_denoise: float = 0.15,
|
| 297 |
+
gate_hold_seconds: float = 0.2,
|
| 298 |
+
voice_fingerprint: str = "",
|
| 299 |
+
) -> dict[str, Any]:
|
| 300 |
+
"""Validate the optional contextual song-latent recipe.
|
| 301 |
+
|
| 302 |
+
The record is stored only for target-locked source audio. Its absence is
|
| 303 |
+
the stable spelling of the pre-feature hard-cut/full-freeze behavior.
|
| 304 |
+
"""
|
| 305 |
+
if value is not None:
|
| 306 |
+
if not isinstance(value, dict):
|
| 307 |
+
raise ValueError(
|
| 308 |
+
"H3 lip-sync options must come from MiniMax H3 Lip-Sync "
|
| 309 |
+
"Options.")
|
| 310 |
+
if value.get("version") != LIP_SYNC_OPTIONS_VERSION:
|
| 311 |
+
raise ValueError("H3 lip-sync options version is missing or obsolete.")
|
| 312 |
+
preroll_seconds = value.get("preroll_seconds", preroll_seconds)
|
| 313 |
+
lookahead_seconds = value.get("lookahead_seconds", lookahead_seconds)
|
| 314 |
+
audio_denoise = value.get("audio_denoise", audio_denoise)
|
| 315 |
+
gap_denoise = value.get("gap_denoise", gap_denoise)
|
| 316 |
+
gate_hold_seconds = value.get(
|
| 317 |
+
"gate_hold_seconds", gate_hold_seconds)
|
| 318 |
+
voice_fingerprint = value.get(
|
| 319 |
+
"voice_fingerprint", voice_fingerprint)
|
| 320 |
+
|
| 321 |
+
def bounded(name: str, raw: Any, low: float, high: float) -> float:
|
| 322 |
+
if isinstance(raw, bool):
|
| 323 |
+
raise ValueError("H3 lip-sync %s must be a number." % name)
|
| 324 |
+
try:
|
| 325 |
+
resolved = float(raw)
|
| 326 |
+
except (TypeError, ValueError) as exc:
|
| 327 |
+
raise ValueError(
|
| 328 |
+
"H3 lip-sync %s must be a number." % name) from exc
|
| 329 |
+
if resolved < low or resolved > high:
|
| 330 |
+
raise ValueError(
|
| 331 |
+
"H3 lip-sync %s must be between %.3g and %.3g." %
|
| 332 |
+
(name, low, high))
|
| 333 |
+
return resolved
|
| 334 |
+
|
| 335 |
+
resolved = {
|
| 336 |
+
"version": LIP_SYNC_OPTIONS_VERSION,
|
| 337 |
+
"preroll_seconds": bounded(
|
| 338 |
+
"pre-roll", preroll_seconds, 0.0, 4.0),
|
| 339 |
+
"lookahead_seconds": bounded(
|
| 340 |
+
"lookahead", lookahead_seconds, 0.0, 2.0),
|
| 341 |
+
"audio_denoise": bounded(
|
| 342 |
+
"sung-region denoise", audio_denoise, 0.0, 1.0),
|
| 343 |
+
"gap_denoise": bounded(
|
| 344 |
+
"gap denoise", gap_denoise, 0.0, 1.0),
|
| 345 |
+
"gate_hold_seconds": bounded(
|
| 346 |
+
"voice-gate hold", gate_hold_seconds, 0.0, 2.0),
|
| 347 |
+
}
|
| 348 |
+
fingerprint = str(voice_fingerprint or "").strip().lower()
|
| 349 |
+
if fingerprint:
|
| 350 |
+
if (len(fingerprint) != 64 or any(
|
| 351 |
+
character not in "0123456789abcdef"
|
| 352 |
+
for character in fingerprint)):
|
| 353 |
+
raise ValueError(
|
| 354 |
+
"H3 lip-sync voice fingerprint must be a SHA-256 hex digest.")
|
| 355 |
+
resolved["voice_fingerprint"] = fingerprint
|
| 356 |
+
return resolved
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def compose_chain_policy(
|
| 360 |
+
audio: dict[str, Any],
|
| 361 |
+
transition: dict[str, Any],
|
| 362 |
+
*,
|
| 363 |
+
audio_context_length: int = DEFAULT_AUDIO_CONTEXT_LENGTH,
|
| 364 |
+
) -> dict[str, Any]:
|
| 365 |
+
"""Combine validated 0.5 policies without changing their stored shape.
|
| 366 |
+
|
| 367 |
+
Plan consumes the two canonical child records and the legacy-compatible
|
| 368 |
+
audio-context value, then persists behavior-only compatibility fields.
|
| 369 |
+
This keeps checkpoint hashes independent of workflow topology.
|
| 370 |
+
"""
|
| 371 |
+
if not isinstance(audio, dict) or audio.get("version") != AUDIO_POLICY_VERSION:
|
| 372 |
+
raise ValueError("H3 Chain Policy requires a current Audio Policy.")
|
| 373 |
+
resolved_audio = audio_policy(
|
| 374 |
+
audio.get("final_audio"), audio.get("source_reference"),
|
| 375 |
+
audio.get("generated_continuity"),
|
| 376 |
+
audio.get("source_audio_target", "off"),
|
| 377 |
+
audio.get("lip_sync_options"))
|
| 378 |
+
if (not isinstance(transition, dict) or
|
| 379 |
+
transition.get("version") != TRANSITION_POLICY_VERSION):
|
| 380 |
+
raise ValueError("H3 Chain Policy requires a current Transition Policy.")
|
| 381 |
+
resolved_transition = transition_policy(
|
| 382 |
+
transition.get("preset"),
|
| 383 |
+
expert_override=bool(transition.get("expert_override", False)),
|
| 384 |
+
continuation_mode=transition.get("continuation_mode"),
|
| 385 |
+
context_length=transition.get("context_length"))
|
| 386 |
+
if isinstance(audio_context_length, bool):
|
| 387 |
+
raise ValueError(
|
| 388 |
+
"H3 Chain Policy audio context must be between 0 and 240 frames.")
|
| 389 |
+
try:
|
| 390 |
+
resolved_audio_context = int(audio_context_length)
|
| 391 |
+
except (TypeError, ValueError) as exc:
|
| 392 |
+
raise ValueError(
|
| 393 |
+
"H3 Chain Policy audio context must be between 0 and 240 frames."
|
| 394 |
+
) from exc
|
| 395 |
+
if resolved_audio_context < 0 or resolved_audio_context > 240:
|
| 396 |
+
raise ValueError(
|
| 397 |
+
"H3 Chain Policy audio context must be between 0 and 240 frames.")
|
| 398 |
+
mode = str(resolved_transition["continuation_mode"])
|
| 399 |
+
context = int(resolved_transition["context_length"])
|
| 400 |
+
carries_generated_audio = (
|
| 401 |
+
resolved_audio["generated_continuity"] == "on"
|
| 402 |
+
and resolved_audio.get("source_audio_target", "off") != "locked"
|
| 403 |
+
and resolved_audio_context > 0
|
| 404 |
+
)
|
| 405 |
+
if (mode in (
|
| 406 |
+
"masked_av", "feathered_av", "audio_feathered_av"
|
| 407 |
+
) and context > 0 and carries_generated_audio
|
| 408 |
+
and context not in AV_TRANSITION_CONTEXT_LENGTHS):
|
| 409 |
+
raise ValueError(
|
| 410 |
+
"H3 AV generated-audio continuity requires an exact shared "
|
| 411 |
+
"video/audio boundary: 39, 90, 141, 192, or 243 context "
|
| 412 |
+
"frames. Set generated continuity off (or audio context to 0) "
|
| 413 |
+
"to test a video-only AV window from 5 frames.")
|
| 414 |
+
return {
|
| 415 |
+
"version": CHAIN_POLICY_VERSION,
|
| 416 |
+
"audio_policy": resolved_audio,
|
| 417 |
+
"transition_policy": resolved_transition,
|
| 418 |
+
"audio_context_length": resolved_audio_context,
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def chain_policy(
|
| 423 |
+
incoming_transition: str,
|
| 424 |
+
final_audio: str,
|
| 425 |
+
source_reference: str,
|
| 426 |
+
generated_continuity: str,
|
| 427 |
+
lock_source_audio: bool = False,
|
| 428 |
+
lip_sync_options: dict[str, Any] | None = None,
|
| 429 |
+
) -> dict[str, Any]:
|
| 430 |
+
"""Build the compact normal-user policy from its semantic choices."""
|
| 431 |
+
preset = str(incoming_transition)
|
| 432 |
+
if preset not in PRIMARY_TRANSITION_PRESETS:
|
| 433 |
+
raise ValueError(
|
| 434 |
+
"H3 Chain Policy incoming transition must be one of %s." %
|
| 435 |
+
(PRIMARY_TRANSITION_PRESETS,))
|
| 436 |
+
resolved_transition = transition_policy(preset)
|
| 437 |
+
return compose_chain_policy(
|
| 438 |
+
audio_policy(
|
| 439 |
+
final_audio, source_reference, generated_continuity,
|
| 440 |
+
"locked" if bool(lock_source_audio) else "off",
|
| 441 |
+
lip_sync_options),
|
| 442 |
+
resolved_transition,
|
| 443 |
+
# Normal authoring keeps picture and generated-audio carry on the
|
| 444 |
+
# same tested boundary. The Advanced Policy keeps that semantic
|
| 445 |
+
# pairing for experimental recipes; independent numeric values remain
|
| 446 |
+
# available only through the 0.4 Legacy Policy Adapter.
|
| 447 |
+
audio_context_length=resolved_transition["context_length"])
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def paired_audio_policy(value: str | bool) -> str:
|
| 451 |
+
"""Normalize a reference-local paired-audio choice or legacy boolean."""
|
| 452 |
+
if isinstance(value, bool):
|
| 453 |
+
return "embedded" if value else "off"
|
| 454 |
+
normalized = str(value).strip().lower()
|
| 455 |
+
if normalized not in PAIRED_AUDIO_POLICIES:
|
| 456 |
+
raise ValueError("Unknown H3 paired-audio policy %r." % value)
|
| 457 |
+
return normalized
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def transition_preset(name: str) -> dict[str, Any]:
|
| 461 |
+
"""Return an isolated resolved transition preset."""
|
| 462 |
+
try:
|
| 463 |
+
preset = TRANSITION_PRESETS[str(name)]
|
| 464 |
+
except KeyError as exc:
|
| 465 |
+
raise ValueError("Unknown H3 transition preset %r." % name) from exc
|
| 466 |
+
return {"version": TRANSITION_POLICY_VERSION, "preset": str(name), **preset}
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def transition_policy(
|
| 470 |
+
preset: str,
|
| 471 |
+
*,
|
| 472 |
+
expert_override: bool = False,
|
| 473 |
+
continuation_mode: str | None = None,
|
| 474 |
+
context_length: int | None = None,
|
| 475 |
+
) -> dict[str, Any]:
|
| 476 |
+
"""Resolve a semantic preset and optional explicit low-level override."""
|
| 477 |
+
resolved = transition_preset(preset)
|
| 478 |
+
expert = bool(expert_override)
|
| 479 |
+
if expert:
|
| 480 |
+
mode = migrate_continuation_mode(continuation_mode)
|
| 481 |
+
if mode not in CONTINUATION_POLICIES:
|
| 482 |
+
raise ValueError(
|
| 483 |
+
"Unknown H3 continuation implementation %r." %
|
| 484 |
+
continuation_mode)
|
| 485 |
+
try:
|
| 486 |
+
context = int(context_length)
|
| 487 |
+
except (TypeError, ValueError) as exc:
|
| 488 |
+
raise ValueError(
|
| 489 |
+
"H3 transition context must be 0 or one of %s." %
|
| 490 |
+
(TRANSITION_CONTEXT_LENGTHS,)) from exc
|
| 491 |
+
if context not in TRANSITION_CONTEXT_LENGTHS:
|
| 492 |
+
raise ValueError(
|
| 493 |
+
"H3 transition context must be 0 or one of %s." %
|
| 494 |
+
(TRANSITION_CONTEXT_LENGTHS,))
|
| 495 |
+
if mode == "latent_guide" and 0 < context < 5:
|
| 496 |
+
raise ValueError(
|
| 497 |
+
"H3 Latent Guide requires at least 5 context frames.")
|
| 498 |
+
if (mode in (
|
| 499 |
+
"masked_av", "feathered_av", "audio_feathered_av"
|
| 500 |
+
) and context > 0
|
| 501 |
+
and context not in VIDEO_ONLY_AV_TRANSITION_CONTEXT_LENGTHS):
|
| 502 |
+
raise ValueError(
|
| 503 |
+
"H3 video-only AV transition experiments require at least "
|
| 504 |
+
"5 context frames (or 0 to disable continuation). Exact "
|
| 505 |
+
"39/90/141/192/243-frame alignment is still required when "
|
| 506 |
+
"generated predecessor audio is carried.")
|
| 507 |
+
if (mode == "tapered_av" and context not in (
|
| 508 |
+
0, int(DETAIL_AV_RECIPE["context_frames"]))):
|
| 509 |
+
raise ValueError(
|
| 510 |
+
"H3 Detail AV currently requires exactly 39 context frames "
|
| 511 |
+
"(or 0 to disable continuation).")
|
| 512 |
+
if (mode in ("drift_control_av", "color_stable_drift_av")
|
| 513 |
+
and context not in (
|
| 514 |
+
0, int(DRIFT_CONTROL_AV_RECIPE["context_frames"]))):
|
| 515 |
+
raise ValueError(
|
| 516 |
+
"H3 Drift-Control AV and Color-Stable Drift AV currently "
|
| 517 |
+
"require exactly 39 context frames (or 0 to disable "
|
| 518 |
+
"continuation).")
|
| 519 |
+
resolved["continuation_mode"] = mode
|
| 520 |
+
resolved["context_length"] = context
|
| 521 |
+
resolved["expert_override"] = expert
|
| 522 |
+
return resolved
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
def source_timeline_shape() -> dict[str, Any]:
|
| 526 |
+
"""Document the required serializable shape without creating media state."""
|
| 527 |
+
return {
|
| 528 |
+
"version": SOURCE_TIMELINE_VERSION,
|
| 529 |
+
"video": {
|
| 530 |
+
"path": "absolute host path or empty",
|
| 531 |
+
"stream_index": 0,
|
| 532 |
+
"native_fps": "positive rational",
|
| 533 |
+
"start_pts_seconds": "number",
|
| 534 |
+
"duration_seconds": "positive number",
|
| 535 |
+
"frame_count_24fps": "non-negative integer",
|
| 536 |
+
},
|
| 537 |
+
"audio": {
|
| 538 |
+
"kind": "embedded | external_path | deferred_tensor | none",
|
| 539 |
+
"path": "absolute host path or empty",
|
| 540 |
+
"stream_index": "non-negative integer or null",
|
| 541 |
+
"sample_rate": "positive integer or null",
|
| 542 |
+
"duration_seconds": "non-negative number",
|
| 543 |
+
},
|
| 544 |
+
"origin": {
|
| 545 |
+
"skip_native_frames": "non-negative integer",
|
| 546 |
+
"skip_seconds": "non-negative number",
|
| 547 |
+
},
|
| 548 |
+
"fingerprints": {
|
| 549 |
+
"video": "sha256 or empty",
|
| 550 |
+
"audio": "sha256 or empty",
|
| 551 |
+
"timeline": "sha256",
|
| 552 |
+
},
|
| 553 |
+
"recovery": {
|
| 554 |
+
"original_available": "boolean",
|
| 555 |
+
"archived_path": "absolute host path or empty",
|
| 556 |
+
"run_owned_audio_path": "absolute host path or empty",
|
| 557 |
+
},
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def scene_dependency_shape() -> dict[str, Any]:
|
| 562 |
+
"""Document the versioned, four-scope checkpoint dependency record."""
|
| 563 |
+
return {
|
| 564 |
+
"version": SCENE_DEPENDENCY_VERSION,
|
| 565 |
+
"scene": "one-based integer",
|
| 566 |
+
"scopes": {scope: "JSON-safe field map" for scope in DEPENDENCY_SCOPES},
|
| 567 |
+
"fingerprints": {scope: "sha256" for scope in DEPENDENCY_SCOPES},
|
| 568 |
+
"generation_hash": "sha256 of scopes except assembly_only",
|
| 569 |
+
}
|
ComfyUI-MiniMaxH3-Context-Loop/deferred_checkpoint_source.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read a pinned processing branch as a deferred H3 source, without promotion."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
from .checkpoint_variants import processing_lineage, processing_stage, validate_processing_lineage
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def editorial_source_manifest(manifest, chain):
|
| 10 |
+
"""Freeze the selected final-cut pictures without promoting generation takes."""
|
| 11 |
+
if manifest.get("presentation_source") or manifest.get("processing_source"):
|
| 12 |
+
return manifest # Already resolved, including a DeRoPE-derived source.
|
| 13 |
+
editorial = chain._manifest_editorial(manifest)
|
| 14 |
+
bases = manifest.get("segments") or []
|
| 15 |
+
pictures = chain._editorial_presentation_segments(
|
| 16 |
+
manifest["run_name"], bases, editorial)
|
| 17 |
+
output = None
|
| 18 |
+
selected = []
|
| 19 |
+
for position, (base, picture) in enumerate(zip(bases, pictures)):
|
| 20 |
+
if picture.get("presentation_media_mode") != "picture_only":
|
| 21 |
+
continue
|
| 22 |
+
scene = int(base["index"])
|
| 23 |
+
metadata, _path = chain._load_checkpoint_revision(
|
| 24 |
+
manifest["run_name"], scene, picture["revision"], verify_artifacts=False)
|
| 25 |
+
resolved = dict(picture)
|
| 26 |
+
compatibility = metadata.get("compatibility") or {}
|
| 27 |
+
geometry = chain.saved_resolution(picture) or compatibility
|
| 28 |
+
if geometry.get("width") and geometry.get("height"):
|
| 29 |
+
resolved["resolution"] = {key: int(geometry[key]) for key in ("width", "height")}
|
| 30 |
+
# A different prompt/reference registry belongs to this ALT, not the
|
| 31 |
+
# first base scene's manifest-level conditioning fingerprint.
|
| 32 |
+
resolved["generation_fingerprint"] = str(compatibility.get("generation_fingerprint") or "")
|
| 33 |
+
if isinstance(metadata.get("scene_dependency"), dict):
|
| 34 |
+
resolved["scene_dependency"] = chain._json_document(metadata["scene_dependency"])
|
| 35 |
+
resolved["sample_rate"] = base.get("sample_rate", 0)
|
| 36 |
+
resolved["presentation_source"] = {
|
| 37 |
+
"mode": "picture_only", "original": chain._json_document(base)}
|
| 38 |
+
if output is None:
|
| 39 |
+
output = chain._json_document(manifest)
|
| 40 |
+
output["segments"][position] = resolved
|
| 41 |
+
selected.append({"scene": scene, "base_revision": base["revision"],
|
| 42 |
+
"alternate_revision": picture["revision"]})
|
| 43 |
+
if not selected:
|
| 44 |
+
return manifest # Keep existing no-ALT source/resume hashes unchanged.
|
| 45 |
+
output["editorial"] = chain._json_document(editorial)
|
| 46 |
+
output["presentation_source"] = {"format": "h3_deferred_editorial_v1", "scenes": selected}
|
| 47 |
+
return output
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def derope_source_manifest(manifest, selection, chain, upscale):
|
| 51 |
+
manifest = editorial_source_manifest(manifest, chain)
|
| 52 |
+
if not isinstance(selection, dict) or selection.get("stage") != "derope":
|
| 53 |
+
raise ValueError("Unknown Checkpoint Manager processing source.")
|
| 54 |
+
root = Path(chain._output_root()).resolve()
|
| 55 |
+
run = root / "h3_chains" / chain._strict_run_name(manifest["run_name"])
|
| 56 |
+
profile = (root / str(selection.get("profile_path") or "")).resolve()
|
| 57 |
+
# Exactly run/upscaled/profile or run/chapters/chapter/upscaled/profile.
|
| 58 |
+
relative = profile.relative_to(run).parts if profile.is_relative_to(run) else ()
|
| 59 |
+
if not ((len(relative) == 2 and relative[0] == "upscaled") or
|
| 60 |
+
(len(relative) == 4 and relative[0] == "chapters" and relative[2] == "upscaled")):
|
| 61 |
+
raise ValueError("DeRoPE source profile is outside the selected run.")
|
| 62 |
+
|
| 63 |
+
def confined(address):
|
| 64 |
+
if not isinstance(address, str) or not address:
|
| 65 |
+
raise ValueError("DeRoPE source has a missing artifact address.")
|
| 66 |
+
path = (root / address).resolve()
|
| 67 |
+
if not path.is_relative_to(profile):
|
| 68 |
+
raise ValueError("DeRoPE source artifact escapes its profile.")
|
| 69 |
+
return path
|
| 70 |
+
|
| 71 |
+
branch = selection.get("branch")
|
| 72 |
+
if not isinstance(branch, dict) or not branch.get("lineage"):
|
| 73 |
+
raise ValueError("Select a saved DeRoPE branch, not an isolated clip.")
|
| 74 |
+
witness = chain._read_json(str(confined(branch.get("path"))))
|
| 75 |
+
if (not isinstance(witness, dict) or witness.get("run_name") != manifest["run_name"] or
|
| 76 |
+
witness.get("profile") != profile.name):
|
| 77 |
+
raise ValueError("DeRoPE branch belongs to another run/profile.")
|
| 78 |
+
if (branch.get("kind") == "metadata" and
|
| 79 |
+
witness.get("format") == "h3_chain_upscale_segment_v1"):
|
| 80 |
+
saved_lineage = witness.get("processing_lineage")
|
| 81 |
+
elif (branch.get("kind") == "manifest" and witness.get("format") in (
|
| 82 |
+
"h3_chain_upscale_manifest_v1", "h3_chain_upscale_partial_manifest_v1")):
|
| 83 |
+
saved_lineage = processing_lineage(witness.get("segments") or [])
|
| 84 |
+
else:
|
| 85 |
+
raise ValueError("Invalid saved DeRoPE branch format.")
|
| 86 |
+
if saved_lineage != branch["lineage"]:
|
| 87 |
+
raise ValueError("Saved DeRoPE branch changed. Select that processing branch again.")
|
| 88 |
+
validate_processing_lineage(saved_lineage)
|
| 89 |
+
by_scene = {int(item["scene"]): item for item in saved_lineage}
|
| 90 |
+
output = chain._json_document(manifest)
|
| 91 |
+
used = []
|
| 92 |
+
for position, original in enumerate(manifest["segments"]):
|
| 93 |
+
index = int(original["index"])
|
| 94 |
+
ref = by_scene.get(index)
|
| 95 |
+
if ref is None:
|
| 96 |
+
continue # Only an absent scene falls back to its selected original.
|
| 97 |
+
revision = str(ref.get("revision") or "")
|
| 98 |
+
if not re.fullmatch(r"[0-9a-f]{32}", revision):
|
| 99 |
+
raise ValueError("Invalid saved DeRoPE revision.")
|
| 100 |
+
path = confined(ref.get("metadata_path"))
|
| 101 |
+
if path != profile / "checkpoints" / ("clip_%04d.%s.json" % (index, revision)):
|
| 102 |
+
raise ValueError("Saved DeRoPE revision address does not match its scene.")
|
| 103 |
+
metadata = chain._read_json(str(path))
|
| 104 |
+
if not isinstance(metadata, dict) or not isinstance(metadata.get("segment"), dict):
|
| 105 |
+
raise ValueError("Saved DeRoPE scene %d has invalid metadata." % index)
|
| 106 |
+
child = metadata.get("segment") or {}
|
| 107 |
+
if (metadata.get("format") != "h3_chain_upscale_segment_v1" or
|
| 108 |
+
metadata.get("run_name") != manifest["run_name"] or
|
| 109 |
+
metadata.get("profile") != profile.name or
|
| 110 |
+
processing_stage(metadata.get("profile_config")) != "derope" or
|
| 111 |
+
child.get("revision") != revision or
|
| 112 |
+
child.get("checkpoint_sha256") != ref.get("checkpoint_sha256")):
|
| 113 |
+
raise ValueError("Saved DeRoPE scene %d identity or stage does not match." % index)
|
| 114 |
+
source_revisions = {original.get("revision"), original.get("adopted_from_revision")}
|
| 115 |
+
if (not child.get("source_revision") or child.get("source_revision") not in source_revisions or
|
| 116 |
+
child.get("source_checkpoint_sha256") != original.get("checkpoint_sha256")):
|
| 117 |
+
raise ValueError("DeRoPE scene %d belongs to a different original take or final-cut ALT. "
|
| 118 |
+
"Select a DeRoPE branch made from the selected picture, or use Original." % index)
|
| 119 |
+
for key in ("raw_frames", "delivered_frames", "prompt", "prompt_hash", "seed", "steps"):
|
| 120 |
+
if child.get(key) != original.get(key):
|
| 121 |
+
raise ValueError("DeRoPE scene %d has different source timing/settings (%s)." % (index, key))
|
| 122 |
+
if child.get("latent_saved") is not True:
|
| 123 |
+
raise ValueError(
|
| 124 |
+
"DeRoPE scene %d has no full latent (preview/continuation tail only). "
|
| 125 |
+
"Render it with save_latent enabled and Recovered AV connected; "
|
| 126 |
+
"or explicitly select Original. No silent fallback." % index)
|
| 127 |
+
for key in ("checkpoint", "segment", "revision_metadata", "prompt_file", "generated_audio"):
|
| 128 |
+
if child.get(key) is not None:
|
| 129 |
+
confined(child[key])
|
| 130 |
+
upscale._verify_upscale_segment(child, index)
|
| 131 |
+
# Retain the original continuation, timing, prompts, cache descriptor
|
| 132 |
+
# and generation fingerprint. Only media/latent identity comes from HQ.
|
| 133 |
+
resolved = dict(original)
|
| 134 |
+
for key in ("blend_segment", "blend_segment_sha256", "blend_frames",
|
| 135 |
+
"generated_audio", "generated_audio_sha256", "resolution"):
|
| 136 |
+
resolved.pop(key, None)
|
| 137 |
+
resolved.update(child)
|
| 138 |
+
resolved["processing_source"] = {
|
| 139 |
+
"stage": "derope", "profile_path": str(profile.relative_to(root)),
|
| 140 |
+
"original": chain._json_document(original),
|
| 141 |
+
}
|
| 142 |
+
resolved.pop("presentation_source", None) # Original owns the ALT/base audio split.
|
| 143 |
+
upscale._validate_processed_latent_header(resolved)
|
| 144 |
+
output["segments"][position] = resolved
|
| 145 |
+
used.append(index)
|
| 146 |
+
if not used:
|
| 147 |
+
raise ValueError("Selected DeRoPE branch has no scenes in this output scope.")
|
| 148 |
+
output["processing_source"] = {"stage": "derope", "scenes": used,
|
| 149 |
+
"fallback": "selected_original", "selection": selection}
|
| 150 |
+
# Geometry is checked per scene by deferred readers. A partial combined
|
| 151 |
+
# DeRoPE+upscale branch can legitimately differ from its original fallback.
|
| 152 |
+
return output
|
ComfyUI-MiniMaxH3-Context-Loop/docs/ADVANCED_WORKFLOWS.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Advanced workflows
|
| 2 |
+
|
| 3 |
+
## Extend an existing video
|
| 4 |
+
|
| 5 |
+
Use **MiniMax H3 Existing Video Context** when scene 1 must continue a decoded
|
| 6 |
+
video rather than start from an empty timeline. The complete experimental model
|
| 7 |
+
is [Extend Existing Video Model Workflow - MiniMax H3](<../example_workflows/Archive/Extend Existing Video Model Workflow - MiniMax H3.json>).
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
Plan ────────────────────────────────┐
|
| 11 |
+
Core Load Video (VIDEO) ─────────────┼→ Existing Video Context → Loop Start
|
| 12 |
+
Other loader IMAGE + AUDIO + FPS ────┘
|
| 13 |
+
H3 audio VAE ─────────────────────────────────────────────→ Loop Context
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
Use exactly one video route:
|
| 17 |
+
|
| 18 |
+
- `source_video` accepts native ComfyUI VIDEO, including embedded audio and
|
| 19 |
+
exact FPS. An explicit audio input overrides embedded audio.
|
| 20 |
+
- `source_frames` accepts IMAGE from VHS or another decoder. Connect optional
|
| 21 |
+
AUDIO and set the real decoded `source_fps`.
|
| 22 |
+
|
| 23 |
+
The adapter normalizes frames to the Plan canvas and H3's 24 fps, uses the last
|
| 24 |
+
`context_length` frames as scene 1's predecessor, and can persist the normalized
|
| 25 |
+
source as a prelude. In head mode:
|
| 26 |
+
|
| 27 |
+
```text
|
| 28 |
+
scene 1 delivered frames = raw frames - imported context frames
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Connect the audio VAE to Loop Context for imported-audio timeline guidance in
|
| 32 |
+
`generated_audio` or `source_plus_timeline`. Visual continuation still works
|
| 33 |
+
without imported audio.
|
| 34 |
+
|
| 35 |
+
With `prepend_original=true`, Assemble places the normalized original before
|
| 36 |
+
generated scenes, including partial output. Arbitrary source codecs and frame
|
| 37 |
+
rates cannot be stream-concatenated safely, so the prelude is encoded once at
|
| 38 |
+
the Plan's `segment_crf`; generated H.264 segments remain stream-copied.
|
| 39 |
+
|
| 40 |
+
The imported tail is fingerprinted. Reconnect the same Plan and source when
|
| 41 |
+
resuming; a changed source correctly invalidates dependent checkpoints.
|
| 42 |
+
|
| 43 |
+
Recommended first settings:
|
| 44 |
+
|
| 45 |
+
```text
|
| 46 |
+
Chain Policy Guide
|
| 47 |
+
encode_mode video
|
| 48 |
+
anchor_mode head
|
| 49 |
+
Loop Trim match_tail true
|
| 50 |
+
Spectrum off
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
Chain Policy derives both visual and generated-audio overlap as 22 frames for
|
| 54 |
+
Guide. Use the Legacy 0.4 Policy Adapter only when this imported-context workflow
|
| 55 |
+
deliberately needs different visual and audio overlap lengths.
|
| 56 |
+
|
| 57 |
+
## Long visual context
|
| 58 |
+
|
| 59 |
+
`56` is a valid advanced context length. It carries 2.33 seconds of motion in
|
| 60 |
+
17 video latent steps, but head mode regenerates and removes all 56 frames from
|
| 61 |
+
every continuation. Start with `22`; use `56` when a long clip's camera move or
|
| 62 |
+
performance genuinely needs more history.
|
| 63 |
+
|
| 64 |
+
## Non-linear visual context
|
| 65 |
+
|
| 66 |
+
Scenes 2 and later normally take visual context from the immediately previous
|
| 67 |
+
timeline scene. **Visual context source** in Plan or Plan Studio can instead
|
| 68 |
+
select any earlier saved scene by stable scene ID:
|
| 69 |
+
|
| 70 |
+
```json
|
| 71 |
+
{
|
| 72 |
+
"id": "scene_5",
|
| 73 |
+
"visual_context_source": "scene_3",
|
| 74 |
+
"context_length": 39,
|
| 75 |
+
"video_blend_frames": 0
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
This changes only the picture-side continuation edge. In this example scene 5
|
| 80 |
+
uses scene 3's retained RGB/video latent, while Generated continuity still
|
| 81 |
+
uses scene 4's audio latent. The timeline, branch ancestry, soundtrack clock,
|
| 82 |
+
and final ordering remain 1 → 2 → 3 → 4 → 5. With head anchoring, the repeated
|
| 83 |
+
scene-3 prefix is removed by Loop Trim, so `video_blend_frames: 0` delivers a
|
| 84 |
+
hard cut from scene 4 to the newly generated part of scene 5.
|
| 85 |
+
|
| 86 |
+
Non-linear visual context requires a zero assembly blend because the visible
|
| 87 |
+
incoming timeline boundary is still scene 4 → scene 5. Checkpoint preflight
|
| 88 |
+
verifies both consumed dependencies independently: the selected visual scene
|
| 89 |
+
and, when enabled, the immediate generated-audio predecessor. Other earlier
|
| 90 |
+
scenes remain assembly-only and do not become false resume blockers.
|
| 91 |
+
|
| 92 |
+
### Selecting a source segment instead of its tail
|
| 93 |
+
|
| 94 |
+
Plan Studio's **Context** tab previews the complete delivered video for every
|
| 95 |
+
visual context block. The highlighted zone is the actual fixed-size context
|
| 96 |
+
window: its width is proportional to the configured frame span. Drag that
|
| 97 |
+
zone across the movie, place it near the player's current frame, or play only
|
| 98 |
+
the selected window. The position snaps to H3's native 17-frame / five-latent-
|
| 99 |
+
step temporal lattice. **Tail (default)**, or **Latest aligned (default)**
|
| 100 |
+
when that block's phase cannot end on the physical tail, removes the override.
|
| 101 |
+
|
| 102 |
+
The stored values are zero-based frame indexes within the source scene's
|
| 103 |
+
delivered video (after that source's own incoming overlap was trimmed):
|
| 104 |
+
|
| 105 |
+
```json
|
| 106 |
+
{
|
| 107 |
+
"id": "scene_5",
|
| 108 |
+
"context_length": 22,
|
| 109 |
+
"visual_context_source": "scene_3",
|
| 110 |
+
"visual_context_start_frame": 119,
|
| 111 |
+
"video_blend_frames": 0
|
| 112 |
+
}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
For an untrimmed source this selects delivered frames 119 through 140 from
|
| 116 |
+
scene 3. Valid numeric starts depend on the source's own trimmed incoming
|
| 117 |
+
prefix, so use Plan Studio instead of hand-authoring an arbitrary frame. The
|
| 118 |
+
backend converts both ends to native latent-step boundaries and slices the
|
| 119 |
+
saved video latent directly. It never replaces that crop with an RGB VAE
|
| 120 |
+
re-encode; RGB Guide/mask consumers use retained frames or a decode-only
|
| 121 |
+
mirror. Generated-audio continuity remains the immediate previous scene's
|
| 122 |
+
tail.
|
| 123 |
+
|
| 124 |
+
## Composed visual context
|
| 125 |
+
|
| 126 |
+
A continuation can use any ordered number of saved picture histories back to
|
| 127 |
+
back. In Plan Studio's **Context → Picture** tab, select the total, the number
|
| 128 |
+
of blocks, and the cumulative H3-native divisions. Every block then exposes
|
| 129 |
+
its own earlier-scene source and exact latent-window selector. The last block
|
| 130 |
+
sits immediately before the new generation:
|
| 131 |
+
|
| 132 |
+
```json
|
| 133 |
+
{
|
| 134 |
+
"id": "scene_5",
|
| 135 |
+
"context_length": 39,
|
| 136 |
+
"visual_context_blocks": [
|
| 137 |
+
{"source": "scene_3", "frames": 5},
|
| 138 |
+
{"source": "scene_4", "frames": 12, "start_frame": 0},
|
| 139 |
+
{"source": "scene_3", "frames": 22}
|
| 140 |
+
],
|
| 141 |
+
"video_blend_frames": 0
|
| 142 |
+
}
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
This example constructs one 39-frame visual prefix:
|
| 146 |
+
|
| 147 |
+
```text
|
| 148 |
+
scene 3 block: 5 RGB frames / 2 video-latent steps
|
| 149 |
+
scene 4 block: 12 RGB frames / 3 video-latent steps
|
| 150 |
+
scene 3 block: 22 RGB frames / 7 video-latent steps
|
| 151 |
+
result: 39 RGB frames / 12 video-latent steps
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
The division selectors expose every valid ordered repartition without building
|
| 155 |
+
an enormous combination menu. For example, three blocks within `39` include
|
| 156 |
+
`5+12+22`, `5+17+17`, `5+29+5`, `17+5+17`, `17+17+5`, and `22+12+5`.
|
| 157 |
+
Changing the count starts from a balanced valid repartition; moving each
|
| 158 |
+
division explores all other native layouts.
|
| 159 |
+
|
| 160 |
+
The blocks are concatenated in their authored order; they are not blended or
|
| 161 |
+
interpolated. Both `22+17` and `17+22` now select source windows on the target
|
| 162 |
+
block's matching temporal phase and concatenate the saved latent crops
|
| 163 |
+
directly. For an inverse layout, the first block's latest aligned crop can end
|
| 164 |
+
before its source movie's physical tail; Plan Studio shows that position.
|
| 165 |
+
Every source may be any earlier scene on the active branch. Sources do not
|
| 166 |
+
need to be chronologically ordered and the same source can appear more than
|
| 167 |
+
once with independent windows. The composed prefix remains one context, so
|
| 168 |
+
its normal head trim or AV mask uses the selected total exactly once.
|
| 169 |
+
|
| 170 |
+
Composition changes only picture context. Generated audio remains one complete
|
| 171 |
+
latent tail from the immediately previous timeline scene, so there is no audio
|
| 172 |
+
splice at the internal visual seam and a scene's independent audio-context
|
| 173 |
+
override still works. Final assembly still follows normal scene order and must
|
| 174 |
+
use `video_blend_frames: 0` at this boundary. Checkpoint preflight verifies
|
| 175 |
+
every selected visual revision plus the immediate audio predecessor when that
|
| 176 |
+
audio continuity is active.
|
| 177 |
+
|
| 178 |
+
Each `visual_context_blocks` item may omit `start_frame` to use its latest
|
| 179 |
+
phase-aligned native crop. The released `visual_context_source` and
|
| 180 |
+
`visual_context_lead_*` one/two-block fields remain accepted for existing
|
| 181 |
+
Plans and checkpoints, but the new builder writes only the ordered list.
|
| 182 |
+
|
| 183 |
+
## Independent audio context
|
| 184 |
+
|
| 185 |
+
Plan Studio's **Context** planner keeps Audio locked by default. This preserves
|
| 186 |
+
the established behavior: picture may use one or more selected windows, while
|
| 187 |
+
generated audio remains one continuous tail from the immediately previous
|
| 188 |
+
timeline scene. Existing plans therefore do not change.
|
| 189 |
+
|
| 190 |
+
Choose **Unlock audio context** to reveal the Audio tab for that scene. It can
|
| 191 |
+
select one earlier saved scene and an exact audio-latent range, or prepend a
|
| 192 |
+
second independently positioned range. The two blocks are sequential context
|
| 193 |
+
excerpts—not a decoded waveform mix—so they can, for example, expose voice
|
| 194 |
+
regions from two characters before the new scene is generated:
|
| 195 |
+
|
| 196 |
+
```json
|
| 197 |
+
{
|
| 198 |
+
"id": "scene_5",
|
| 199 |
+
"audio_context_unlocked": true,
|
| 200 |
+
"audio_context_source": "scene_3",
|
| 201 |
+
"audio_context_start_frame": 0,
|
| 202 |
+
"audio_context_lead_source": "scene_4",
|
| 203 |
+
"audio_context_lead_frames": 5,
|
| 204 |
+
"audio_context_lead_start_frame": 12
|
| 205 |
+
}
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
The lead block is first and `audio_context_source` is nearest generation. Both
|
| 209 |
+
sources may point to the same scene to select two different moments. Positions
|
| 210 |
+
are delivered-frame indexes and snap to crops whose duration is exact on H3's
|
| 211 |
+
40 Hz audio-latent clock; the saved audio tensors are sliced directly without
|
| 212 |
+
decode/re-encode. AV prefix implementations still use the picture context
|
| 213 |
+
duration as their shared target span. Locking Audio again removes these custom
|
| 214 |
+
fields and restores immediate-predecessor continuity.
|
| 215 |
+
|
| 216 |
+
## Last-frame destinations
|
| 217 |
+
|
| 218 |
+
When stock H3 Image to Video supplies `last_frame`, Motion Context preserves
|
| 219 |
+
that target on continuation scenes. The carried repeated head replaces a
|
| 220 |
+
conflicting `first_frame` anchor because both cannot own the same opening
|
| 221 |
+
coordinates.
|
| 222 |
+
|
| 223 |
+
Place official **MiniMax H3 Add Guide** nodes after Loop Context for additional
|
| 224 |
+
scene-local image, video, or audio anchors.
|
| 225 |
+
|
| 226 |
+
## Re-film a synchronized performance
|
| 227 |
+
|
| 228 |
+
The [three-angle guitar workflow](<../example_workflows/Archive/Three-Angle Guitar Ref2VA - EXPERIMENTAL - MiniMax H3.json>)
|
| 229 |
+
uses **Reference Video Prep** to convert native VIDEO or decoded IMAGE/AUDIO
|
| 230 |
+
into exact 24 fps Ref2VA input. Its soundtrack is copied without padding or
|
| 231 |
+
time-stretching, allowing one performance to be generated from multiple camera
|
| 232 |
+
angles in one pass.
|
| 233 |
+
|
| 234 |
+
Reference Video Prep rejects sources shorter than the requested H3-valid length
|
| 235 |
+
instead of silently padding or stretching them.
|
| 236 |
+
|
| 237 |
+
## External stitchers
|
| 238 |
+
|
| 239 |
+
Loop Trim's `images_with_overlap` output exposes part of the repeated visual
|
| 240 |
+
context while leaving its normal images and audio fully trimmed. Connect
|
| 241 |
+
**Current Shot → state** to **Loop Trim → state**. Loop Trim then resolves each
|
| 242 |
+
incoming scene's optional `video_blend_frames` override, or the Plan default
|
| 243 |
+
when the scene value is blank. Scene N controls the N−1→N boundary. Keep it at
|
| 244 |
+
`0` for a hard boundary. The old Plan/default and Current Shot/resolved integer
|
| 245 |
+
outputs remain compatibility sockets only; do not wire either one in a 0.5
|
| 246 |
+
workflow.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/AUDIO_AND_CONTINUITY.md
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Audio and continuity
|
| 2 |
+
|
| 3 |
+
## Choose a generation profile
|
| 4 |
+
|
| 5 |
+
New workflows should use **Generation Profile**, which replaces the individual
|
| 6 |
+
audio switches with explicit intent:
|
| 7 |
+
|
| 8 |
+
| Audio profile | Generation and final-output behavior |
|
| 9 |
+
|---|---|
|
| 10 |
+
| Generate audio | H3 generates sound and carries it between scenes |
|
| 11 |
+
| Generate fresh audio per scene | H3 generates sound without carrying the preceding scene's audio latent |
|
| 12 |
+
| Lip-sync to source audio | The exact source window drives generation; with grouped tracks, vocals drive generation and the full mix remains the final soundtrack |
|
| 13 |
+
| Generate audio from source guide | Source audio is a loose reference while H3 creates the final sound |
|
| 14 |
+
| Use source soundtrack only | The source track is used for assembly without guiding generation |
|
| 15 |
+
| No final audio | The assembled MP4 is silent |
|
| 16 |
+
|
| 17 |
+
Its **Scene continuity** profile separately selects visual continuity,
|
| 18 |
+
independent scenes, a hard picture with protected audio, or a hard picture
|
| 19 |
+
with a smooth audio release. The profile output is the same canonical
|
| 20 |
+
`chain_policy` record used everywhere else and can feed Advanced Policy
|
| 21 |
+
Override before Plan.
|
| 22 |
+
|
| 23 |
+
For exact songs or dialogue, the optional **Lip-Sync Options** node adds audio
|
| 24 |
+
context around each scene before encoding and can use an aligned isolated vocal
|
| 25 |
+
stem to distinguish sung/spoken regions from instrumental gaps. Wire it as:
|
| 26 |
+
|
| 27 |
+
```text
|
| 28 |
+
source vocal stem ─→ Lip-Sync Options voice
|
| 29 |
+
Lip-Sync Options lip_sync_options ─→ Generation Profile lip_sync_options
|
| 30 |
+
Lip-Sync Options voice ────────────→ Chain Context lip_sync_voice
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Select **Lip-sync to source audio** on Generation Profile. The original source
|
| 34 |
+
song still enters through Loop Start or Source Timeline and remains the final
|
| 35 |
+
soundtrack; the optional vocal stem is only a timing mask. With no options node,
|
| 36 |
+
the established hard-cut/full-freeze behavior remains unchanged.
|
| 37 |
+
|
| 38 |
+
The deprecated **Manual Chain Policy (Legacy)** remains loadable for existing
|
| 39 |
+
workflows and unusual combinations that do not fit a named profile. Its legacy
|
| 40 |
+
controls are described below.
|
| 41 |
+
|
| 42 |
+
## Grouped songs: full mix, vocals and instrumental
|
| 43 |
+
|
| 44 |
+
For songs, import the full mix and aligned stems into **Project Asset Carousel**.
|
| 45 |
+
Keep one card as the **Source track**, then use its **Synchronized audio tracks**
|
| 46 |
+
selectors to assign the full mix, vocals, and optional instrumental. Stem cards
|
| 47 |
+
can have **Available to prompts** disabled and still serve the group. No prompt
|
| 48 |
+
tag is required for source-locked lip-sync.
|
| 49 |
+
|
| 50 |
+
Alternatively, connect AUDIO loaders to **H3 Audio Tracks** and feed its
|
| 51 |
+
`source_timeline` output to the existing Source Timeline inputs on Run Manager
|
| 52 |
+
or Loop Start. Use Generation Profile → **Lip-sync to source audio**.
|
| 53 |
+
|
| 54 |
+
- **Full mix supplied:** used unchanged as the final soundtrack; stems are not
|
| 55 |
+
layered over it. Vocals alone supply the locked generation-audio target.
|
| 56 |
+
- **No full mix:** supplied stems are summed once, matching sample rate and
|
| 57 |
+
mono/stereo channels. Gain is reduced only if their sum would clip. The
|
| 58 |
+
synthesized mix is cached for Studio playback; enable Run Manager's audio
|
| 59 |
+
archive for durable recovery together with the original stems.
|
| 60 |
+
- **Missing vocals in a group:** lip-sync reports an error rather than using
|
| 61 |
+
instrumental music as its driver.
|
| 62 |
+
- **Existing single track:** retains its previous routing; use **Reset to
|
| 63 |
+
single track** to remove a Carousel group.
|
| 64 |
+
|
| 65 |
+
All tracks must have the same start and full duration, including silence during
|
| 66 |
+
instrumental sections. They are aligned on the 24 fps source timeline, not
|
| 67 |
+
automatically beat-matched, stretched or offset. Source routing does not perform
|
| 68 |
+
stem separation; provide your own aligned stems.
|
| 69 |
+
|
| 70 |
+
The **Lip-sync** selector in each scene's Studio/editor settings offers
|
| 71 |
+
**Inherit**, **On** and **Off**. On locks that scene to vocals (or the original
|
| 72 |
+
single source). Off disables the source target, loose source reference and
|
| 73 |
+
generated-audio carry for that scene, while leaving the final soundtrack alone.
|
| 74 |
+
Inherit restores the Plan's three audio defaults. Existing unusual combinations
|
| 75 |
+
appear as **Custom** and remain editable with the advanced audio controls.
|
| 76 |
+
|
| 77 |
+
This is a **per-scene** control, not an arbitrary timed on/off lane within one
|
| 78 |
+
scene. Split scenes where you need explicit changes. Vocal silence avoids
|
| 79 |
+
feeding instrumental notes to the lip-sync target, but neither silence nor Off
|
| 80 |
+
guarantees that the model will animate a closed mouth; describe the desired
|
| 81 |
+
action in the prompt. The separate Lip-Sync Options voice input remains an
|
| 82 |
+
optional timing mask, independent of the group's vocal driver.
|
| 83 |
+
|
| 84 |
+
## Manual policy axes
|
| 85 |
+
|
| 86 |
+
Version 0.5 separates three independent decisions and one exact-target switch:
|
| 87 |
+
|
| 88 |
+
| Axis | Values | Meaning |
|
| 89 |
+
|---|---|---|
|
| 90 |
+
| Final audio | `generated`, `source`, `none` | What Assemble places in the final MP4 |
|
| 91 |
+
| Source reference | `on`, `off` | Whether the exact active source window guides H3 |
|
| 92 |
+
| Generated continuity | `on`, `off` | Whether the previous sampled audio latent continues into the next scene |
|
| 93 |
+
| Lock source audio | `on`, `off` | Whether each exact source window occupies the complete target audio latent and is protected from denoising |
|
| 94 |
+
|
| 95 |
+
Set these controls and the default incoming boundary on the deprecated
|
| 96 |
+
**Manual Chain Policy (Legacy)** node. Its output may connect directly to Plan
|
| 97 |
+
or pass through an
|
| 98 |
+
**Advanced Policy Override**. Advanced Policy preserves every audio choice and
|
| 99 |
+
replaces only the incoming transition with a named experimental recipe such as
|
| 100 |
+
Drift-Control AV. Use the **Legacy 0.4 Policy Adapter** only for a genuine 0.4
|
| 101 |
+
import, a raw implementation/context pair, or an independent numeric audio
|
| 102 |
+
overlap. It can also accept an incoming Chain Policy, in which case its legacy
|
| 103 |
+
`audio_mode` is ignored and the modern audio intent is preserved.
|
| 104 |
+
|
| 105 |
+
Policy layers are ordered left to right. Each downstream layer replaces only
|
| 106 |
+
the boundary fields it owns; the last boundary layer wins, while the audio
|
| 107 |
+
record continues unchanged:
|
| 108 |
+
|
| 109 |
+
```text
|
| 110 |
+
Generation Profile → Plan
|
| 111 |
+
Generation Profile → Advanced Policy Override → Plan
|
| 112 |
+
Manual Chain Policy (Legacy) → Plan
|
| 113 |
+
Generation Profile → Legacy 0.4 Policy Adapter → Plan
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
The reverse Advanced/Legacy order is also valid: whichever is nearest Plan
|
| 117 |
+
owns the effective transition. A standalone Legacy Adapter retains exact 0.4
|
| 118 |
+
`audio_mode` behavior for imported workflows.
|
| 119 |
+
|
| 120 |
+
### Per-scene audio behavior
|
| 121 |
+
|
| 122 |
+
Final soundtrack remains a single Chain Policy choice, but the three
|
| 123 |
+
generation-time audio axes can be overridden independently on each scene in
|
| 124 |
+
Plan or Plan Studio:
|
| 125 |
+
|
| 126 |
+
- **Source reference** decides whether that scene receives its exact source
|
| 127 |
+
window as a loose Ref2VA audio reference.
|
| 128 |
+
- **Generated continuity** decides whether that scene carries the preceding
|
| 129 |
+
sampled audio latent.
|
| 130 |
+
- **Lock source audio** places that scene's exact source window in the target
|
| 131 |
+
audio latent and protects it from denoising. Lock wins over the other two
|
| 132 |
+
switches for that scene.
|
| 133 |
+
|
| 134 |
+
Each selector defaults to **Inherit Chain Policy**. Therefore existing plans
|
| 135 |
+
do not change, and only scenes with an explicit override acquire new JSON or a
|
| 136 |
+
new generation dependency. For example, a music video can use a source
|
| 137 |
+
performance reference for scene 1, create fresh generated sound in scene 2,
|
| 138 |
+
and lock an exact source passage in scene 3 while still choosing one global
|
| 139 |
+
final soundtrack:
|
| 140 |
+
|
| 141 |
+
```json
|
| 142 |
+
{
|
| 143 |
+
"shots": [
|
| 144 |
+
{"id": "scene_1", "prompt": "...", "source_reference": "on"},
|
| 145 |
+
{"id": "scene_2", "prompt": "...", "generated_continuity": "off"},
|
| 146 |
+
{"id": "scene_3", "prompt": "...", "source_audio_target": "locked"}
|
| 147 |
+
]
|
| 148 |
+
}
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
The canonical stored values are `on`/`off` for `source_reference` and
|
| 152 |
+
`generated_continuity`, and `locked`/`off` for `source_audio_target`. Omitting
|
| 153 |
+
a key means inherit. Source audio still connects once at Loop Start or through
|
| 154 |
+
Source Timeline; scenes that do not consume it need no rewiring. Changing one
|
| 155 |
+
scene's effective source reference, generated carry, or lock invalidates that
|
| 156 |
+
scene and its descendants, not unrelated earlier checkpoints.
|
| 157 |
+
|
| 158 |
+
For a prerecorded song or dialogue performance that must remain exact, choose
|
| 159 |
+
Source final audio and enable **Lock source audio**. The switch resolves Source
|
| 160 |
+
reference and Generated continuity off: the source is no longer a loose Ref2VA
|
| 161 |
+
audio bank or a carried predecessor prefix. Chain Context VAE-encodes the
|
| 162 |
+
scene-local source window into H3's target audio stream and sets its complete
|
| 163 |
+
denoise mask to zero. Video remains denoisable and can attend to the protected
|
| 164 |
+
audio for timing and lip sync. Source-rate conversion for H3's audio VAE may
|
| 165 |
+
still occur; final assembly with Final audio=source uses the Source Timeline
|
| 166 |
+
track rather than model-decoded audio. For a short voice/timbre
|
| 167 |
+
reference where H3 should generate new words, choose Generated final audio and
|
| 168 |
+
schedule that clip as an ordinary tagged audio reference.
|
| 169 |
+
|
| 170 |
+
Legacy music-video workflows may still connect a full `AUDIO` directly to
|
| 171 |
+
Loop Start instead of building Source Timeline. Loop Start now materializes
|
| 172 |
+
that waveform once under the run's `source_timeline/` folder and carries its
|
| 173 |
+
path-backed recovery descriptor through recursive state, checkpoint metadata,
|
| 174 |
+
and the final manifest. Current Shot, partial review assembly, final assembly,
|
| 175 |
+
and the full-chain SeedVR2 adapter can therefore recover it without another
|
| 176 |
+
audio wire. Existing redundant downstream wires remain compatible when their
|
| 177 |
+
fingerprint matches. A manifest produced before this recovery descriptor was
|
| 178 |
+
added still needs its legacy `source_audio` fallback connected.
|
| 179 |
+
|
| 180 |
+
For normal finishing, leave `audio_source` set to `plan`. `Final audio =
|
| 181 |
+
generated` uses the generated WAV sidecars written by Segment Save, `Final
|
| 182 |
+
audio = source` uses the persisted source track, and `Final audio = none`
|
| 183 |
+
stays silent. The explicit source/generated/none choices on finishing nodes are
|
| 184 |
+
overrides, not required routing steps.
|
| 185 |
+
|
| 186 |
+
Saved 0.4 modes migrate without changing behavior:
|
| 187 |
+
|
| 188 |
+
| Legacy `audio_mode` | Final | Source reference | Generated continuity |
|
| 189 |
+
|---|---|---|---|
|
| 190 |
+
| `generated_audio` | generated | off | on |
|
| 191 |
+
| `source_track` | source | on | off |
|
| 192 |
+
| `source_plus_timeline` | source | on | on |
|
| 193 |
+
|
| 194 |
+
These descriptions apply to `guide`, `tone_carry_guide`, `latent_guide`, and
|
| 195 |
+
`tapered_guide`.
|
| 196 |
+
Tone Carry Guide uses the RGB/VAE route and applies the predecessor's saved,
|
| 197 |
+
direct boundary-tone curve before encoding its context. It falls back to clean
|
| 198 |
+
Guide when no coherent curve was detected and never silently takes the direct
|
| 199 |
+
video-latent path.
|
| 200 |
+
When an older checkpoint predates this curve metadata, resume recovers it from
|
| 201 |
+
the two existing scene videos. The saved checkpoint and sampled AV latent are
|
| 202 |
+
reused unchanged; diffusion is not rerun.
|
| 203 |
+
Latent Guide reuses the generated predecessor's sampled video-latent tail
|
| 204 |
+
directly, while imported or incompatible context falls back to the normal
|
| 205 |
+
RGB/VAE Guide route. Tapered Guide changes only the disposable video context
|
| 206 |
+
passed to the Guide VAE. In the
|
| 207 |
+
experimental `masked_av`, `tapered_av`, `feathered_av`, and
|
| 208 |
+
`drift_control_av` modes, Chain Context always places a video prefix inside the
|
| 209 |
+
target latent. With Generated continuity on it also
|
| 210 |
+
places the matching audio prefix; `masked_av` protects that complete prefix and
|
| 211 |
+
`tapered_av` protects a disposable video-only latent-noise copy of that prefix;
|
| 212 |
+
`feathered_av` progressively denoises its final latent steps. With Generated
|
| 213 |
+
continuity off, the audio mask remains fully open even when final assembly uses
|
| 214 |
+
`source_track`. For recursive scenes, enabled audio carry copies the previous
|
| 215 |
+
sampler's audio latent directly. For scene 1 after Existing Video Context,
|
| 216 |
+
carrying imported audio requires source audio and the H3 audio VAE.
|
| 217 |
+
|
| 218 |
+
With **Lock source audio** on and no Lip-Sync Options, the complete scene-local
|
| 219 |
+
audio mask is zero. Lip-Sync Options can instead encode discarded pre-roll and
|
| 220 |
+
lookahead context around the kept scene ticks. Its Voice-region denoise value
|
| 221 |
+
applies to the whole song when no vocal stem is supplied. With a stem, vocal
|
| 222 |
+
ticks use Voice-region denoise and gaps use Between-phrase denoise; the final
|
| 223 |
+
assembly still uses the unmodified source soundtrack. This composes with every
|
| 224 |
+
picture boundary: Cut and Guide affect only video context, while AV modes
|
| 225 |
+
preserve or taper their video prefix without copying predecessor audio over the
|
| 226 |
+
locked source window.
|
| 227 |
+
|
| 228 |
+
Experimental `drift_control_av` is a recursive picture-only treatment layered
|
| 229 |
+
on the same 39-frame AV prefix. It does not bake random noise into a checkpoint
|
| 230 |
+
or redraw noise between scenes. At each model evaluation, it uses the sampler's
|
| 231 |
+
existing noise field and sets the prefix mask to `next_sigma / current_sigma`.
|
| 232 |
+
The oldest eight of the 12 video-latent steps use that complete ratio; the last
|
| 233 |
+
four multiply it by `.75`, `.50`, `.25`, and `.00`, leaving the actual seam
|
| 234 |
+
clean. The same live mask drives both ComfyUI's inpaint blend and, through an
|
| 235 |
+
apply-model hook, H3's internal per-row timestep labels. Audio remains exact or
|
| 236 |
+
open according to Audio Policy.
|
| 237 |
+
|
| 238 |
+
Select **Drift-Control AV**, connect the H3 MODEL to Chain Context's optional
|
| 239 |
+
`model` input, and use its `model` output for the first or only sampler stage.
|
| 240 |
+
Chain Context stops before scene 1 when a Drift-Control Plan is missing that
|
| 241 |
+
route. For a sigma split that switches models, leave Chain Context's `model`
|
| 242 |
+
input disconnected. Connect the original full schedule (before the split) to
|
| 243 |
+
its `drift_sigmas` input, then place **MiniMax H3 Drift-Control Model Patch
|
| 244 |
+
(Sigma Split)** between each raw model and its sampler. Feed every patch Current
|
| 245 |
+
Shot `state`, Chain Context `latent`, and that same original full sigma
|
| 246 |
+
schedule. This keeps both model loaders independent and sequential. Each patch
|
| 247 |
+
stores only a small CPU sigma tuple and cloned ModelPatcher metadata, not
|
| 248 |
+
another copy of either model's weights.
|
| 249 |
+
|
| 250 |
+
Do not put Differential Diffusion or another dynamic denoise-mask patch on the
|
| 251 |
+
same MODEL path. The initial validated baseline is 39 frames and 20 sampling
|
| 252 |
+
steps; other samplers, schedulers, and step counts remain experimental.
|
| 253 |
+
|
| 254 |
+
The 0.5 **Soft AV** preset selects `audio_feathered_av`: all picture-prefix
|
| 255 |
+
steps remain exact. With Generated continuity on, only the final eight carried
|
| 256 |
+
audio ticks are released with a half-cosine ramp. With Generated continuity
|
| 257 |
+
off, the target audio stays fully denoisable and paired source audio spans the
|
| 258 |
+
complete raw scene window instead of inheriting the delivered-video window.
|
| 259 |
+
This is the tested upstream AV extension recipe. The older
|
| 260 |
+
dual-stream `feathered_av` remains a raw Legacy Adapter override for
|
| 261 |
+
compatibility.
|
| 262 |
+
|
| 263 |
+
AV prefixes must end on both native clocks: 39, 90, 141, 192, or 243 frames.
|
| 264 |
+
The normal 39-frame prefix maps exactly to 12 video latent steps and 65 audio
|
| 265 |
+
steps; 22 frames maps to 36.666... audio steps and is rejected before model
|
| 266 |
+
loading. At 39 frames, legacy raw `feathered_av` fully protects the first 8 video /
|
| 267 |
+
42 audio steps and ramps the final 4 video / 23 audio prefix steps.
|
| 268 |
+
|
| 269 |
+
Chain Policy controls the normal incoming boundary: Cut carries no picture,
|
| 270 |
+
Guide uses 22 clean RGB/VAE guide frames, Tone Carry Guide uses the same RGB
|
| 271 |
+
span with the predecessor's detected tone correction, Latent Guide uses the
|
| 272 |
+
same span from the saved sampled video latent, Detail Guide uses the same span with an
|
| 273 |
+
eight-frame chroma-noise exit taper, Detail AV uses a protected disposable
|
| 274 |
+
39-frame video-latent copy with matched-variance Gaussian noise tapering from
|
| 275 |
+
0.30 to a completely clean boundary while leaving audio exact, Drift-Control
|
| 276 |
+
AV applies schedule-matched noise to a clean carried prefix at every model
|
| 277 |
+
evaluation, Hard AV uses a protected 39-frame prefix, and Soft AV keeps the
|
| 278 |
+
picture exact while feathering only a carried-audio exit. With Generated
|
| 279 |
+
continuity off, all AV presets carry picture only.
|
| 280 |
+
Detail AV v2 is fixed to 39 frames. Its seed and complete recipe enter the
|
| 281 |
+
incoming-boundary dependency fingerprint, its predecessor checkpoint is never
|
| 282 |
+
mutated, and the entire treated prefix is trimmed before delivery. Advanced
|
| 283 |
+
mode may pair either experimental Guide with another Guide context length; 22
|
| 284 |
+
is the published baseline. Mixed plans must still use
|
| 285 |
+
encode/anchor settings compatible with every AV-mask scene.
|
| 286 |
+
|
| 287 |
+
Drift-Control AV v1 is also fixed to 39 frames. Its sigma rule, 8+4 temporal
|
| 288 |
+
taper, mask quantization, audio behavior, and validated-step baseline enter the
|
| 289 |
+
incoming-boundary dependency fingerprint. Changing the mode or recipe therefore
|
| 290 |
+
requires regenerating that incoming scene, while the saved predecessor remains
|
| 291 |
+
unchanged.
|
| 292 |
+
|
| 293 |
+
**Color-Stable Drift AV** is the opt-in latent-domain counterpart to Final
|
| 294 |
+
Assembly's scene-one color stabilizer. It keeps Drift-Control's 39-frame mask,
|
| 295 |
+
but adds only the difference between a VAE encode of a weakly corrected RGB
|
| 296 |
+
decode and an encode of the unchanged RGB decode. The shared VAE reconstruction
|
| 297 |
+
error cancels. That low-frequency delta ramps from zero at the old overlap edge
|
| 298 |
+
to full strength beside the generated future. The first generated scene is the
|
| 299 |
+
anchor; scene 2 is therefore neutral, while scene 3 onward can resist recursive
|
| 300 |
+
exposure/saturation drift. Audio and saved checkpoints remain exact. It costs
|
| 301 |
+
one 39-frame decode and two 39-frame encodes only when a non-neutral correction
|
| 302 |
+
is detected.
|
| 303 |
+
|
| 304 |
+
In Plan Studio, a scene normally chooses Inherit, Cut, Guide, Hard AV, or Soft
|
| 305 |
+
AV. That single choice writes the matching visual and generated-audio overlap.
|
| 306 |
+
The raw per-scene implementation and separate visual/audio context fields are
|
| 307 |
+
under Advanced boundary controls. Plan-wide raw 0.4 defaults belong to the
|
| 308 |
+
Legacy 0.4 Policy Adapter.
|
| 309 |
+
|
| 310 |
+
### Editorial scene placement, gaps, and subtitles
|
| 311 |
+
|
| 312 |
+
Plan Studio can place each generated scene at an exact 24 fps timeline frame.
|
| 313 |
+
The first legal position is the end of the preceding scene, so scene order and
|
| 314 |
+
generation dependencies remain unchanged and clips never overlap. Moving a
|
| 315 |
+
scene later creates uncovered track time; preview shows that interval as black
|
| 316 |
+
and final Assemble renders the same number of black frames.
|
| 317 |
+
|
| 318 |
+
Generated clip audio is moved with its scene and silence is inserted in every
|
| 319 |
+
gap. Source Timeline audio instead remains locked to the absolute project clock,
|
| 320 |
+
so a song continues through black. These positions live in the run's
|
| 321 |
+
`editorial.json`, outside the Plan hash, and do not invalidate checkpoints.
|
| 322 |
+
|
| 323 |
+
Prompt-word alternates use the same non-destructive editorial layer. An enabled
|
| 324 |
+
`alternate_draft` identifies one active base revision, prompt, and seed. Its
|
| 325 |
+
accepted immutable revision is recorded under `replacements` as
|
| 326 |
+
`media_mode: picture_only`; the canonical `clip_NNNN.json` pointer is never
|
| 327 |
+
changed. Final picture consumers resolve that replacement, while generated
|
| 328 |
+
audio and every later scene dependency continue to resolve the base lineage.
|
| 329 |
+
The boundary after an alternate must be a hard cut because a saved incoming
|
| 330 |
+
blend contains frames from the original base take.
|
| 331 |
+
|
| 332 |
+
An audio asset may also store lyrics. Timestamp lines as LRC (`[MM:SS.xx]`) or
|
| 333 |
+
paste SRT, then select that asset in Plan Studio's Subtitles tab. Subtitle
|
| 334 |
+
preview and exported `.srt` use the same absolute editorial clock and optional
|
| 335 |
+
offset; they do not enter prompts or generation fingerprints.
|
| 336 |
+
|
| 337 |
+
### Scheduled boundary spatial proxy
|
| 338 |
+
|
| 339 |
+
`context_spatial_proxy` is an optional **per-scene incoming-boundary** setting.
|
| 340 |
+
It is off when absent, so it can be scheduled only where a long chain begins
|
| 341 |
+
to burn in or needs a controlled spatial reset:
|
| 342 |
+
|
| 343 |
+
```json
|
| 344 |
+
{
|
| 345 |
+
"id": "scene_4",
|
| 346 |
+
"continuation_mode": "masked_av",
|
| 347 |
+
"context_length": 39,
|
| 348 |
+
"context_spatial_proxy": "latent_5_6"
|
| 349 |
+
}
|
| 350 |
+
```
|
| 351 |
+
|
| 352 |
+
`rgb_5_6` is shown as **Low-grid 5/6 · Guide** and is available for Guide,
|
| 353 |
+
Tone Carry Guide, and Detail Guide on scenes 2 and later. It reduces the
|
| 354 |
+
complete saved predecessor video latent (for example, 86×48 becomes 72×40),
|
| 355 |
+
VAE-decodes that disposable stream natively at 1152×640, and selects the
|
| 356 |
+
requested delivered RGB tail. Motion Context then restores that tail to
|
| 357 |
+
1376×768 before the normal Guide encode. This preserves the nonlinear
|
| 358 |
+
low-grid VAE decode observed in the source mixed-resolution experiment; it is
|
| 359 |
+
not merely an RGB resize.
|
| 360 |
+
Because it VAE-decodes the predecessor stream once more, Low-grid Guide uses
|
| 361 |
+
more preparation time and peak memory than native Guide. Schedule it only on
|
| 362 |
+
boundaries where the reset is wanted.
|
| 363 |
+
`latent_5_6` is available for AV modes. It downscales and restores only the
|
| 364 |
+
copied video-prefix latent (86×48 becomes 72×40 and returns to 86×48). It does
|
| 365 |
+
not filter the paired audio prefix. Neither mode resizes generated frames,
|
| 366 |
+
saved checkpoints, predecessor state, or assembly output. The fixed recipe is
|
| 367 |
+
stored in the incoming-scene dependency, so enabling it for scene 4 leaves
|
| 368 |
+
scenes 2 and 3 valid but requires scene 4 and its successors to be regenerated.
|
| 369 |
+
|
| 370 |
+
## Source Timeline wiring
|
| 371 |
+
|
| 372 |
+
Register picture and sound once. New workflows pass a typed descriptor instead
|
| 373 |
+
of repeating a decoded full-track AUDIO wire:
|
| 374 |
+
|
| 375 |
+
```text
|
| 376 |
+
Load Video ─┐
|
| 377 |
+
├→ Source Timeline ─┬→ Preflight / Plan Studio
|
| 378 |
+
Load Audio ─┘ └→ Loop Start → Current Shot
|
| 379 |
+
└→ scene-local source slice
|
| 380 |
+
|
| 381 |
+
Loop End manifest → Assemble (recovers the timeline descriptor)
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
Current Shot requests each overlap-aware scene window from the descriptor.
|
| 385 |
+
Loop Start fingerprints the source so changed media cannot silently resume old
|
| 386 |
+
checkpoints. Path-backed video and audio remain lazy; only the active scene is
|
| 387 |
+
decoded. A tensor-only AUDIO input is normalized once into a run-owned file.
|
| 388 |
+
The source must cover the required delivered timeline; Preflight reports the
|
| 389 |
+
exact shortfall and last complete scene before model loading.
|
| 390 |
+
|
| 391 |
+
The 0.4 Lazy Motion AV Loader fan-out remains accepted as a compatibility route:
|
| 392 |
+
|
| 393 |
+
```text
|
| 394 |
+
Lazy Motion AV Loader source_video ─┬→ Tagged Motion Ref source_video
|
| 395 |
+
└→ Run Manager asset
|
| 396 |
+
Lazy Motion AV Loader source_audio ─┬→ Loop Start
|
| 397 |
+
├→ Current Shot
|
| 398 |
+
├→ Tagged Audio Ref
|
| 399 |
+
└→ Assemble
|
| 400 |
+
Lazy Motion AV Loader skip frames ───→ Tagged Motion Ref skip frames
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
The native VIDEO remains disk-backed. The loader decodes only the complete
|
| 404 |
+
post-skip audio track, which is still required: Loop Start establishes its
|
| 405 |
+
fingerprint and Current Shot maps exact Plan frame windows onto its sample
|
| 406 |
+
clock for H3 audio-latent alignment. Scene-local paired audio from the tagged
|
| 407 |
+
motion reference does not replace this master track.
|
| 408 |
+
|
| 409 |
+
For new workflows, Source Timeline performs that registration without decoding
|
| 410 |
+
the complete audio track or requiring the downstream fan-out.
|
| 411 |
+
|
| 412 |
+
### Tagged Ref2VA source timeline
|
| 413 |
+
|
| 414 |
+
Current Shot's source slice may feed a standalone Tagged Audio Ref. Keep the
|
| 415 |
+
registry fingerprint that returns to Plan independent of that downstream slice:
|
| 416 |
+
|
| 417 |
+
```text
|
| 418 |
+
Plan → Loop Start → Current Shot → Tagged Audio Ref → Tagged Ref2VA
|
| 419 |
+
↑ │
|
| 420 |
+
└──────── picture/reference registry fingerprint ─────┘
|
| 421 |
+
```
|
| 422 |
+
|
| 423 |
+
The canonical topology is:
|
| 424 |
+
|
| 425 |
+
```text
|
| 426 |
+
Load Audio → Source Timeline → Loop Start → Current Shot
|
| 427 |
+
├→ source_audio_slice → Tagged Audio Ref
|
| 428 |
+
└→ state ─────────────→ Tagged Ref2VA
|
| 429 |
+
```
|
| 430 |
+
|
| 431 |
+
The structured scene dependency records the canonical PCM window, so that
|
| 432 |
+
scene—not unrelated future audio—is invalidated when the source changes. Do not
|
| 433 |
+
return the slice-derived audio fingerprint to Plan, because that would create a
|
| 434 |
+
real graph cycle. Current Shot's optional alignment switch changes only the
|
| 435 |
+
reference slice.
|
| 436 |
+
|
| 437 |
+
## Experimental reference-grid alignment
|
| 438 |
+
|
| 439 |
+
For 362 video frames, the exact picture duration is 15.083333 seconds. Stock H3
|
| 440 |
+
creates 603 target audio steps, while an exact-duration audio reference reaches
|
| 441 |
+
604 steps after audio-VAE padding.
|
| 442 |
+
|
| 443 |
+
Current Shot's `align_audio_reference` switch shortens only its Ref2VA output:
|
| 444 |
+
|
| 445 |
+
```text
|
| 446 |
+
362-frame source window: 15.083333 s
|
| 447 |
+
aligned reference slice: 15.070000 s
|
| 448 |
+
reference audio latents: 603, with a 5 ms padded tail in the final step
|
| 449 |
+
```
|
| 450 |
+
|
| 451 |
+
The exact 15.075-second latent boundary still reproduced visual duplication in
|
| 452 |
+
testing; 15.070 seconds did not. The switch therefore uses a 5 ms safety
|
| 453 |
+
undercut. It computes the equivalent sample boundary for the connected sample
|
| 454 |
+
rate, passes shorter audio through unchanged, and never changes the full source
|
| 455 |
+
used by Assemble.
|
| 456 |
+
|
| 457 |
+
This remains experimental model-behavior guidance rather than a proven H3
|
| 458 |
+
architecture requirement. Leave the switch off when comparing against stock
|
| 459 |
+
frame-exact behavior.
|
| 460 |
+
|
| 461 |
+
## Generated audio is always retained
|
| 462 |
+
|
| 463 |
+
When decoded audio is connected to Segment + Checkpoint, every scene receives
|
| 464 |
+
an uncompressed WAV under:
|
| 465 |
+
|
| 466 |
+
```text
|
| 467 |
+
output/h3_chains/<run_name>/generated_audio/
|
| 468 |
+
```
|
| 469 |
+
|
| 470 |
+
Completed assembly also writes `<final-name>.generated.wav` beside the MP4,
|
| 471 |
+
even when the final video uses `source_track`. This keeps H3's ambience, effects,
|
| 472 |
+
and regenerated performance available for post-production.
|
| 473 |
+
|
| 474 |
+
Generated WAV assembly budgets samples from cumulative delivered-video frame
|
| 475 |
+
boundaries, preventing per-scene rounding from accumulating into drift. This
|
| 476 |
+
approach was inspired by **seitanism's**
|
| 477 |
+
[MultiRef implementation](https://github.com/seitanism/ComfyUI-H3-Motion-Context-MultiRef).
|
| 478 |
+
For masked AV, Loop Trim also carries the complete decoded overlap privately
|
| 479 |
+
inside its normal AUDIO output. Segment Save checkpoints it, and the later
|
| 480 |
+
scene owns that interval during combined generated-audio assembly. This keeps
|
| 481 |
+
Soft AV's half-cosine audio release instead of throwing it away at the trim.
|
| 482 |
+
The same ownership rule applies when scene 1 continues an Existing Video
|
| 483 |
+
Context prelude. Legacy checkpoints without the extra tensor keep
|
| 484 |
+
delivered-only assembly.
|
| 485 |
+
|
| 486 |
+
## Continuation trimming
|
| 487 |
+
|
| 488 |
+
Use **MiniMax H3 Context Loop Trim** after decoding. In head mode it removes the
|
| 489 |
+
repeated visual prefix. With `match_tail=true`, it time-conforms the small H3
|
| 490 |
+
grid mismatch to the exact delivered-frame duration and carries the
|
| 491 |
+
full AV overlap privately to Segment Save. Connect Trim's AUDIO output directly;
|
| 492 |
+
there is no second overlap-audio socket.
|
| 493 |
+
|
| 494 |
+
`images_with_overlap` exposes an additional visual stream containing the
|
| 495 |
+
retained repeated context selected by the active scene state. In 0.5 chains,
|
| 496 |
+
wire **Current Shot → state** to **Loop Trim → state**. A scene-level
|
| 497 |
+
`video_blend_frames` value controls the boundary entering that scene; blank
|
| 498 |
+
inherits the Plan default and explicit `0` keeps a hard cut. The manual
|
| 499 |
+
`retain_overlap_frames` input and both blend integer outputs are legacy routes.
|
| 500 |
+
This assembly-only setting does not alter diffusion, the normal clean images
|
| 501 |
+
output, or audio.
|
| 502 |
+
|
| 503 |
+
All continuation modes return the prefix length as `trim_frames`. In AV mask
|
| 504 |
+
modes those leading frames come from target-latent rows rather than persistent
|
| 505 |
+
guide-conditioning rows. They still overlap the preceding scene and must be
|
| 506 |
+
removed from delivered duration, including the feathered portion.
|
| 507 |
+
|
| 508 |
+
## Measure a join
|
| 509 |
+
|
| 510 |
+
Place **MiniMax H3 Context Loop Seam Probe** between the current clip's untrimmed
|
| 511 |
+
audio decode and Loop Trim. Connect the preceding sampler AV latent, H3 audio
|
| 512 |
+
VAE, and the same `trim_frames` value.
|
| 513 |
+
|
| 514 |
+
The AUDIO output is unchanged. The report measures:
|
| 515 |
+
|
| 516 |
+
- correlation across the join;
|
| 517 |
+
- estimated timing offset;
|
| 518 |
+
- broadband level step;
|
| 519 |
+
- low-frequency ambience-floor step.
|
| 520 |
+
|
| 521 |
+
Strongly periodic music can alias by one complete cycle; the report marks that
|
| 522 |
+
as a known limitation. `tests/seam_probe.py` provides the file-based equivalent.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/COMPATIBILITY.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Compatibility
|
| 2 |
+
|
| 3 |
+
## Runtime scope
|
| 4 |
+
|
| 5 |
+
Installing the pack does not globally alter ordinary ComfyUI workflows. Its H3
|
| 6 |
+
conditioning patches activate when a Context Loop Context node executes and
|
| 7 |
+
self-check the live model/layout assumptions before use.
|
| 8 |
+
|
| 9 |
+
The two continuation engines and public masking path are capability-gated:
|
| 10 |
+
|
| 11 |
+
- `guide`, `latent_guide`, and `tapered_guide` prefer native Add Guide /
|
| 12 |
+
MultiRef behavior from ComfyUI PR #15439 and use the existing guarded guide
|
| 13 |
+
fallback only on older builds. Latent Guide directly slices a compatible
|
| 14 |
+
generated predecessor's saved video latent and otherwise falls back to the
|
| 15 |
+
RGB/VAE route. Tapered Guide alters only its disposable RGB context before
|
| 16 |
+
VAE encoding;
|
| 17 |
+
- `masked_av`, `tapered_av`, `feathered_av`, and `drift_control_av` prefer native per-token H3 AV masks from PR
|
| 18 |
+
#15375 and lazily install only missing mask-engine, payload, token-aligned
|
| 19 |
+
inpaint-scale, and legacy sampler-bridge behavior when the masked path
|
| 20 |
+
executes. It follows
|
| 21 |
+
PR #15375 commit `989e7a9`: pixel masks stay intact for final sampler
|
| 22 |
+
blending while internal H3 timesteps use pooled token-grid values. It still
|
| 23 |
+
requires the native #15439 Add Guide / MultiRef core baseline; masked mode
|
| 24 |
+
is not enabled on the older guide-fallback architecture.
|
| 25 |
+
- `drift_control_av` additionally needs current ModelPatcher dynamic-mask and
|
| 26 |
+
apply-model-wrapper APIs. Chain Context installs both hooks on a cloned MODEL
|
| 27 |
+
and refuses an existing dynamic denoise-mask function rather than composing
|
| 28 |
+
two incompatible owners. A sigma-split model switch uses the dedicated
|
| 29 |
+
one-model Drift-Control Model Patch for each raw branch and shares the
|
| 30 |
+
original unsplit sigma schedule; the patch does not copy model weights.
|
| 31 |
+
- `MiniMaxH3ContexMaskedTarget` uses the same native-first per-token AV mask
|
| 32 |
+
layer for arbitrary manual targets. It activates compatibility only when the
|
| 33 |
+
node executes and does not require a separate MODEL patch node.
|
| 34 |
+
|
| 35 |
+
Importing the node pack or running a guide-only workflow does not activate the
|
| 36 |
+
masked runtime compatibility. A partially updated native mask engine is
|
| 37 |
+
rejected rather than mixed with the vendored snapshot.
|
| 38 |
+
|
| 39 |
+
The general masking IDs are deliberately different from
|
| 40 |
+
`ComfyUI-MiniMaxH3-PerRowMasking`, so both folders may be installed during
|
| 41 |
+
migration without one pack replacing the other's registered nodes.
|
| 42 |
+
|
| 43 |
+
After updating ComfyUI or H3 optimization packs, restart the process fully so
|
| 44 |
+
patch ownership is rebuilt cleanly.
|
| 45 |
+
|
| 46 |
+
## H3 Motion Context copies
|
| 47 |
+
|
| 48 |
+
This pack no longer registers the historical public Motion Context ids, so
|
| 49 |
+
NikoDemon80's original pack or seitanism's maintained MultiRef pack may coexist
|
| 50 |
+
without a node-id collision. When seitanism's registered
|
| 51 |
+
`MiniMaxH3MotionContext` is present on native-guide ComfyUI, Chain Context calls
|
| 52 |
+
it for compatible Guide scenes while retaining the same Chain node UI and
|
| 53 |
+
workflow contract.
|
| 54 |
+
|
| 55 |
+
Loop-only capabilities currently remain internal: direct saved-video-latent
|
| 56 |
+
Guide reuse, audio-only continuation, a timeline-audio window longer than the
|
| 57 |
+
visual Guide, and the guarded pre-native-core fallback.
|
| 58 |
+
The adapter also resolves carried-head keyframe collisions and restores exact
|
| 59 |
+
latent-audio placement after the upstream call.
|
| 60 |
+
|
| 61 |
+
The retained engine follows the current provider contract for arbitrary exact
|
| 62 |
+
Guide lengths, interior `target_start` placement, native-run batching, and
|
| 63 |
+
end-aligned 40 Hz source-audio encoding. This keeps a capability-gated fallback
|
| 64 |
+
scene behaviorally consistent instead of silently reverting to the older
|
| 65 |
+
snap-to-grid implementation.
|
| 66 |
+
|
| 67 |
+
Compatible legacy patch copies share ownership markers; the second copy
|
| 68 |
+
normally stands down.
|
| 69 |
+
|
| 70 |
+
If an older compatible copy owns the process first, wire **MiniMax H3 Patch
|
| 71 |
+
Priority** before Context Loop Context. It can replace only a recognized sibling
|
| 72 |
+
implementation. Unknown wrappers fail with an ownership explanation rather
|
| 73 |
+
than being overwritten.
|
| 74 |
+
|
| 75 |
+
## Native MiniMax H3 tokenizer tokens
|
| 76 |
+
|
| 77 |
+
[ComfyUI PR #15808](https://github.com/Comfy-Org/ComfyUI/pull/15808)
|
| 78 |
+
registers the seven additional tokens declared by MiniMax H3's released
|
| 79 |
+
`tokenizer_config.json`: dialogue open/close, cutoff, lyrics open/close, and
|
| 80 |
+
caption open/close. On an older ComfyUI build, this pack installs the same
|
| 81 |
+
token list on the module-local MiniMax Qwen tokenizer during startup and
|
| 82 |
+
refreshes its inverse vocabulary. General Qwen tokenizers are not changed.
|
| 83 |
+
|
| 84 |
+
When core exposes its native `MiniMaxQwenSDTokenizer`, the compatibility hook
|
| 85 |
+
stands down. Restart ComfyUI after updating core so tokenizer ownership is
|
| 86 |
+
detected before any H3 text encoder is loaded.
|
| 87 |
+
|
| 88 |
+
## Native MiniMax H3 Add Guide
|
| 89 |
+
|
| 90 |
+
When ComfyUI provides native **MiniMax H3 Add Guide**, core owns arbitrary
|
| 91 |
+
video/audio guide records and payload merging. This pack retains only the
|
| 92 |
+
marker-gated target-alignment behavior needed for Ref2VA continuation.
|
| 93 |
+
|
| 94 |
+
Place official Add Guide nodes after Loop Context so they append scene-local
|
| 95 |
+
anchors to the already constructed continuation guides.
|
| 96 |
+
|
| 97 |
+
## H3-Multishot and SolAttn
|
| 98 |
+
|
| 99 |
+
- H3-Multishot's recognized AV-bank payload is reused rather than wrapped a
|
| 100 |
+
second time.
|
| 101 |
+
- Kijai's SolAttn H3 Morton observer composes safely in either activation order.
|
| 102 |
+
Recognition is independent of the custom-node folder name: the upstream
|
| 103 |
+
module, path-loaded PR helpers such as `sol_attn_minimax_v2`, renamed copies,
|
| 104 |
+
and nested observer copies are identified by their audited `original_init`
|
| 105 |
+
closure plus read-only `_video_span`/`_SPANS` registration behavior. Merely
|
| 106 |
+
having a similar name or constructor closure is insufficient, so unknown
|
| 107 |
+
layout-mutating wrappers remain rejected.
|
| 108 |
+
- Ref2VA media remains intact when continuation guides are merged.
|
| 109 |
+
- Changed layout assumptions and unknown wrappers fail loudly.
|
| 110 |
+
|
| 111 |
+
Keep Spectrum and other step-skipping systems disabled for baseline continuity
|
| 112 |
+
tests. KJ preview bridging is scoped to the active loop.
|
| 113 |
+
|
| 114 |
+
## Legacy widget widths
|
| 115 |
+
|
| 116 |
+
While any Context Loop node is present on a legacy LiteGraph canvas, the pack
|
| 117 |
+
works around
|
| 118 |
+
[ComfyUI frontend issue #12443](https://github.com/Comfy-Org/ComfyUI_frontend/issues/12443)
|
| 119 |
+
for all visible nodes. A separate Legacy Widget Width Fix node is unnecessary
|
| 120 |
+
but remains compatible.
|
| 121 |
+
|
| 122 |
+
Disable the embedded workaround under **Settings → MiniMax H3 Context Loop →
|
| 123 |
+
Compatibility → Widget widths** if another frontend or extension handles it.
|
| 124 |
+
|
| 125 |
+
## Platform notes
|
| 126 |
+
|
| 127 |
+
- Review and Assemble prefer `ffmpeg` but fall back to bundled PyAV.
|
| 128 |
+
- The Plan's Output action opens paths on the ComfyUI host. On headless or
|
| 129 |
+
remote servers it copies the host path; the browser cannot open an arbitrary
|
| 130 |
+
remote filesystem path on the client machine.
|
| 131 |
+
- Run Manager operates against the ComfyUI host's input/output folders and is
|
| 132 |
+
therefore suitable for Docker and remote deployments.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/FEATURE_TRACEABILITY.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Feature traceability
|
| 2 |
+
|
| 3 |
+
This page answers two questions: **where did a feature come from?** and
|
| 4 |
+
**where is it implemented here?** It is a navigation aid, not a replacement
|
| 5 |
+
for the license notices or Git history.
|
| 6 |
+
|
| 7 |
+
Origin labels have precise meanings:
|
| 8 |
+
|
| 9 |
+
- **Original** — designed and implemented in this repository.
|
| 10 |
+
- **Adapted** — implementation work is based on an identified upstream
|
| 11 |
+
project or algorithm.
|
| 12 |
+
- **Inspired** — an upstream idea or interaction informed a new local
|
| 13 |
+
implementation.
|
| 14 |
+
- **Integrated** — this pack targets or interoperates with an upstream API;
|
| 15 |
+
it does not claim that upstream implementation.
|
| 16 |
+
- **Compatibility** — retained only so older workflows or compatible packs
|
| 17 |
+
continue to work.
|
| 18 |
+
|
| 19 |
+
For copied or adapted work, [Third-party notices](../THIRD_PARTY_NOTICES.md)
|
| 20 |
+
remain the authoritative attribution and license record.
|
| 21 |
+
|
| 22 |
+
## Production loop and authoring
|
| 23 |
+
|
| 24 |
+
| Feature | What users get | Origin | Implementation and evidence |
|
| 25 |
+
|---|---|---|---|
|
| 26 |
+
| Recursive scene loop | One reusable H3 sampling body advances through a scene plan. | **Original**, built on the adapted Motion Context foundation described below. | [`chain_nodes.py`](../chain_nodes.py), [initial loop commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/3a744122ce4cf2a2e611a6f2fd90ac8ec7318070) |
|
| 27 |
+
| Plan and scene editor | Multiline prompts, exact H3 timing, per-scene seeds, settings, and bounded scene ranges. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [`web/h3_chain_plan_editor.js`](../web/h3_chain_plan_editor.js), [visual editor commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/c56bc6e85496d66bf08da426567a9ab42cd5c78c) |
|
| 28 |
+
| Per-scene LoRA model routing | Each scene selects the Base model or one of the scheduler's connected LoRA-patched `MODEL` branches. Route inputs grow progressively from A through Z, Plan and Studio expose only connected routes, and unused branches remain lazy. Existing A-D workflows retain their saved route IDs and connections. | **Original**; interoperates with ComfyUI's standard `MODEL` connections and lazy-input contract without loading or applying LoRAs itself. | [`chain_nodes.py`](../chain_nodes.py), [`web/h3_chain_lora_scheduler.js`](../web/h3_chain_lora_scheduler.js), [`web/h3_lora_scheduler_core.mjs`](../web/h3_lora_scheduler_core.mjs), [`web/h3_chain_plan_editor.js`](../web/h3_chain_plan_editor.js), [`web/h3_chain_plan_studio.js`](../web/h3_chain_plan_studio.js), [progressive routing commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/01540d38434d2f7ecbacbcd3a43e8c1da01ceeb8) |
|
| 29 |
+
| Strict H3 prompt authoring | Both prompt editors provide mode-aware section validation/repair, alignment helpers, completions, diagnostics, counts, and Plain/Rich presentation. | **Backported** from the original standalone [H3 Prompt IDE](https://github.com/ethanfel/ComfyUI-H3-Prompt-IDE), which itself was adapted from this repository's editor. | [`web/h3_prompt_schema_core.mjs`](../web/h3_prompt_schema_core.mjs), [`web/h3_prompt_schema_ui.mjs`](../web/h3_prompt_schema_ui.mjs), [`web/h3_prompt_completion_core.mjs`](../web/h3_prompt_completion_core.mjs) |
|
| 30 |
+
| Review, retry, and reroll | Every rendered scene can be approved, edited and retried, rerolled, or used to stop with a valid partial run. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [`web/h3_chain_review_final.js`](../web/h3_chain_review_final.js), [Review Gate commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/6bb0082edf66ce6b199dd165644868fb2ee66a39) |
|
| 31 |
+
| Checkpoints, resume, and assembly | Accepted scenes are saved atomically and assembled from disk without a cumulative image tensor. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [`run_manager.py`](../run_manager.py), [resumable loop commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/3a744122ce4cf2a2e611a6f2fd90ac8ec7318070) |
|
| 32 |
+
| Run Manager and prompt history | Saved plans, branching prompt revisions, archived loader assets, and run restoration. | **Original** | [`run_manager.py`](../run_manager.py), [`prompt_history.py`](../prompt_history.py), [Run Manager](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/2695a9a950511c0f42e95052fcd82b98046c1476), [prompt history](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/5543ff73469a663ac4c07bb6f6ceb9d462f6973b) |
|
| 33 |
+
| Plan Studio and Rich Scene Prompt Editor | Optional timeline authoring, source comparison, richer prompt presentation, and optimizer connections. | **Original, experimental UI**; selected quick-reference and dialogue interactions are **inspired** by `ComfyUI-MiniMaxH3-Easy`. | [`web/h3_chain_plan_studio.js`](../web/h3_chain_plan_studio.js), [`web/h3_chain_rich_scene_prompt_editor.js`](../web/h3_chain_rich_scene_prompt_editor.js), [notice](../THIRD_PARTY_NOTICES.md#comfyui-minimaxh3-easy) |
|
| 34 |
+
| Preflight and structured dependencies | Model-free checks explain source length, references, compatibility, and exactly which change invalidates a saved scene. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [`contracts_v05.py`](../contracts_v05.py), [preflight](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/855c06e6341743246052b2d8530b26da850e689c), [scene dependencies](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/100e65861995b1a59d94ef73b334120e6835988d) |
|
| 35 |
+
| PNG/WAV re-decode | Saved scene latents can be decoded later into lossless PNG sequences, a synchronized PCM WAV, or both. Picture and audio VAEs are independently optional; cached integrity checks, bounded parallel atomic PNG saving, progress, timings, trim/order alignment, and partial recovery remain available. | **Original**; chunked CPU conversion and threaded image saving are **inspired** by [`ethanfel/ComfyUI-JSON-Dynamic`](https://github.com/ethanfel/ComfyUI-JSON-Dynamic)'s Fast Absolute Saver. | [`chain_nodes.py`](../chain_nodes.py), [PNG export commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/d1dd4f7daae15b48a31ec86cc54d0c75de20a769) |
|
| 36 |
+
|
| 37 |
+
## Continuity, audio, and editing
|
| 38 |
+
|
| 39 |
+
| Feature | What users get | Origin | Implementation and evidence |
|
| 40 |
+
|---|---|---|---|
|
| 41 |
+
| H3 Motion Context foundation | Previous frames condition the next scene; last-frame targets, 56-frame context, and seam diagnostics remain available. | **Adapted** from [`NikoDemon80/ComfyUI-H3-Motion-Context`](https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context). | [`nodes.py`](../nodes.py), [`probe_node.py`](../probe_node.py), [`patch_layout.py`](../patch_layout.py), [notice](../THIRD_PARTY_NOTICES.md#comfyui-h3-motion-context) |
|
| 42 |
+
| Guide and Cut transitions | Clean RGB/VAE context or no carried picture, selected by an incoming-scene policy. Compatible Guide scenes transparently use the installed upstream Motion Context provider; the retained fallback supports exact arbitrary lengths, interior placement, and exact-grid source audio. | Local policy and compatibility adapter are **Original**; low-level Guide construction and the exact 40 Hz audio-grid helper are **Integrated/adapted** from [`seitanism/ComfyUI-H3-Motion-Context-MultiRef`](https://github.com/seitanism/ComfyUI-H3-Motion-Context-MultiRef), with native placement from [ComfyUI PR #15439](https://github.com/Comfy-Org/ComfyUI/pull/15439). | [`chain_nodes.py`](../chain_nodes.py), [`motion_context_upstream.py`](../motion_context_upstream.py), [`nodes.py`](../nodes.py), [`h3_audio_grid.py`](../h3_audio_grid.py), [`contracts_v05.py`](../contracts_v05.py), [notice](../THIRD_PARTY_NOTICES.md#comfyui-h3-motion-context-multiref) |
|
| 43 |
+
| Tone Carry Guide | Detects a coherent boundary tone difference and applies a saved correction to the next disposable RGB guide. | **Original, experimental** | [`chain_nodes.py`](../chain_nodes.py), [implementation commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/0b2068c238db527910aa727ed4e7ded0bb73af20) |
|
| 44 |
+
| Latent Guide | Reuses the predecessor's compatible sampled video-latent tail and falls back to RGB/VAE Guide when needed. | **Original, opt-in** | [`chain_nodes.py`](../chain_nodes.py), [implementation commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/e211007efb7b872d3dd86d161524ef59f6a9d822) |
|
| 45 |
+
| Detail Guide | Applies a deterministic, tapered chroma-noise treatment to a disposable Guide copy. | **Adapted, experimental** from [`MacroSony/minimax-h3-chained-character-swap`](https://github.com/MacroSony/minimax-h3-chained-character-swap). | [`chain_nodes.py`](../chain_nodes.py), [local integration](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/c797345932d5f8492d8c8f967a73d507b65b5375), [notice](../THIRD_PARTY_NOTICES.md#minimax-h3-chained-character-swap) |
|
| 46 |
+
| Hard AV and Soft AV | Carries an exact protected picture prefix; optionally carries exact or audio-feathered generated sound. | **Adapted** from [`seitanism/ComfyUI-H3-Motion-Context-MultiRef`](https://github.com/seitanism/ComfyUI-H3-Motion-Context-MultiRef). Per-token masking is **Integrated** with [ComfyUI PR #15375](https://github.com/Comfy-Org/ComfyUI/pull/15375). | [`masked_context.py`](../masked_context.py), [`h3_mask_compat.py`](../h3_mask_compat.py), [masked AV commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/e4ec458af0063be7774dae8dc3575c8c96248935), [notices](../THIRD_PARTY_NOTICES.md#comfyui-h3-motion-context-multiref) |
|
| 47 |
+
| Detail AV | Adds matched-variance latent noise to a disposable Hard AV picture prefix, tapering to a clean boundary while leaving audio exact. | **Adapted, experimental** from [`beijinren/ComfyUI-H3-Context-Noise`](https://github.com/beijinren/ComfyUI-H3-Context-Noise). | [`masked_context.py`](../masked_context.py), [local integration](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/72a4e449425c9b4174cabb0eda4da3eeed6d78de), [notice](../THIRD_PARTY_NOTICES.md#comfyui-h3-context-noise) |
|
| 48 |
+
| Drift-Control AV | Applies the sampler's existing noise field to a clean carried video prefix at the next scheduler sigma, with an 8+4 clean-seam mask shared by sampler blending and H3 row timesteps. | **Original, experimental integration** built on ComfyUI's dynamic denoise-mask and H3 per-token mask APIs. | [`drift_control.py`](../drift_control.py), [`chain_nodes.py`](../chain_nodes.py) |
|
| 49 |
+
| Color-Stable Drift AV | Adds a tapered, low-frequency `E(graded D(z)) - E(D(z))` scene-one color delta to Drift-Control's disposable copied video prefix; audio and saved latents remain immutable. | **Original, experimental integration.** | [`latent_color_carry.py`](../latent_color_carry.py), [`masked_context.py`](../masked_context.py), [`chain_nodes.py`](../chain_nodes.py) |
|
| 50 |
+
| Reference Video Fade | Keeps complete native Ref2VA video blocks at full early influence, then applies a full-schedule half-cosine value-row fade while preserving still refs, Qwen presentation, audio, guides, targets, and an existing attention backend. | **Original, experimental.** | [`reference_video_fade.py`](../reference_video_fade.py), [design and wiring](REFERENCE_VIDEO_FADE.md) |
|
| 51 |
+
| Audio policies and exact timing | Final soundtrack, source reference, and generated continuity are independent; cumulative frame boundaries prevent long-run A/V rounding drift. | Policy contract is **Original**. Cumulative audio budgeting is **Inspired** by MultiRef; exact AV timing paths are **Adapted** from later MultiRef updates. | [`av_timing.py`](../av_timing.py), [`chain_nodes.py`](../chain_nodes.py), [audio policy](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/d47523cac69aacd8eed2bd82717771ba49751f7a), [timing integration](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/06045d4aca71316269bbc32b6add6fc843047c1d), [notice](../THIRD_PARTY_NOTICES.md#comfyui-h3-motion-context-multiref) |
|
| 52 |
+
| General masked editing | Loop-aware source AV targets, tracked-mask slicing, 32px cell preview, audio preservation, inpaint/outpaint, and mask composition. | **Adapted** from [`ethanfel/ComfyUI-MiniMaxH3-PerRowMasking`](https://github.com/ethanfel/ComfyUI-MiniMaxH3-PerRowMasking); mask execution uses the PR #15375 integration instead of the earlier model wrapper. | [`masking_nodes.py`](../masking_nodes.py), [`source_av_target.py`](../source_av_target.py), [`masking_ops.py`](../masking_ops.py), [local integration](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/174e8d2dabba6892092300c67286e16aa0dfbbf0), [notice](../THIRD_PARTY_NOTICES.md#comfyui-minimaxh3-perrowmasking) |
|
| 53 |
+
| Exact causal mask conversion | Thin or moving masks are max-reduced through H3's causal frame groups and 2x2 latent-token cells instead of being weakened by temporal interpolation. | **Adapted** from [`drozbay/MaskVidExperiments`](https://github.com/drozbay/MaskVidExperiments). | [`masking_ops.py`](../masking_ops.py), [`masking_nodes.py`](../masking_nodes.py), [notice](../THIRD_PARTY_NOTICES.md#maskvidexperiments-causal-mask-conversion) |
|
| 54 |
+
| Master-audio continuation and two-ended AV bridge | Protects exact soundtrack windows, optionally carries a video prefix, or preserves both clip endpoints while generating the gap. | **Adapted** from MultiRef Updates 4–6. | [`master_audio_context.py`](../master_audio_context.py), [`masked_bridge.py`](../masked_bridge.py), [notice](../THIRD_PARTY_NOTICES.md#comfyui-h3-motion-context-multiref) |
|
| 55 |
+
| Boundary blending and partial recovery | Scene-specific overlap blends also survive interrupted-run assembly. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [frame-exact blending](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/0e4f070e6d95f6288022281cf2b0d0b337443907), [recovery schedules](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/73bfb911871bd593d764479fb256bf1925db26a6) |
|
| 56 |
+
|
| 57 |
+
## References, media, and interoperability
|
| 58 |
+
|
| 59 |
+
| Feature | What users get | Origin | Implementation and evidence |
|
| 60 |
+
|---|---|---|---|
|
| 61 |
+
| Prompt-driven tagged references | Stable `@tags` activate only the pictures, videos, motion, and audio used by the current scene. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [`web/h3_reference_autoconnect.js`](../web/h3_reference_autoconnect.js), [v0.4 tagged workflow commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/c21bebd0eb0d72e6c30d4389ab0f1ec0a0bfc9d6) |
|
| 62 |
+
| Qwen-only semantic pictures and timed anchors | Bare `#picture` adds an untimed Qwen visual; `#picture[time]` adds explicit approximate placement or a picture-storyboard cue. Neither creates a native VAE reference. | **Original, experimental** | [`chain_nodes.py`](../chain_nodes.py), [syntax](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/73af736ae9d2e59355e55b3b0c17311e651587aa), [conditioning](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/1355c390e2ead60c19a45024220b50d5d6ebd25f), [storyboard mode](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/11783a3d57982dc3bd00fff4f55c5d2787ea65d4) |
|
| 63 |
+
| Source Timeline and lazy motion media | Register source media once, decode only the active scene window, and retain exact FPS/PTS and recovery provenance. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [disk-backed loader](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/9438c67e3eb10680ed5d9880448936c0908f50f2), [typed timeline](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/f168555e5ff98ec2ec866be2e75a603ca0a6fc11) |
|
| 64 |
+
| Existing-video continuation | Scene 1 can continue an imported clip and optionally prepend the normalized original during assembly. | **Original** | [`chain_nodes.py`](../chain_nodes.py), [workflow commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/a7ebfa7b3bd61458e7efe162165bb1c0ba8a65f1) |
|
| 65 |
+
| Legacy scheduled references | Numeric scene selectors remain available for old graphs and explicit schedules. | **Original, compatibility route** | [`chain_nodes.py`](../chain_nodes.py), [initial schedule commit](https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop/commit/6b3a36809f53662aaacf1480716ea62f2345af46) |
|
| 66 |
+
| H3-Multishot and SolAttn coexistence | Known external payload and attention wrappers remain active instead of being overwritten. | **Integrated compatibility**; SolAttn defensive detection includes a contribution by `tsolful` in repository PR #16. | [`patch_layout.py`](../patch_layout.py), [`patch_payload.py`](../patch_payload.py), [compatibility guide](COMPATIBILITY.md) |
|
| 67 |
+
| Legacy widget-width repair | Older serialized widgets remain usable in current ComfyUI frontends. | **Adapted** with permission from [`pekkAi-dev/ComfyUI-LegacyWidgetWidthFix`](https://github.com/pekkAi-dev/ComfyUI-LegacyWidgetWidthFix). | [`web/h3_legacy_widget_width_fix.js`](../web/h3_legacy_widget_width_fix.js), [notice](../THIRD_PARTY_NOTICES.md#comfyui-legacywidgetwidthfix) |
|
| 68 |
+
|
| 69 |
+
## Example and media provenance
|
| 70 |
+
|
| 71 |
+
Example-specific prompts, images, footage, and licensing are recorded next to
|
| 72 |
+
the workflow that uses them:
|
| 73 |
+
|
| 74 |
+
- [Workflow catalog and prompt sources](../example_workflows/README.md)
|
| 75 |
+
- [Bundled example asset licenses](../example_workflows/assets/README.md)
|
| 76 |
+
- [Complete third-party notices](../THIRD_PARTY_NOTICES.md)
|
| 77 |
+
|
| 78 |
+
When adding a feature from another project, update this page and
|
| 79 |
+
`THIRD_PARTY_NOTICES.md` in the same change. Record the upstream repository,
|
| 80 |
+
author, revision, license, local files, and whether the relationship is
|
| 81 |
+
adapted, inspired, integrated, or compatibility-only.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/GETTING_STARTED.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Getting started
|
| 2 |
+
|
| 3 |
+
This guide takes the shortest path from a clean install to one reviewed,
|
| 4 |
+
assembled video.
|
| 5 |
+
|
| 6 |
+
## 1. Install the packs
|
| 7 |
+
|
| 8 |
+
From `ComfyUI/custom_nodes`:
|
| 9 |
+
|
| 10 |
+
```bash
|
| 11 |
+
git clone https://github.com/seitanism/ComfyUI-H3-Motion-Context-MultiRef.git
|
| 12 |
+
git clone https://github.com/ethanfel/ComfyUI-MiniMaxH3-Contex-Loop.git
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
Restart ComfyUI. Use a current ComfyUI build with native **Add Guide for
|
| 16 |
+
MiniMax H3** support. `ffmpeg` is recommended for preview and assembly; PyAV is
|
| 17 |
+
the fallback where supported.
|
| 18 |
+
|
| 19 |
+
You also need your own MiniMax H3 diffusion model, text encoder, video VAE, and
|
| 20 |
+
audio VAE. They are not included in this repository.
|
| 21 |
+
|
| 22 |
+
## 2. Open the smallest matching example
|
| 23 |
+
|
| 24 |
+
| Your starting material | Recommended first workflow |
|
| 25 |
+
|---|---|
|
| 26 |
+
| Text only | [T2V Normal](<../example_workflows/T2V Normal - MiniMax H3 0.6.json>) |
|
| 27 |
+
| One opening image | [I2V Normal](<../example_workflows/I2V Normal - MiniMax H3 0.6.json>) |
|
| 28 |
+
| First and last images | [FL2V Normal](<../example_workflows/FL2V Normal - MiniMax H3 0.6.json>) |
|
| 29 |
+
| Prompt-selected references | [Ref2V Tagged](<../example_workflows/Ref2V Tagged - MiniMax H3 0.6.json>) |
|
| 30 |
+
| Existing video | [Masked AV Extension — Single Clip](<../example_workflows/Masked AV Extension - Single Clip - MiniMax H3 0.6.json>) |
|
| 31 |
+
|
| 32 |
+
Open or drag the JSON into ComfyUI. Start with a **Normal** workflow. Studio is
|
| 33 |
+
an optional editorial interface, not a simpler sampler.
|
| 34 |
+
|
| 35 |
+
Some examples use bundled media. Copy those files from
|
| 36 |
+
[`example_workflows/assets/`](../example_workflows/assets/) to `ComfyUI/input/`
|
| 37 |
+
before opening the workflow.
|
| 38 |
+
|
| 39 |
+
## 3. Resolve models and missing nodes
|
| 40 |
+
|
| 41 |
+
Set the four model loaders:
|
| 42 |
+
|
| 43 |
+
| Loader | Select |
|
| 44 |
+
|---|---|
|
| 45 |
+
| **Load Diffusion Model** | MiniMax H3 model |
|
| 46 |
+
| **Load CLIP** | H3 text encoder |
|
| 47 |
+
| **Load VAE — Video** | H3 video VAE |
|
| 48 |
+
| **Load VAE — Audio** | H3 audio VAE |
|
| 49 |
+
|
| 50 |
+
If ComfyUI reports a missing `MiniMaxH3MotionContext` node, install or update
|
| 51 |
+
the MultiRef companion pack and restart. If it reports missing native H3 nodes,
|
| 52 |
+
update ComfyUI.
|
| 53 |
+
|
| 54 |
+
Deferred-upscale examples need extra packs. Their requirements are listed in
|
| 55 |
+
the [workflow catalog](../example_workflows/README.md#deferred-upscale).
|
| 56 |
+
|
| 57 |
+
## 4. Edit only the first-run controls
|
| 58 |
+
|
| 59 |
+
### Plan
|
| 60 |
+
|
| 61 |
+
For a new graph, use **MiniMax H3 Plan (Modern)**. It keeps the established
|
| 62 |
+
vertical scene-column editor but groups its smaller control set into Project,
|
| 63 |
+
Canvas, Generation defaults, and Delivery. Its Generation Profile connection
|
| 64 |
+
is required, and it has no legacy context, audio, or continuation fallbacks.
|
| 65 |
+
|
| 66 |
+
The original **MiniMax H3 Context Loop Plan** remains unchanged so old workflows
|
| 67 |
+
open safely. After connecting Generation Profile, right-click it and choose
|
| 68 |
+
**Upgrade to Modern Plan…** to preserve its scenes, supported settings,
|
| 69 |
+
position, and links.
|
| 70 |
+
|
| 71 |
+
Set these values first:
|
| 72 |
+
|
| 73 |
+
| Control | First-run choice |
|
| 74 |
+
|---|---|
|
| 75 |
+
| `run_name` | A short unique name, for example `red_kite_test_01` |
|
| 76 |
+
| Width / height | Keep the example defaults |
|
| 77 |
+
| Scene Plan | Replace the sample scene prompts |
|
| 78 |
+
| Default duration | Keep the example default until the graph works |
|
| 79 |
+
|
| 80 |
+
`run_name` is the identity of the production. Reusing it exposes the same
|
| 81 |
+
checkpoint history; changing it starts a separate run.
|
| 82 |
+
|
| 83 |
+
Use the visual Scene Plan or Scene Prompt Editor for normal work. Raw Plan JSON
|
| 84 |
+
is an import/export and advanced-editing surface.
|
| 85 |
+
|
| 86 |
+
### Continuity and audio
|
| 87 |
+
|
| 88 |
+
For a new graph, connect **Generation Profile** to Plan and begin with:
|
| 89 |
+
|
| 90 |
+
| Control | Recommended value |
|
| 91 |
+
|---|---|
|
| 92 |
+
| Scene continuity | **Visual continuity** |
|
| 93 |
+
| Audio profile | **Generate audio** |
|
| 94 |
+
|
| 95 |
+
Some shipped examples still contain the compatible **Manual Chain Policy
|
| 96 |
+
(Legacy)** node. Its saved Guide/generated defaults are safe for a first run;
|
| 97 |
+
you do not need to rebuild the workflow before testing it.
|
| 98 |
+
|
| 99 |
+
### Review Gate
|
| 100 |
+
|
| 101 |
+
Keep `enabled = true`. Start with `candidate_count = 1`. Multiple candidates
|
| 102 |
+
are useful after the basic workflow is working.
|
| 103 |
+
|
| 104 |
+
## 5. Understand the disabled nodes
|
| 105 |
+
|
| 106 |
+
Most generation examples include two grey or muted recovery nodes:
|
| 107 |
+
|
| 108 |
+
```text
|
| 109 |
+
[MUTED] Load Manifest ──> [MUTED] Assemble — recovery
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
They are intentionally present but do not execute during generation. Leave
|
| 113 |
+
them muted for the first run. The active Assemble node connected to Loop End
|
| 114 |
+
handles normal final assembly.
|
| 115 |
+
|
| 116 |
+
A bypassed node is different: it forwards a compatible input without applying
|
| 117 |
+
its operation. Optional attention overrides in some upscale examples are
|
| 118 |
+
bypassed because they are not safe on every system.
|
| 119 |
+
|
| 120 |
+
See the [disabled-node legend](NODE_REFERENCE.md#how-disabled-nodes-are-shown)
|
| 121 |
+
for the notation used throughout these docs.
|
| 122 |
+
|
| 123 |
+
## 6. Queue and review
|
| 124 |
+
|
| 125 |
+
Queue the workflow normally.
|
| 126 |
+
|
| 127 |
+
1. **Preflight** validates timing, references, media, and resume compatibility
|
| 128 |
+
before the large models load.
|
| 129 |
+
2. The loop renders and checkpoints one scene.
|
| 130 |
+
3. **Review Gate** opens with synchronized video and audio.
|
| 131 |
+
4. Choose an action:
|
| 132 |
+
|
| 133 |
+
| Action | Result |
|
| 134 |
+
|---|---|
|
| 135 |
+
| **Approve & continue** | Accept this take and render the next scene. |
|
| 136 |
+
| **Retry scene / seed / length** | Render the scene again from the same accepted predecessor. |
|
| 137 |
+
| **Reroll seed** | Give only this scene a new seed, then retry it. |
|
| 138 |
+
| **Approve & stop** | Accept this scene, optionally assemble a partial video, and stop cleanly. |
|
| 139 |
+
|
| 140 |
+
When `candidate_count` is greater than one, Review Gate displays saved takes in
|
| 141 |
+
a carousel. The selected take becomes the exact continuation for the next
|
| 142 |
+
scene; checked alternatives remain in checkpoint history.
|
| 143 |
+
|
| 144 |
+
After the final approval, Loop End emits a manifest and the active Assemble
|
| 145 |
+
node creates the final MP4.
|
| 146 |
+
|
| 147 |
+
## 7. Find the result
|
| 148 |
+
|
| 149 |
+
One run is stored under:
|
| 150 |
+
|
| 151 |
+
```text
|
| 152 |
+
ComfyUI/output/h3_chains/<run_name>/
|
| 153 |
+
├── checkpoints/ saved continuation state and revision data
|
| 154 |
+
├── segments/ reviewed scene media
|
| 155 |
+
├── final/ assembled MP4 and optional audio/subtitle sidecars
|
| 156 |
+
└── ... manifests, previews, and recovery metadata
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
Exact subfolders can grow as review branches, project assets, or upscale
|
| 160 |
+
profiles are added. Use **Run Manager** or **Checkpoint Manager** instead of
|
| 161 |
+
manually changing active revision files.
|
| 162 |
+
|
| 163 |
+
## Assemble later without rendering
|
| 164 |
+
|
| 165 |
+
1. Open the same workflow.
|
| 166 |
+
2. Set Plan's `run_name` to the saved run.
|
| 167 |
+
3. Unmute the recovery **Load Manifest** and **Assemble** nodes.
|
| 168 |
+
4. Queue only the recovery Assemble node.
|
| 169 |
+
|
| 170 |
+
This reads the saved manifest and segments from disk. It does not execute the
|
| 171 |
+
sampler body.
|
| 172 |
+
|
| 173 |
+
## Common first-run problems
|
| 174 |
+
|
| 175 |
+
| Symptom | What to check |
|
| 176 |
+
|---|---|
|
| 177 |
+
| Preflight says the run already contains incompatible work | Use a new `run_name`, or restore the original generation settings. |
|
| 178 |
+
| Queue pauses after one scene | Review Gate is waiting for a decision. |
|
| 179 |
+
| Opening image appears in every scene | Keep **Frame Gate** active; do not bypass it. |
|
| 180 |
+
| No final MP4 appears | Approve the final scene and confirm the active Assemble node is enabled. |
|
| 181 |
+
| Recovery nodes render unexpectedly | Mute the recovery Load Manifest and Assemble pair. |
|
| 182 |
+
| Audio or preview tools fail | Install `ffmpeg`, or confirm ComfyUI's PyAV installation works. |
|
| 183 |
+
| A source-audio profile fails preflight | Connect a Source Timeline containing enough audio for the plan. |
|
| 184 |
+
|
| 185 |
+
For deeper diagnosis, use [Compatibility](COMPATIBILITY.md), [Audio and
|
| 186 |
+
continuity](AUDIO_AND_CONTINUITY.md), or [Runs and recovery](RUNS_AND_RECOVERY.md).
|
ComfyUI-MiniMaxH3-Context-Loop/docs/MASKED_EDITING.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Masked editing
|
| 2 |
+
|
| 3 |
+
MiniMax H3 can edit a real source AV latent instead of treating the source as
|
| 4 |
+
an ordinary reference. A nested denoise mask controls every target stream:
|
| 5 |
+
|
| 6 |
+
```text
|
| 7 |
+
0 = preserve the source latent
|
| 8 |
+
1 = regenerate this row
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
This is the general form of the chain's `masked_av`, `tapered_av`,
|
| 12 |
+
`feathered_av`, and `drift_control_av` continuation. The chain builds a
|
| 13 |
+
temporal prefix mask automatically; the public masking nodes accept arbitrary
|
| 14 |
+
static or tracked spatial masks.
|
| 15 |
+
|
| 16 |
+
`masked_av` assigns `0` to the complete repeated picture prefix.
|
| 17 |
+
Experimental `tapered_av` uses the same hard mask after replacing only the
|
| 18 |
+
carried video prefix with a deterministic, disposable matched-variance noise
|
| 19 |
+
taper; audio and the accepted predecessor remain unchanged.
|
| 20 |
+
`feathered_av` keeps most of that picture prefix exact, then raises the mask
|
| 21 |
+
gradually toward `1` over its final latent steps. When Audio Policy enables
|
| 22 |
+
Generated continuity, the matching audio prefix participates too: with the
|
| 23 |
+
recommended 39-frame context, 8 of 12 video steps and 42 of 65 audio steps are
|
| 24 |
+
fully protected; the final 4 video and 23 audio prefix steps form the denoise
|
| 25 |
+
ramp. With Generated continuity off, audio remains fully denoisable. Both modes
|
| 26 |
+
trim all 39 repeated picture frames after decoding.
|
| 27 |
+
|
| 28 |
+
Experimental `drift_control_av` keeps the accepted predecessor checkpoint
|
| 29 |
+
clean. During sampling it applies the sampler's existing noise field to the
|
| 30 |
+
oldest eight of a 12-step picture prefix at `next_sigma / current_sigma`, then
|
| 31 |
+
tapers the last four steps `.75/.50/.25/.00` so the seam itself stays exact.
|
| 32 |
+
The same live mask reaches H3's row-timestep calculation through Chain
|
| 33 |
+
Context's MODEL output. Audio remains owned by Audio Policy. This mode is fixed
|
| 34 |
+
to 39 context frames and initially validated at 20 sampling steps.
|
| 35 |
+
|
| 36 |
+
Start with
|
| 37 |
+
[`Masked Video Inpaint - MiniMax H3 0.6.json`](<../example_workflows/Masked Video Inpaint - MiniMax H3 0.6.json>).
|
| 38 |
+
For picture-defined replacement appearance, use
|
| 39 |
+
[`Ref2V Masked Video Inpaint - MiniMax H3 0.6.json`](<../example_workflows/Ref2V Masked Video Inpaint - MiniMax H3 0.6.json>).
|
| 40 |
+
For temporal continuation and bridging, use the three
|
| 41 |
+
[masked AV examples](../example_workflows/README.md#masked-av-extension-and-bridge).
|
| 42 |
+
|
| 43 |
+
## Ablejones / MaskVidExperiments interoperability
|
| 44 |
+
|
| 45 |
+
Ablejones/droz's
|
| 46 |
+
[`droz_MiniMaxH3_LatentMaskInpainting_wReference_v3.1.json`](https://discord.com/channels/1076117621407223829/1539132102585356359/1539347237887549512)
|
| 47 |
+
uses `MVEx Mask To Latent Space` in `auto + max + max` mode before stock
|
| 48 |
+
`Set Latent Noise Mask`. At zero additional grow, that is the same H3 mask
|
| 49 |
+
contract as **Apply Target Mask → H3 exact**: VAE-aware causal frame groups,
|
| 50 |
+
conservative max reduction, and 2×2 latent-token unification. The corresponding
|
| 51 |
+
[workflow thread](https://discord.com/channels/1076117621407223829/1539132102585356359/1539132102585356359)
|
| 52 |
+
also documents the crop-based approach and its current border-flicker caveat.
|
| 53 |
+
|
| 54 |
+
The maintained Ref2V demo maps the remaining wiring as follows:
|
| 55 |
+
|
| 56 |
+
| Ablejones v3.1 | This pack |
|
| 57 |
+
|---|---|
|
| 58 |
+
| `MiniMaxH3ReferenceToVideo` picture input | The same core Ref2VA picture input |
|
| 59 |
+
| Video/audio VAE encode + two `SetLatentNoiseMask` nodes + `LTXVConcatAVLatent` | **Loop Source AV Target** followed by **Apply Target Mask** on the authoritative stock joint target |
|
| 60 |
+
| `MVEx Subject Crop/Uncrop` | Optional external speed/compositing layer; the maintained loop demo stays full-frame |
|
| 61 |
+
| SAM3 tracked mask | Any static mask or complete tracked MASK batch through **Loop Mask Slice** |
|
| 62 |
+
| `grow_spatial` / `grow_temporal` | Optional pixel-mask preparation before **Loop Mask Slice**; the bundled generous static mask uses zero additional grow |
|
| 63 |
+
|
| 64 |
+
Do not feed the source movie into Ref2VA again merely to label it. The source
|
| 65 |
+
movie is already the clean target latent and supplies protected pixels, motion,
|
| 66 |
+
camera, and synchronized audio. A picture reference should supply only the
|
| 67 |
+
replacement appearance. Crop/uncrop remains useful when a small subject would
|
| 68 |
+
otherwise spend most compute on an unchanged full frame, but a multi-scene
|
| 69 |
+
chain must slice crop boxes on the same scene windows before uncropping.
|
| 70 |
+
|
| 71 |
+
## Nodes
|
| 72 |
+
|
| 73 |
+
### Masking · Loop Source AV Target
|
| 74 |
+
|
| 75 |
+
**MiniMax H3 Masking · Loop Source AV Target** is the preferred source-video
|
| 76 |
+
path inside Chain Loop. Connect Current Shot `state`, Chain Context `latent`,
|
| 77 |
+
both H3 VAEs, and the complete synchronized source frames/audio. The node uses
|
| 78 |
+
`generation_start_frame` and the stock H3 joint target to select and encode the
|
| 79 |
+
exact current interval.
|
| 80 |
+
|
| 81 |
+
The stock target's video and audio lengths are authoritative. At fractional
|
| 82 |
+
24-fps/40-Hz boundaries, exact picture-duration audio can encode one token
|
| 83 |
+
shorter than the stock H3 audio target. This node adds only the encoder-grid
|
| 84 |
+
lookahead needed to fill that target and trims back to its exact step count.
|
| 85 |
+
Do not put `LTXVConcatAVLatent` or `LTXVSeparateAVLatent` in this path.
|
| 86 |
+
|
| 87 |
+
### Masking · Loop Mask Slice
|
| 88 |
+
|
| 89 |
+
**MiniMax H3 Masking · Loop Mask Slice** accepts either one static mask or a
|
| 90 |
+
complete tracked MASK batch. A single frame is explicitly broadcast across the
|
| 91 |
+
current scene. A multi-frame batch is treated as a source-timeline mask and
|
| 92 |
+
sliced with the current shot's `generation_start_frame` and `raw_frames`.
|
| 93 |
+
|
| 94 |
+
Set `source_fps` to the rate represented by the tracked mask batch. For a
|
| 95 |
+
24-fps source/mask pair, the bundled two-scene workflow selects mask frames
|
| 96 |
+
0..174 for scene 1 and 136..310 for scene 2. The repeated 136..174 interval is
|
| 97 |
+
therefore identical to the source AV continuation overlap. Connect
|
| 98 |
+
`scene_mask` to Grid Preview; its existing spatial snapping then operates only
|
| 99 |
+
on the correct current interval. Apply Target Mask's exact mode validates that
|
| 100 |
+
the returned tracked batch has the complete raw scene length; it never
|
| 101 |
+
stretches a short tracked batch silently.
|
| 102 |
+
|
| 103 |
+
### Masking · Trim Source AV
|
| 104 |
+
|
| 105 |
+
**MiniMax H3 Masking · Trim Source AV** drops only trailing video frames until
|
| 106 |
+
the source has a valid H3 `17k+5` length. Optional source audio is trimmed to
|
| 107 |
+
the same duration at H3's fixed 24 fps. It never resizes frames, changes fps,
|
| 108 |
+
or pads short audio.
|
| 109 |
+
|
| 110 |
+
Use the returned `h3_length` for the core H3 conditioning node. Resize the
|
| 111 |
+
returned frames once to the final H3 canvas, then feed that exact batch to the
|
| 112 |
+
video VAE, mask preview, and any tracking path.
|
| 113 |
+
|
| 114 |
+
### Masking · Grid Preview
|
| 115 |
+
|
| 116 |
+
H3's video VAE reduces each spatial axis by 16 and the DiT groups the result
|
| 117 |
+
into 2×2 latent patches. One independently masked H3 row therefore covers
|
| 118 |
+
roughly **32×32 source pixels**.
|
| 119 |
+
|
| 120 |
+
**MiniMax H3 Masking · Grid Preview** shows those cells and returns a snapped
|
| 121 |
+
MASK suitable for Apply Target Mask. Its canvas must match the encoded source,
|
| 122 |
+
be divisible by 32, and contain a valid one-frame or `17k+5` H3 run. Runtime
|
| 123 |
+
exact mode also unions every source-frame group that feeds one causal video
|
| 124 |
+
latent, then paints that effective selection back over the grouped frames.
|
| 125 |
+
|
| 126 |
+
- `runtime exact (latent max)` reproduces the spatial reduction used by H3;
|
| 127 |
+
- `any pixel coverage` selects a whole cell for any marked source pixel;
|
| 128 |
+
- `50% pixel coverage` selects cells at least half covered;
|
| 129 |
+
- `full pixel coverage` selects only completely covered cells;
|
| 130 |
+
- `cell_adjust` grows or shrinks by complete cells.
|
| 131 |
+
|
| 132 |
+
The `grid_preview` IMAGE contains only the selected `preview_frame` to avoid
|
| 133 |
+
duplicating an entire video for display. The `snapped_mask` output retains the
|
| 134 |
+
complete static or tracked mask batch.
|
| 135 |
+
|
| 136 |
+
### Masking · Apply Target Mask
|
| 137 |
+
|
| 138 |
+
**MiniMax H3 Masking · Apply Target Mask** expects the source media as the
|
| 139 |
+
sampler's real joint video/audio target latent. In a loop, produce it with
|
| 140 |
+
**Loop Source AV Target**, apply the mask, and connect `masked_target` to
|
| 141 |
+
`SamplerCustomAdvanced.latent_image`.
|
| 142 |
+
|
| 143 |
+
Do not also add the same source as `<Video>` merely to make masking work. A
|
| 144 |
+
reference influences generation; it does not provide the clean latent values
|
| 145 |
+
that the mask protects.
|
| 146 |
+
|
| 147 |
+
The main mask may use either convention:
|
| 148 |
+
|
| 149 |
+
- `white = generate` for conventional inpainting;
|
| 150 |
+
- `white = preserve` when the supplied artwork describes protected content.
|
| 151 |
+
|
| 152 |
+
The default **H3 exact (causal/token max)** conversion follows the video VAE's
|
| 153 |
+
repeating `1,4,4,4,4` pixel-frame groups and max-reduces each group to its real
|
| 154 |
+
latent step. It then max-reduces spatially and unifies every 2×2 latent block
|
| 155 |
+
read by one H3 token. This preserves thin details and moving-mask coverage that
|
| 156 |
+
trilinear interpolation can weaken or shift. One mask is broadcast; a tracked
|
| 157 |
+
mask must contain exactly the pixel-frame span represented by the target. In a
|
| 158 |
+
loop, connect Loop Mask Slice to supply it. **legacy trilinear** remains
|
| 159 |
+
available for reproducing earlier workflows. Switching conversion modes changes
|
| 160 |
+
generation, so also change the Plan generation fingerprint and regenerate the
|
| 161 |
+
affected scene history. Batch size is currently one.
|
| 162 |
+
|
| 163 |
+
This conversion is for inpainting, replacement, removal, outpainting, and
|
| 164 |
+
other masked edits. It does not add a mask input to ordinary AV extension; the
|
| 165 |
+
chain continues to create its own temporal AV-prefix mask automatically.
|
| 166 |
+
|
| 167 |
+
## Audio modes
|
| 168 |
+
|
| 169 |
+
| Mode | Behavior |
|
| 170 |
+
|---|---|
|
| 171 |
+
| `preserve source audio` | Protect the complete encoded source-audio latent. This is the default for visual edits. |
|
| 172 |
+
| `generate all audio` | Regenerate the complete H3 audio stream. |
|
| 173 |
+
| `follow video mask` | Generate audio during times where any video region is selected. |
|
| 174 |
+
| `custom audio mask` | Reduce the optional MASK to a time envelope; white generates and black preserves. |
|
| 175 |
+
|
| 176 |
+
Audio is not spatial, so a visual object mask has no unique audio equivalent.
|
| 177 |
+
Use preserve mode unless the edit deliberately needs new sound.
|
| 178 |
+
|
| 179 |
+
## Exact master-audio timelines
|
| 180 |
+
|
| 181 |
+
**Masking · Master Audio + Video Prefix** is the asymmetric timeline-audio
|
| 182 |
+
form of target masking. It takes the empty joint target produced by stock H3
|
| 183 |
+
conditioning, VAE-encodes the exact master-audio interval beginning at
|
| 184 |
+
`clip_start_seconds`, replaces the complete target audio stream, and assigns
|
| 185 |
+
audio mask `0` for the full raw clip. The master may be music, dialogue,
|
| 186 |
+
narration, or another finished soundtrack.
|
| 187 |
+
|
| 188 |
+
The H3 target audio grid is authoritative when picture duration falls between
|
| 189 |
+
40 Hz boundaries. The node may encode a few milliseconds of additional real
|
| 190 |
+
timeline audio so a floor-style audio VAE fills every target step, then crops
|
| 191 |
+
the latent to the target length. Its `clip_audio` output remains exactly the
|
| 192 |
+
picture duration; it never invents or repeats a latent token.
|
| 193 |
+
|
| 194 |
+
For live H3 chaining, connect the preceding sampler output to `source_latent`.
|
| 195 |
+
The node copies its phase-aligned video-latent tail directly into the protected
|
| 196 |
+
target prefix without decoding or re-encoding it. The source latent's audio is
|
| 197 |
+
ignored: the selected `master_audio` interval remains authoritative.
|
| 198 |
+
|
| 199 |
+
`source_frames` remains the legacy fallback for imported or decoded media. The
|
| 200 |
+
node converts it to 24 fps, selects the final native H3 context run, VAE-encodes
|
| 201 |
+
it into the target video prefix, and protects only those video rows. Connect
|
| 202 |
+
either `source_latent` or `source_frames`, never both. Future video rows remain
|
| 203 |
+
`1` and are generated normally. The returned `trim_frames` is authoritative
|
| 204 |
+
for Loop Trim and visual overlap assembly.
|
| 205 |
+
|
| 206 |
+
The master audio is target content, not a Ref2VA audio reference. Keep every
|
| 207 |
+
`ref_audio_*` input disconnected, and mux the untouched full master audio onto
|
| 208 |
+
the assembled picture for final delivery.
|
| 209 |
+
|
| 210 |
+
## Composition with chain continuation
|
| 211 |
+
|
| 212 |
+
If the target already contains a nested H3 noise mask, Apply Target Mask
|
| 213 |
+
intersects the two masks. **Preservation wins.** This allows an arbitrary
|
| 214 |
+
spatial edit to compose safely with the protected or feathered prefix produced
|
| 215 |
+
by either AV mask mode:
|
| 216 |
+
|
| 217 |
+
```text
|
| 218 |
+
chain prefix mask × spatial edit mask = final generation mask
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
The same rule holds whether Apply Target Mask sits before or after Chain
|
| 222 |
+
Context, because both nodes emit latent-sized nested AV masks.
|
| 223 |
+
|
| 224 |
+
## Inpaint, outpaint, and clip bridging
|
| 225 |
+
|
| 226 |
+
The bundled workflow implements video inpainting. The same mask contract also
|
| 227 |
+
supports the other operations once their source target is prepared:
|
| 228 |
+
|
| 229 |
+
- **Outpaint:** place the source on a larger H3 canvas, encode that canvas,
|
| 230 |
+
preserve the original rectangle, and generate the exposed border.
|
| 231 |
+
- **Object removal/replacement:** provide a static or tracked object mask and
|
| 232 |
+
describe only the intended replacement while asking H3 to retain the rest.
|
| 233 |
+
- **Temporal repair:** use a mask batch that is black on retained frames and
|
| 234 |
+
white during the interval to regenerate.
|
| 235 |
+
- **Two-clip bridge:** **Masking · Two-Clip AV Bridge** encodes the end of clip
|
| 236 |
+
A and beginning of clip B into opposite ends of an empty joint H3 target,
|
| 237 |
+
protects both AV windows, and generates only the middle interval. Use exact
|
| 238 |
+
H3 endpoint runs; 39 frames is recommended because it maps to exactly 65
|
| 239 |
+
audio steps.
|
| 240 |
+
|
| 241 |
+
This integration bundles the arbitrary mask/inpaint workflow and the two-ended
|
| 242 |
+
AV bridge. Expanded-canvas outpaint preparation remains external to the pack;
|
| 243 |
+
it can be added without changing the H3 mask/runtime layer.
|
| 244 |
+
|
| 245 |
+
## Runtime compatibility
|
| 246 |
+
|
| 247 |
+
The masking nodes prefer native ComfyUI support equivalent to PR #15375. On a
|
| 248 |
+
supported post-PR-#15439 H3 baseline, they lazily install only the missing mask
|
| 249 |
+
engine, payload extraction, token-aligned inpaint scaling, or legacy sampler
|
| 250 |
+
bridge. The original pixel mask remains authoritative for final sampler
|
| 251 |
+
blending; only H3's internal timestep labels are pooled to the video/audio
|
| 252 |
+
token grids. No MODEL patch node is needed, and importing the pack does not
|
| 253 |
+
modify stock H3 behavior.
|
| 254 |
+
|
| 255 |
+
A partially updated native implementation is rejected rather than combined
|
| 256 |
+
with the compatibility snapshot. Restart ComfyUI fully after updating core or
|
| 257 |
+
an H3 optimization extension.
|
| 258 |
+
|
| 259 |
+
Per-row mask support guarantees the sampling mechanics, not that H3 was trained
|
| 260 |
+
equally strongly for every edit topology. Feathered source preparation and a
|
| 261 |
+
short test render remain advisable for outpainting and complex tracked masks.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/MIGRATING_TO_0_5.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Migrating workflows to version 0.5
|
| 2 |
+
|
| 3 |
+
Version 0.5 is backward compatible. Existing 0.4 node ids, positional outputs,
|
| 4 |
+
widget order, manifests, and checkpoints remain supported. Migration is
|
| 5 |
+
recommended for clearer graphs and model-free validation, but it is not
|
| 6 |
+
required merely to open or resume an older workflow.
|
| 7 |
+
|
| 8 |
+
## What changes
|
| 9 |
+
|
| 10 |
+
Version 0.5 uses one **Chain Policy** connection for normal authoring. It keeps
|
| 11 |
+
the audio and transition records independent inside the Plan/checkpoint
|
| 12 |
+
contract, but removes the need to wire two policy nodes.
|
| 13 |
+
|
| 14 |
+
| 0.4 audio mode | Final audio | Source reference | Generated continuity |
|
| 15 |
+
|---|---|---|---|
|
| 16 |
+
| `generated_audio` | generated | off | on |
|
| 17 |
+
| `source_track` | source | on | off |
|
| 18 |
+
| `source_plus_timeline` | source | on | on |
|
| 19 |
+
|
| 20 |
+
| 0.4 continuation | 0.5 transition | Default context |
|
| 21 |
+
|---|---|---:|
|
| 22 |
+
| guide with zero context | Cut | 0 frames |
|
| 23 |
+
| `guide` | Guide | 22 frames |
|
| 24 |
+
| `masked_av` | Hard AV | 39 frames |
|
| 25 |
+
| `audio_feathered_av` | Soft AV | 39 frames |
|
| 26 |
+
| any other implementation/context pair | Legacy 0.4 Policy Adapter | preserved exactly |
|
| 27 |
+
|
| 28 |
+
The compact presets derive generated-audio overlap from the same boundary: 0,
|
| 29 |
+
22, or 39 frames. A custom audio overlap, Tone/Latent/Detail Guide, Detail AV,
|
| 30 |
+
Drift-Control AV, old dual-stream Feathered AV, and other raw pairs migrate to
|
| 31 |
+
the single **Legacy 0.4 Policy Adapter** node instead. Nothing is approximated.
|
| 32 |
+
The semantic choice always describes the boundary entering a scene.
|
| 33 |
+
|
| 34 |
+
## Automatic migration
|
| 35 |
+
|
| 36 |
+
Back up a custom workflow, then run:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
python tools/migrate_v05_workflows.py /path/to/workflow.json
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Pass several paths to migrate them together. Use `--check` to report files that
|
| 43 |
+
would change without writing them:
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
python tools/migrate_v05_workflows.py --check /path/to/workflow.json
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
With no paths, the tool checks or migrates the maintained active examples. It
|
| 50 |
+
is idempotent: running it again does not add duplicate policy, preflight, or
|
| 51 |
+
timeline nodes. Exact 0.4 settings become one Chain Policy when they fit the
|
| 52 |
+
normal presets; other implementation/context pairs become one exact Legacy 0.4
|
| 53 |
+
Policy Adapter.
|
| 54 |
+
|
| 55 |
+
## Source-media rewiring
|
| 56 |
+
|
| 57 |
+
The old source-track graph repeated one full decoded AUDIO value:
|
| 58 |
+
|
| 59 |
+
```text
|
| 60 |
+
Load Audio ─┬→ Loop Start
|
| 61 |
+
├→ Current Shot
|
| 62 |
+
├→ Tagged Audio Ref
|
| 63 |
+
└→ Assemble
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
The 0.5 graph registers media once:
|
| 67 |
+
|
| 68 |
+
```text
|
| 69 |
+
Load Video ─┐
|
| 70 |
+
├→ Source Timeline ─┬→ Preflight / Plan Studio
|
| 71 |
+
Load Audio ─┘ └→ Loop Start → Current Shot
|
| 72 |
+
└→ scene slice → Tagged Audio Ref
|
| 73 |
+
|
| 74 |
+
Loop End manifest → Assemble
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Path-backed media remains lazy. Current Shot requests only its active window,
|
| 78 |
+
and manifests carry the descriptor to recovery and assembly. If the only audio
|
| 79 |
+
input is a tensor, Loop Start materializes one normalized run-owned copy.
|
| 80 |
+
|
| 81 |
+
Do not return a Tagged Audio fingerprint derived from Current Shot to Plan;
|
| 82 |
+
that would create a graph cycle. Keep static picture/reference fingerprints on
|
| 83 |
+
Plan. Structured scene dependencies record the exact source PCM window used by
|
| 84 |
+
generation automatically.
|
| 85 |
+
|
| 86 |
+
## Preflight and resume
|
| 87 |
+
|
| 88 |
+
Normal workflows insert Chain Preflight before Loop Start. Studio workflows use
|
| 89 |
+
the same backend through Plan Studio. It validates duration, source windows,
|
| 90 |
+
references, compatibility, and resume eligibility without loading H3.
|
| 91 |
+
|
| 92 |
+
Version 0.5 stores structured per-scene dependencies. A change to the next
|
| 93 |
+
scene, its incoming transition, or assembly-only media does not invalidate an
|
| 94 |
+
already accepted predecessor. Old checkpoints without structured records keep
|
| 95 |
+
their generic hash fallback.
|
| 96 |
+
|
| 97 |
+
## Compatibility guarantee
|
| 98 |
+
|
| 99 |
+
The migration tool does not renumber the sampling body or change its node
|
| 100 |
+
types. Loop Start's original optional socket order is preserved so
|
| 101 |
+
numerical 0.4 workflow slots still deserialize correctly. Legacy full-AUDIO
|
| 102 |
+
fan-out, direct media paths, Plan widgets, and archived examples remain
|
| 103 |
+
accepted throughout the 0.5 release.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/NODE_REFERENCE.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Node guide
|
| 2 |
+
|
| 3 |
+
This is a user-facing map of the nodes you normally see in a Context Loop
|
| 4 |
+
workflow. It focuses on what to connect and what comes out; exact expert fields
|
| 5 |
+
remain documented by the node tooltips and the linked specialist guides.
|
| 6 |
+
|
| 7 |
+
## Read a node at a glance
|
| 8 |
+
|
| 9 |
+
The tables use this direction:
|
| 10 |
+
|
| 11 |
+
```text
|
| 12 |
+
input socket or widget ──> NODE ──> output socket
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
- **Socket** means connect another node.
|
| 16 |
+
- **Widget** means choose or type a value on the node.
|
| 17 |
+
- Inputs marked optional can stay disconnected for the normal path.
|
| 18 |
+
- Outputs described as status or summary are for inspection; they do not need
|
| 19 |
+
to be wired for generation.
|
| 20 |
+
|
| 21 |
+
The flow overview in the [README](../README.md#how-the-graph-is-organized) shows
|
| 22 |
+
where the core nodes sit.
|
| 23 |
+
|
| 24 |
+
## How disabled nodes are shown
|
| 25 |
+
|
| 26 |
+
Disabled nodes remain part of the saved workflow, so their sockets and purpose
|
| 27 |
+
still matter. These docs keep their node-shaped representation and add an
|
| 28 |
+
explicit state label:
|
| 29 |
+
|
| 30 |
+
| Representation | ComfyUI mode | Meaning |
|
| 31 |
+
|---|---:|---|
|
| 32 |
+
| **Solid card — ACTIVE** | `0` | Executes when its output is requested. |
|
| 33 |
+
| **Grey dashed card — MUTED** | `2` | Present, but never executes. No output is produced. |
|
| 34 |
+
| **Dashed amber card — BYPASSED** | `4` | Skips its operation and forwards a compatible input. |
|
| 35 |
+
|
| 36 |
+
This follows the useful behavior of disabled-pack schema previews: input
|
| 37 |
+
sockets, widgets, defaults, and outputs remain visible even though the node is
|
| 38 |
+
not live. It prevents a muted recovery branch from looking like missing or
|
| 39 |
+
broken documentation.
|
| 40 |
+
|
| 41 |
+
In the shipped generation examples, **Load Manifest → Assemble — recovery** is
|
| 42 |
+
muted. Unmute that pair only to assemble saved scenes without rendering. In
|
| 43 |
+
some deferred-upscale examples, an attention override is bypassed intentionally
|
| 44 |
+
for compatibility.
|
| 45 |
+
|
| 46 |
+
## Main loop
|
| 47 |
+
|
| 48 |
+
| Node | Inputs you normally use | Outputs you normally use | What it does |
|
| 49 |
+
|---|---|---|---|
|
| 50 |
+
| **MiniMax H3 Generation Profile** | Widgets: Scene continuity, Audio profile. Optional: `lip_sync_options`. | `chain_policy`, `status` | Turns two user choices into the complete policy expected by Plan. |
|
| 51 |
+
| **MiniMax H3 Plan (Modern)** | Required `chain_policy`; organized Project, Canvas, Generation defaults, and Delivery settings; optional `project_assets`. | `plan`, `summary`, scene count, width, height, blend frames | Recommended for new graphs. Keeps the familiar scene-column editor, removes all legacy fallback controls, and compiles the same Plan contract as the original node. |
|
| 52 |
+
| **MiniMax H3 Context Loop Plan** | Widgets: Scene Plan, `run_name`, size, duration, seed. Optional sockets: `chain_policy`, `project_assets`. | `plan`, `summary`, scene count, width, height, blend frames | Original compatibility-preserving Plan. Existing workflows keep it unchanged. Connect Generation Profile, then use **Upgrade to Modern Plan…** from its context menu when desired. |
|
| 53 |
+
| **MiniMax H3 Chain Preflight** | `plan` | checked `plan`, `preflight`, `ready`, `status`, report JSON | Validates the full plan before model-heavy nodes execute. Wire its Plan output to Loop Start. |
|
| 54 |
+
| **MiniMax H3 Context Loop Start** | `plan`. Optional: scene range, source timeline, source audio, external context. | `flow`, `state`, `status` | Starts scene 1, a bounded range, or a compatible resume. |
|
| 55 |
+
| **MiniMax H3 Context Loop Current Shot** | `state`. Optional source-audio fallback. | state, scene index/count, prompt, seed, raw length, steps, size, source-audio slice, blend frames | Exposes the current scene as ordinary ComfyUI values. |
|
| 56 |
+
| **MiniMax H3 Current Tagged Ref2VA Scene** | `state`, CLIP, video VAE, audio VAE, tagged references. Optional: Tagged Scene Options. | state, positive conditioning, latent, typed scene data | Compact prompt-driven Ref2VA route. It replaces the visible Current Shot + Tagged Ref2VA pair in new reference graphs. |
|
| 57 |
+
| **MiniMax H3 Context Loop Context** | state, conditioning, video VAE, latent. Optional: audio VAE, model, drift sigmas, anchors, lip-sync voice. | conditioning, trim frames, continuation flag, latent, model | Adds the selected visual/audio continuation. Scene 1 is effectively a pass-through. |
|
| 58 |
+
| **MiniMax H3 Context Loop Trim** | decoded images, trim count. Optional: audio, FPS, overlap controls, state. | delivered images/audio, overlap-ready images, overlap count | Removes the repeated head context and retains any frames needed for assembly blending. |
|
| 59 |
+
| **MiniMax H3 Context Loop Segment + Checkpoint** | state, delivered images, sampled latent. Optional: audio, overlap-ready images, denoised latent. | `segment`, `status` | Saves the scene movie, latent continuation, dependency record, and immutable revision. |
|
| 60 |
+
| **MiniMax H3 Context Loop Review Gate** | state, segment. Widgets: enabled, timeout, candidate count, memory cleanup, partial assembly. | reviewed `segment`, `status` | Pauses for approve, retry, reroll, candidate selection, or clean stop. |
|
| 61 |
+
| **MiniMax H3 Context Loop End** | flow, state, images, sampled latent, reviewed segment. | manifest, manifest JSON, final context frames, final context latent | Recurses into the next scene or finishes the selected range. |
|
| 62 |
+
| **MiniMax H3 Context Loop Assemble** | manifest. Widgets: audio source, filename, bitrate. Optional: output copy, blend/tone controls, recovery media. | `video_path` | Verifies selected segments, joins them, and muxes the chosen audio into the final MP4. |
|
| 63 |
+
|
| 64 |
+
The H3 model loader, scheduler, sampler, and VAE decoders are ordinary ComfyUI
|
| 65 |
+
or MiniMax H3 nodes. Context Loop provides the plan, continuity, checkpoint, and
|
| 66 |
+
recovery layer around them.
|
| 67 |
+
|
| 68 |
+
## Authoring and policy nodes
|
| 69 |
+
|
| 70 |
+
| Node | In → out | Use it when… |
|
| 71 |
+
|---|---|---|
|
| 72 |
+
| **Scene Prompt Editor** | Plan → Plan | You want the stable per-scene prompt editor and prompt history. |
|
| 73 |
+
| **Rich Scene Prompt Editor (Experimental)** | Plan → Plan | You want the experimental rich editor and optional optimizer UI. |
|
| 74 |
+
| **Plan Studio (Experimental)** | Plan and optional Source Timeline → Plan | You want editorial placement, gaps, chapter views, previews, timeline controls, or picture-only alternate final-cut takes. |
|
| 75 |
+
| **Project Asset Carousel** | Project name/catalog; optional existing tagged refs or upscale model → project assets, references, Source Timeline | You want one project library instead of many loader/tag nodes. See the [Carousel guide](PROJECT_ASSETS.md). |
|
| 76 |
+
| **Scene LoRA Scheduler** | Model plus lazily revealed LoRA inputs; state → scene model | Different scenes use different connected LoRA routes. |
|
| 77 |
+
| **Lip-Sync Options** | Timing/denoise widgets; optional vocal stem → lip-sync options and voice | The Generation Profile uses **Lip-sync to source audio**. |
|
| 78 |
+
| **Advanced Policy Override** | chain policy + advanced transition → chain policy | You need Tone, Latent, Detail, Drift-Control, or Color-Stable Drift behavior. |
|
| 79 |
+
| **Manual Chain Policy (Legacy)** | Low-level transition/audio widgets → chain policy | An existing workflow already uses it. Prefer Generation Profile for new graphs. |
|
| 80 |
+
| **Legacy 0.4 Policy Adapter** | Old 0.4 fields; optional current policy → chain policy | You are rebuilding or migrating an old control surface. |
|
| 81 |
+
|
| 82 |
+
See [Scene authoring](SCENE_AUTHORING.md), [Audio and
|
| 83 |
+
continuity](AUDIO_AND_CONTINUITY.md), and the [complete Plan format](../H3_CHAIN_FORMAT_GUIDE.md).
|
| 84 |
+
|
| 85 |
+
## References and source media
|
| 86 |
+
|
| 87 |
+
| Node group | Main inputs → outputs | Use it for |
|
| 88 |
+
|---|---|---|
|
| 89 |
+
| **Tagged Picture / Video / Motion / Audio Ref** | Media + tag/options → tagged references | Build a prompt-selected `@tag` reference collection. |
|
| 90 |
+
| **Tagged Ref2VA** | Tagged refs + CLIP/VAEs + current scene values → conditioning, latent, fingerprint | Compile only the references used by the current prompt. |
|
| 91 |
+
| **Tagged Scene Options** | Reference/backend widgets → options | Keep non-default reference settings off the compact Current Tagged Scene node. |
|
| 92 |
+
| **Scene Data Extract** | typed scene data + selected field → typed value | Recover a secondary Current Tagged Scene value such as source-audio slice or RefMod sources. |
|
| 93 |
+
| **Scheduled Picture / Video / Audio Ref** | Media + scene selector/options → schedule | Assign media by scene number or range instead of prompt tags. |
|
| 94 |
+
| **Scheduled Ref2VA** | Schedule + current scene + CLIP/VAEs → conditioning, latent, fingerprint | Compile the active scheduled references for one scene. |
|
| 95 |
+
| **Source Timeline** | Source media/path and timing options → Source Timeline | Register the reusable video/audio timeline once. |
|
| 96 |
+
| **Audio Tracks** | Optional full mix, vocals, instrumental AUDIO → Source Timeline | Use vocals for source-locked lip-sync while preserving the full mix for delivery; mix stems only when a full mix is absent. See [grouped audio](AUDIO_AND_CONTINUITY.md#grouped-songs-full-mix-vocals-and-instrumental). |
|
| 97 |
+
| **Reference Video Prep** | Video + fit/timing controls → prepared reference | Prepare motion/reference video for H3 timing. |
|
| 98 |
+
| **Frame Gate** | Opening image; optional last frame + scene index → first/last-frame outputs | Apply the opening image only to scene 1. Do not bypass it in I2V workflows. |
|
| 99 |
+
| **Frame Index Switch** | Scene index + image inputs → selected image | Alternate last-frame targets across scenes. |
|
| 100 |
+
| **Existing Video Context** | Plan plus source video or frames; optional audio → external context | Prepend an existing clip and continue from its tail. |
|
| 101 |
+
|
| 102 |
+
Lazy motion loaders, source-timeline previews, semantic anchors, and Patch
|
| 103 |
+
Priority are advanced reference tools. Their behavior is
|
| 104 |
+
covered by [Scheduled references](SCHEDULED_REFERENCES.md) and [Advanced
|
| 105 |
+
workflows](ADVANCED_WORKFLOWS.md).
|
| 106 |
+
|
| 107 |
+
## Runs, recovery, and output
|
| 108 |
+
|
| 109 |
+
| Node | Main inputs → outputs | Use it for |
|
| 110 |
+
|---|---|---|
|
| 111 |
+
| **Run Manager** | Plan, archive controls, optional loader assets/Source Timeline/tagged refs → Plan, Source Timeline | Browse runs, restore a saved Plan and loader bindings, and archive recovery assets. |
|
| 112 |
+
| **Checkpoint Manager** | Saved UI selection; optional Plan → selected manifest | Pin a workflow-local lineage or choose **Selected chapter only** for chapter export/upscaling. Chapter output preserves original scene numbers and timing, and permits different resolutions in earlier chapters. Local output leaves the project and connected Plan unchanged. |
|
| 113 |
+
| **Load Manifest** | Plan; optional source media/context → manifest, manifest JSON, status | Verify and load saved segments without executing the generation loop. Usually muted in generation examples. |
|
| 114 |
+
| **Chapter Delivery** | Manifest, enabled, chapter number → delivery manifest, snapshot path | Seal a chosen chapter's generated scenes for independent export. `0` selects the chapter containing the last generated scene; a positive number selects that chapter explicitly. |
|
| 115 |
+
| **Chapter Recovery Load** | Run name, chapter number, optional snapshot ID → chapter manifest | Recover a sealed chapter's exact saved lineage and editorial state without loading or extending the rest of the run. |
|
| 116 |
+
| **Export PNG Sequence + Audio** | Manifest + optional VAEs, or file-backed VIDEO + upscale state; output folder, 8/16-bit PNGs, workers, verification, reuse → directory, PNG count, status, WAV path, VIDEO | Legacy latent PNG/WAV export remains available. VIDEO mode decodes and publishes one scene at a time with bounded memory, then passes the original VIDEO downstream. Verified unchanged frames can be reused; changed contents are never overwritten. |
|
| 117 |
+
| **Full-Chain Latent Video Adapter** | Generated manifest + video VAE → cached continuous video | Build a low-RAM, disk-backed whole-run input for SeedVR2. |
|
| 118 |
+
| **Assemble** | Manifest + audio/output choices → MP4 path | Join a complete or partial source/upscale manifest. |
|
| 119 |
+
|
| 120 |
+
See [Runs and recovery](RUNS_AND_RECOVERY.md) for review branches, resume rules,
|
| 121 |
+
folder layout, deferred upscaling, and assembly options.
|
| 122 |
+
|
| 123 |
+
## Masking nodes
|
| 124 |
+
|
| 125 |
+
| Node | Main inputs → outputs | Use it for |
|
| 126 |
+
|---|---|---|
|
| 127 |
+
| **Masking · Loop Source AV Target** | State + source video/audio + H3 target → scene target latent | Slice the exact source interval for the current loop scene. |
|
| 128 |
+
| **Masking · Loop Mask Slice** | State + mask batch → current scene masks | Keep a tracked mask aligned with recursive scene timing. |
|
| 129 |
+
| **Masking · Trim Source AV** | Source frames; optional audio → valid-length frames/audio, length, status | Trim the tail to H3's largest valid `17k+5` frame count without padding or resizing. |
|
| 130 |
+
| **Masking · Grid Preview** | Mask + H3 grid controls → snapped mask, preview, status | Inspect the actual 32×32 H3 cells that will change. |
|
| 131 |
+
| **Masking · Apply Target Mask** | H3 target latent + mask → masked target | Combine the spatial mask with any existing AV protection mask. |
|
| 132 |
+
| **Masking · Master Audio + Video Prefix** | H3 target + full source track + timing/prefix options → masked target, audio slice | Protect exact source audio while controlling the incoming video prefix. |
|
| 133 |
+
| **Masking · Two-Clip AV Bridge** | Start/end clips + H3 target → bridge target | Protect both ends and generate only the middle gap. |
|
| 134 |
+
|
| 135 |
+
Use [Masked editing](MASKED_EDITING.md) for the required wiring and mask/audio
|
| 136 |
+
semantics.
|
| 137 |
+
|
| 138 |
+
## Upscale and research nodes
|
| 139 |
+
|
| 140 |
+
Deferred-upscale workflows include a checkpoint adapter, current-scene node,
|
| 141 |
+
reference-conditioning recovery, pass-2 AV preparation, segment save, and an
|
| 142 |
+
upscale Loop End. The de-rope example adds Guard, Freeze Mask, Continuity, and
|
| 143 |
+
Recovered AV nodes. These nodes are designed to be used as a supplied group,
|
| 144 |
+
not assembled one at a time from this short reference.
|
| 145 |
+
|
| 146 |
+
Start from a [deferred-upscale example](../example_workflows/README.md#deferred-upscale)
|
| 147 |
+
and use [Runs and recovery](RUNS_AND_RECOVERY.md#whole-chain-seedvr2-finishing)
|
| 148 |
+
for the detailed contracts.
|
| 149 |
+
|
| 150 |
+
Nodes whose names end in **Experimental**, **Research**, **Internal**, or
|
| 151 |
+
**Legacy** are not the normal starting point. Keep their supplied wiring unless
|
| 152 |
+
you are following the matching specialist guide.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/PROJECT_ASSETS.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Asset Carousel
|
| 2 |
+
|
| 3 |
+
Project Asset Carousel keeps a run's pictures, video, audio, reference tags,
|
| 4 |
+
and Source track in one project library. Use it when separate loader and tag
|
| 5 |
+
nodes are making the workflow hard to manage.
|
| 6 |
+
|
| 7 |
+
## Connect it
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
Project Asset Carousel.project_assets ──> Plan.project_assets
|
| 11 |
+
Project Asset Carousel.references ──> Tagged Ref2VA.references
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
If the project contains a **Source track**, Plan stores its recoverable Source
|
| 15 |
+
Timeline automatically. Loop Start, Plan Studio, recovery, and Assemble can
|
| 16 |
+
read it from the Plan; another Source Timeline wire is normally unnecessary.
|
| 17 |
+
|
| 18 |
+
## Inputs and outputs
|
| 19 |
+
|
| 20 |
+
| Side | Field | Use |
|
| 21 |
+
|---|---|---|
|
| 22 |
+
| Input widget | `run_name` | Select or create the project. It synchronizes the connected Plan's run name. |
|
| 23 |
+
| Input widget | Catalog and operation state | Managed by the Carousel interface; do not hand-edit for normal use. |
|
| 24 |
+
| Optional input | `tagged_references` | Import the existing reference carrier as Unassigned cards. |
|
| 25 |
+
| Optional input | `upscale_model` | Enable nondestructive model upscaling in the image editor. |
|
| 26 |
+
| Output | `project_assets` | Connect to Plan. Carries the project catalog, fingerprints, and Source-track recovery data. |
|
| 27 |
+
| Output | `references` | Connect to Tagged Ref2VA or the compact Current Tagged Ref2VA Scene. |
|
| 28 |
+
| Output | `reference_fingerprint` | Inspect the active reference identity; Plan includes it automatically through `project_assets`. |
|
| 29 |
+
| Output | `source_timeline` | Optional direct Source Timeline access for specialized graphs. |
|
| 30 |
+
| Output | `status` | Compact counts and the current catalog revision. |
|
| 31 |
+
|
| 32 |
+
## Add an asset
|
| 33 |
+
|
| 34 |
+
Use any of these routes from the Carousel:
|
| 35 |
+
|
| 36 |
+
- Drop one or several image, video, or audio files onto the node.
|
| 37 |
+
- **Upload** a new file.
|
| 38 |
+
- **Import** a file already in `ComfyUI/input/`.
|
| 39 |
+
- Choose **Other Run** to copy an asset from another live Carousel project.
|
| 40 |
+
- Import from an H3 run recovery backup.
|
| 41 |
+
|
| 42 |
+
An imported asset receives its own project-owned media copy. Copying from
|
| 43 |
+
another run preserves its role, tag, reference options, and audio lyrics; the
|
| 44 |
+
source project is not changed.
|
| 45 |
+
|
| 46 |
+
## Assign a role
|
| 47 |
+
|
| 48 |
+
| Role | What it does |
|
| 49 |
+
|---|---|
|
| 50 |
+
| Native tagged reference | Makes the asset available to prompt-selected `@tag` references. |
|
| 51 |
+
| Semantic picture | Makes a picture available as a `#tag` presentation anchor. |
|
| 52 |
+
| Source track | Supplies the project's recoverable source video/audio timeline. |
|
| 53 |
+
| Unassigned | Keeps the card visible without using an H3 reference slot or changing the generation fingerprint. |
|
| 54 |
+
|
| 55 |
+
When you connect an existing `tagged_references` line, the Carousel creates
|
| 56 |
+
Unassigned cards for its tags and media roles. Bind each card to an input file,
|
| 57 |
+
upload, another project, or a backup before expecting it to take part in
|
| 58 |
+
generation. Move other server media into the configured ComfyUI input folder
|
| 59 |
+
first; browser requests cannot import arbitrary filesystem paths.
|
| 60 |
+
|
| 61 |
+
Only references used by the current scene are decoded during sampling.
|
| 62 |
+
|
| 63 |
+
## Group a song and its stems
|
| 64 |
+
|
| 65 |
+
On an audio **Source track** card, **Synchronized audio tracks** assigns the
|
| 66 |
+
project's full mix, isolated vocals and optional instrumental. Import the stems
|
| 67 |
+
as audio assets first; they need not be available to prompts. All tracks must
|
| 68 |
+
start together and retain the complete song duration, including silent gaps.
|
| 69 |
+
Only one Source track card needs to be enabled.
|
| 70 |
+
|
| 71 |
+
The full mix supplies delivery; vocals supply source-locked lip-sync. If no
|
| 72 |
+
full mix is assigned, the stems are mixed automatically without doubling an
|
| 73 |
+
existing mix. Use each scene's **Lip-sync** selector to turn guidance on/off
|
| 74 |
+
without changing the final soundtrack. **Reset to single track** restores the
|
| 75 |
+
old behavior. See [audio routing details](AUDIO_AND_CONTINUITY.md#grouped-songs-full-mix-vocals-and-instrumental).
|
| 76 |
+
|
| 77 |
+
Copying a grouped Source track from **Other Run** also copies its assigned
|
| 78 |
+
tracks and remaps their IDs. Deleting a referenced stem is blocked until you
|
| 79 |
+
detach it from the group.
|
| 80 |
+
|
| 81 |
+
## Edit a picture
|
| 82 |
+
|
| 83 |
+
**Edit / upscale** creates a new PNG variant; it never overwrites the source.
|
| 84 |
+
The editor supports:
|
| 85 |
+
|
| 86 |
+
- draggable crop and placement;
|
| 87 |
+
- exact width and height or megapixel targets;
|
| 88 |
+
- locked aspect ratio;
|
| 89 |
+
- Lanczos, bicubic, bilinear, or nearest resampling;
|
| 90 |
+
- output snapping to Off, 8, 16, 32, or 64;
|
| 91 |
+
- **Use full image** and **Reset all**;
|
| 92 |
+
- model upscaling when `upscale_model` is connected.
|
| 93 |
+
|
| 94 |
+
Model upscale queues only the Carousel and its lazy model-loader dependency. It
|
| 95 |
+
does not launch the downstream H3 generation loop.
|
| 96 |
+
|
| 97 |
+
Every variant records its parent and transform so the original remains
|
| 98 |
+
available.
|
| 99 |
+
|
| 100 |
+
## Organize the library
|
| 101 |
+
|
| 102 |
+
- Duplicate a card without copying its media bytes again.
|
| 103 |
+
- Create folders and drag cards onto them.
|
| 104 |
+
- Click a folder card to expand or collapse its assets inline.
|
| 105 |
+
- Duplicate the complete project under a new run name when you want a separate
|
| 106 |
+
production with the same library.
|
| 107 |
+
|
| 108 |
+
Folder names, order, membership, and expansion state are presentation only;
|
| 109 |
+
they do not change prompts or generation fingerprints.
|
| 110 |
+
|
| 111 |
+
Selecting an audio asset opens a lyrics workspace. Lyrics are saved with the
|
| 112 |
+
catalog and recovery backup, but remain notes: they are not added to prompts or
|
| 113 |
+
generation fingerprints.
|
| 114 |
+
|
| 115 |
+
## Storage and recovery
|
| 116 |
+
|
| 117 |
+
Project-owned input media is stored under:
|
| 118 |
+
|
| 119 |
+
```text
|
| 120 |
+
ComfyUI/input/h3_projects/<run_name>/
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
Recovery copies and catalog metadata are stored with the run under:
|
| 124 |
+
|
| 125 |
+
```text
|
| 126 |
+
ComfyUI/output/h3_chains/<run_name>/project_assets/
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
The workflow JSON keeps compact catalog metadata rather than embedding media.
|
| 130 |
+
The run backup lets the project be restored if an input binding is moved or the
|
| 131 |
+
workflow is opened on another system.
|
| 132 |
+
|
| 133 |
+
Deleting a generated run through Checkpoint Manager does not delete the
|
| 134 |
+
original project-owned assets under `ComfyUI/input/h3_projects/`.
|
| 135 |
+
|
| 136 |
+
For prompt tag behavior, see [Scheduled references](SCHEDULED_REFERENCES.md).
|
| 137 |
+
For run backups and recovery, see [Runs and recovery](RUNS_AND_RECOVERY.md).
|
ComfyUI-MiniMaxH3-Context-Loop/docs/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Documentation
|
| 2 |
+
|
| 3 |
+
Start with the page that matches what you are doing now. You do not need to
|
| 4 |
+
read the implementation references to run a normal workflow.
|
| 5 |
+
|
| 6 |
+
## New here
|
| 7 |
+
|
| 8 |
+
1. [Getting started](GETTING_STARTED.md) — install, open an example, render,
|
| 9 |
+
review, and find the result.
|
| 10 |
+
2. [Workflow catalog](../example_workflows/README.md) — choose the smallest
|
| 11 |
+
example for your task.
|
| 12 |
+
3. [Node guide](NODE_REFERENCE.md) — understand the visible nodes, sockets, and
|
| 13 |
+
disabled-node notation.
|
| 14 |
+
4. [0.5 → 0.6 major improvements](assets/minimax-h3-context-loop-0.5-to-0.6-major-improvements.png)
|
| 15 |
+
— a Discord-ready visual overview of the current release.
|
| 16 |
+
|
| 17 |
+
## Guides by task
|
| 18 |
+
|
| 19 |
+
| I need to… | Read this |
|
| 20 |
+
|---|---|
|
| 21 |
+
| Write or reorder scenes | [Scene authoring](SCENE_AUTHORING.md) |
|
| 22 |
+
| Manage pictures, video, audio, and Source-track assets | [Project Asset Carousel](PROJECT_ASSETS.md) |
|
| 23 |
+
| Choose continuity or audio behavior | [Audio and continuity](AUDIO_AND_CONTINUITY.md) |
|
| 24 |
+
| Use image, video, motion, or audio references | [Scheduled references](SCHEDULED_REFERENCES.md) |
|
| 25 |
+
| Review takes, resume, recover, or assemble | [Runs and recovery](RUNS_AND_RECOVERY.md) |
|
| 26 |
+
| Inpaint, outpaint, extend, or bridge video | [Masked editing](MASKED_EDITING.md) |
|
| 27 |
+
| Extend an existing video or use special context | [Advanced workflows](ADVANCED_WORKFLOWS.md) |
|
| 28 |
+
| Fix a missing-node or runtime compatibility problem | [Compatibility](COMPATIBILITY.md) |
|
| 29 |
+
| Move an older workflow to the current contract | [Migrating to 0.5](MIGRATING_TO_0_5.md) |
|
| 30 |
+
|
| 31 |
+
## Reference material
|
| 32 |
+
|
| 33 |
+
These pages are useful when you need exact behavior rather than a first-run
|
| 34 |
+
explanation.
|
| 35 |
+
|
| 36 |
+
| Reference | Contents |
|
| 37 |
+
|---|---|
|
| 38 |
+
| [Complete Plan format](../H3_CHAIN_FORMAT_GUIDE.md) | Every Plan and per-scene field, exact timing, prompt syntax, and JSON forms |
|
| 39 |
+
| [Version 0.5 architecture](V0_5_ARCHITECTURE.md) | Frozen Source Timeline, policy, dependency, migration, and preflight contracts |
|
| 40 |
+
| [Reference Video Fade](REFERENCE_VIDEO_FADE.md) | Experimental denoising-time control of Ref2VA video influence |
|
| 41 |
+
| [Feature traceability](FEATURE_TRACEABILITY.md) | Origins, upstream links, implementation files, and commit evidence |
|
| 42 |
+
|
| 43 |
+
## Project information
|
| 44 |
+
|
| 45 |
+
- [Changelog](../CHANGELOG.md)
|
| 46 |
+
- [Third-party credits and licenses](../THIRD_PARTY_NOTICES.md)
|
| 47 |
+
- [Contributing](../CONTRIBUTING.md)
|
| 48 |
+
- [Example asset licenses](../example_workflows/assets/README.md)
|
| 49 |
+
- [Dormant Prompt Assistant design study](../AGENT_PROMPT_ASSISTANT_STUDY.md)
|
| 50 |
+
|
| 51 |
+
### Terms used in these docs
|
| 52 |
+
|
| 53 |
+
- **Scene** — one planned H3 generation.
|
| 54 |
+
- **Run** — one named production and its saved checkpoint history.
|
| 55 |
+
- **Segment** — the delivered media saved for one scene.
|
| 56 |
+
- **Manifest** — the verified list of selected segments used for recovery or
|
| 57 |
+
final assembly.
|
| 58 |
+
- **Muted node** — present in the workflow but not executed.
|
| 59 |
+
- **Bypassed node** — present, but forwarding a compatible input instead of
|
| 60 |
+
applying its normal operation.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/REFERENCE_VIDEO_FADE.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reference Video Fade (experimental)
|
| 2 |
+
|
| 3 |
+
## Why it exists
|
| 4 |
+
|
| 5 |
+
MiniMax H3 does not reduce a Ref2VA video to poses. Core VAE-encodes the
|
| 6 |
+
complete 24 fps reference and packs its latent rows into the same transformer
|
| 7 |
+
sequence as text, reference pictures/audio, and the target AV streams. Those
|
| 8 |
+
video rows remain available at every DiT block and every sampling evaluation.
|
| 9 |
+
|
| 10 |
+
That strong path explains why a simple character-swap prompt can reproduce the
|
| 11 |
+
reference camera, action, and timing so closely. It also means source color,
|
| 12 |
+
background, framing, and texture can influence the target even though source
|
| 13 |
+
pixels are not directly composited into the output.
|
| 14 |
+
|
| 15 |
+
Dropping every second or third input frame is not a safe strength control. It
|
| 16 |
+
changes reference speed, duration, and temporal coordinates. Repeating frames
|
| 17 |
+
keeps duration but introduces stepped motion. Reference Video Fade therefore
|
| 18 |
+
keeps the complete video and schedules its transformer contribution instead.
|
| 19 |
+
|
| 20 |
+
## What the node does
|
| 21 |
+
|
| 22 |
+
**MiniMax H3 Reference Video Fade (Experimental)** is an opt-in MODEL patch.
|
| 23 |
+
During early denoising, native reference-video rows remain at full value
|
| 24 |
+
strength. After `fade_start`, their value contribution follows a half-cosine
|
| 25 |
+
curve toward `end_strength`:
|
| 26 |
+
|
| 27 |
+
```text
|
| 28 |
+
strength = 1 progress <= fade_start
|
| 29 |
+
u = (progress - fade_start) / (1 - fade_start)
|
| 30 |
+
strength = end + (1-end) × 0.5 × (1 + cos(pi×u)) progress > fade_start
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
`progress` is measured against the complete sigma schedule from 0 at the first
|
| 34 |
+
sampling level to 1 at the end. The node modifies only H3 attention V slices
|
| 35 |
+
belonging to native `video` and `video_audio` reference blocks. H3's V tensor
|
| 36 |
+
is already fresh scratch memory, so the slices are scaled in place without a
|
| 37 |
+
sequence-sized mask, a duplicate model, or a second attention pass.
|
| 38 |
+
|
| 39 |
+
The following remain unchanged:
|
| 40 |
+
|
| 41 |
+
- native still-picture references;
|
| 42 |
+
- Qwen text and visual-presentation tokens;
|
| 43 |
+
- native reference audio;
|
| 44 |
+
- continuation guides or AV-prefix masks;
|
| 45 |
+
- target video and target audio rows.
|
| 46 |
+
|
| 47 |
+
The reference video is still completely encoded and supplied to H3. This is a
|
| 48 |
+
smooth reduction of its attention value contribution, not a hard removal. Its
|
| 49 |
+
Q/K rows and Qwen presentation can still carry weaker semantic influence.
|
| 50 |
+
|
| 51 |
+
## Relationship to continuation color control
|
| 52 |
+
|
| 53 |
+
Reference Video Fade and **Color-Stable Drift AV** act on different inputs and
|
| 54 |
+
can be used together:
|
| 55 |
+
|
| 56 |
+
- Reference Video Fade reduces late denoising influence from an external
|
| 57 |
+
native Ref2VA motion video. It is the control to test when source framing,
|
| 58 |
+
background, or color keeps leaking into the generated scene.
|
| 59 |
+
- Color-Stable Drift AV corrects the disposable previous-scene prefix used for
|
| 60 |
+
chain continuation. It does not alter the external motion reference, source
|
| 61 |
+
audio, or saved scene latent.
|
| 62 |
+
|
| 63 |
+
Use Reference Video Fade alone to diagnose motion-reference leakage. Add
|
| 64 |
+
Color-Stable Drift AV only when chained scenes also develop their own
|
| 65 |
+
scene-to-scene color drift. Neither feature modifies the final assembled video.
|
| 66 |
+
|
| 67 |
+
## Presets
|
| 68 |
+
|
| 69 |
+
| Preset | Full reference through | End strength |
|
| 70 |
+
|---|---:|---:|
|
| 71 |
+
| `full` | 100% | 100% (true bypass) |
|
| 72 |
+
| `balanced` | 67% | 20% |
|
| 73 |
+
| `freer` | 50% | 15% |
|
| 74 |
+
| `early_only` | 50% | 0% |
|
| 75 |
+
| `custom` | `custom_fade_start` | `custom_end_strength` |
|
| 76 |
+
|
| 77 |
+
Start with `balanced`. Use `freer` when the reference reproduces source color,
|
| 78 |
+
background, or camera too literally. `early_only` is an aggressive diagnostic,
|
| 79 |
+
not a recommended default.
|
| 80 |
+
|
| 81 |
+
## Wiring
|
| 82 |
+
|
| 83 |
+
For one sampler/model:
|
| 84 |
+
|
| 85 |
+
```text
|
| 86 |
+
H3 MODEL → other backend/model patches → Reference Video Fade → sampler
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
In a Context Loop Drift-Control graph, the faded MODEL may feed Chain Context's
|
| 90 |
+
MODEL input; use Chain Context's MODEL output for sampling.
|
| 91 |
+
|
| 92 |
+
For split-sigma sampling, connect the original **unsplit** schedule to
|
| 93 |
+
`full_sigmas`. If the split switches models, put one Reference Video Fade node
|
| 94 |
+
on each model branch and connect the same `full_sigmas` to every copy. This
|
| 95 |
+
prevents the second half from restarting the fade at progress zero.
|
| 96 |
+
|
| 97 |
+
Place the node after an attention-backend selector when possible. It chains an
|
| 98 |
+
already installed optimized-attention override, including SolAttn or Comfy
|
| 99 |
+
Kitchen, instead of replacing it.
|
| 100 |
+
|
| 101 |
+
## Scope and limitations
|
| 102 |
+
|
| 103 |
+
- All native `video` and `video_audio` reference blocks are faded. Core does
|
| 104 |
+
not preserve Tagged-reference role names in the packed payload, so a graph
|
| 105 |
+
with several native reference videos fades all of them. Still pictures are
|
| 106 |
+
never selected.
|
| 107 |
+
- This is value gating, not an exact target-query-to-reference-key attention
|
| 108 |
+
mask. It is deliberately the low-memory implementation suitable for H3's
|
| 109 |
+
very long packed sequence.
|
| 110 |
+
- It does not fade the 2 fps Qwen presentation derived from the reference
|
| 111 |
+
video. Prompts can therefore retain semantic awareness after the native
|
| 112 |
+
latent-video contribution has weakened.
|
| 113 |
+
- Results are model-, sampler-, schedule-, and prompt-dependent. Treat the
|
| 114 |
+
presets as experimental comparisons and keep the rest of the graph fixed.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/RUNS_AND_RECOVERY.md
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runs, review, and recovery
|
| 2 |
+
|
| 3 |
+
## Stable 0.6.2 recovery update
|
| 4 |
+
|
| 5 |
+
Update the Context Loop pack and restart ComfyUI to load the upscale/cache fixes.
|
| 6 |
+
Existing workflows and legacy reference bundles remain supported; conversion is
|
| 7 |
+
optional. The PNG exporter is still **MiniMax H3 Context Loop Export PNG Sequence
|
| 8 |
+
+ Audio**, with optional file-backed VIDEO/state inputs and an appended VIDEO
|
| 9 |
+
passthrough output. No replacement of the old latent-export workflow is required.
|
| 10 |
+
|
| 11 |
+
New generation and alternate takes save immutable recovery documents under
|
| 12 |
+
`h3_chains/<run>/recovery_archives/<revision>/`. The active Run also retains
|
| 13 |
+
`plan.json`, `workflow.json`, and `api_prompt.json` for older recovery tools.
|
| 14 |
+
Historical checkpoint output uses its own snapshot, not a later edited Plan.
|
| 15 |
+
Older root-only archives remain readable but cannot retroactively supply unknown
|
| 16 |
+
reference timing/settings. Rebuilding disposable reference latents still requires
|
| 17 |
+
the original saved reference media and identities; missing or altered originals
|
| 18 |
+
produce an actionable error instead of silently changing the render.
|
| 19 |
+
|
| 20 |
+
## Review Gate
|
| 21 |
+
|
| 22 |
+
Place **Review Gate** between Segment + Checkpoint and Loop End. Each scene is
|
| 23 |
+
persisted before the gate waits, then the gate offers:
|
| 24 |
+
|
| 25 |
+
- **Approve & continue**
|
| 26 |
+
- **Generate next candidate** when a multi-take batch is not complete
|
| 27 |
+
- **Retry scene / seed / length**
|
| 28 |
+
- **Reroll seed**
|
| 29 |
+
- **Approve & stop**, optionally assembling a partial video
|
| 30 |
+
|
| 31 |
+
Set Review Gate's optional **candidate_count** above 1 to collect several
|
| 32 |
+
different-seed takes. The gate pauses after every saved candidate instead of
|
| 33 |
+
blindly generating the full batch. Use **Generate next candidate** to continue,
|
| 34 |
+
or accept the current scene early and interrupt the remaining batch. The
|
| 35 |
+
carousel previews every completed take with synchronized sound; arrow keys and
|
| 36 |
+
the visible dots move between them. Mark one or more takes to keep, then choose
|
| 37 |
+
the take that becomes the active continuation. Its saved video frames and AV
|
| 38 |
+
tensors—not the last generated take—become the context for the following scene.
|
| 39 |
+
The active take and checked alternatives remain in checkpoint history; unkept
|
| 40 |
+
alternatives are deleted only after the active checkpoint is promoted safely.
|
| 41 |
+
The default value of 1 keeps the normal one-take review behavior. The widget can
|
| 42 |
+
be converted to an input and driven by a regular INT node; the safety limit is
|
| 43 |
+
20 candidates per scene.
|
| 44 |
+
|
| 45 |
+
Notification sound, automatic timeout, and model unloading while waiting are
|
| 46 |
+
optional. Drag the bar below the player to resize it; double-click to restore
|
| 47 |
+
the default height.
|
| 48 |
+
|
| 49 |
+
When a Scene Prompt Editor or Rich Scene Prompt Editor is bound to the same
|
| 50 |
+
Plan, Review Gate selects the scene under review there automatically. Editor
|
| 51 |
+
changes are used by **Retry prompt / seed** or **Reroll seed** through the live
|
| 52 |
+
Plan prompt. In 0.5, Review Gate's own prompt field is disabled by default.
|
| 53 |
+
Restore it under **Settings → MiniMax H3 Context Loop → Interface → Review Gate
|
| 54 |
+
→ Enable prompt editing inside Review Gate**. When enabled, text explicitly
|
| 55 |
+
typed in that field wins for the submitted retry and is synchronized back to
|
| 56 |
+
the Plan and connected editor after the server accepts it.
|
| 57 |
+
|
| 58 |
+
During sampling, the optional floating **Cancel & reroll scene N** control
|
| 59 |
+
targets only the active H3 prompt. It waits for confirmed interruption, writes a
|
| 60 |
+
new explicit scene seed, moves Loop Start to that scene, preserves a bounded
|
| 61 |
+
range end, and queues normally. Once saving or review begins, Review Gate owns
|
| 62 |
+
the retry instead.
|
| 63 |
+
|
| 64 |
+
Disable the floating control under **Settings → MiniMax H3 Context Loop →
|
| 65 |
+
Interface → Cancel & reroll** without affecting Review Gate.
|
| 66 |
+
|
| 67 |
+
## Between-scene memory cleanup
|
| 68 |
+
|
| 69 |
+
Loop End can apply a runtime-only `between_scene_cleanup` policy after the
|
| 70 |
+
current scene and checkpoint are durable, immediately before it starts the next
|
| 71 |
+
scene or retry:
|
| 72 |
+
|
| 73 |
+
- `off` keeps ComfyUI's normal caches.
|
| 74 |
+
- `unload_models` unloads model weights and empties the device allocator.
|
| 75 |
+
- `fresh_scene` first asks ComfyUI's active RAM-pressure cache to evict reusable
|
| 76 |
+
execution outputs and runs Python garbage collection, then releases remaining
|
| 77 |
+
pinned model pages, unloads models, and empties the device allocator. Use it
|
| 78 |
+
for chains that switch large models between scenes; the next scene will reload
|
| 79 |
+
anything that was evicted.
|
| 80 |
+
|
| 81 |
+
The policy does not enter Plan or resume hashes. It deliberately preserves the
|
| 82 |
+
small recursive carry and dynamic graph result because ComfyUI has no supported
|
| 83 |
+
way to reset the entire executor while that graph is still resolving. On cache
|
| 84 |
+
types other than RAM pressure, `fresh_scene` still unloads models and clears
|
| 85 |
+
allocators, but no executor-output eviction callback is available.
|
| 86 |
+
|
| 87 |
+
## Resume
|
| 88 |
+
|
| 89 |
+
For a fresh run:
|
| 90 |
+
|
| 91 |
+
```text
|
| 92 |
+
run_name: choose a new name
|
| 93 |
+
start_clip: 1
|
| 94 |
+
scene_range: blank
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
To resume scene N, keep the original `run_name` and dependency settings, then
|
| 98 |
+
set `start_clip: N`. The loop loads checkpoint N−1 and validates all completed
|
| 99 |
+
predecessors that the selected scene actually consumes. Editing scene N or
|
| 100 |
+
later is safe; changing an earlier prompt,
|
| 101 |
+
seed, timing, source waveform, Plan compatibility setting, or
|
| 102 |
+
`generation_fingerprint` invalidates the dependent resume.
|
| 103 |
+
|
| 104 |
+
A visually new scene can still consume the immediately previous generated
|
| 105 |
+
audio when its video context is `0` but its audio context is positive. That
|
| 106 |
+
audio-only edge validates the predecessor's prompt/model/source identity and
|
| 107 |
+
saved artifacts, but ignores its unrelated incoming visual-boundary recipe.
|
| 108 |
+
Set both scene video context and audio context to `0` (or select Visual Cut)
|
| 109 |
+
for a completely independent scene.
|
| 110 |
+
|
| 111 |
+
Loop Start's `verify_resume_history` switch is enabled by default. Disable it
|
| 112 |
+
only when you intentionally want scene N to consume the existing saved scene
|
| 113 |
+
N−1 despite a changed Plan. The override skips Plan/history matching; it does
|
| 114 |
+
not skip missing-file checks, SHA-256 artifact validation, checkpoint tensor
|
| 115 |
+
validation, or metadata's own recorded-history consistency. Consequently, any
|
| 116 |
+
new settings that describe the saved predecessor are not retroactively present
|
| 117 |
+
in its pixels or AV latent.
|
| 118 |
+
|
| 119 |
+
Plan-wide continuation mode and context length are the exceptions: they choose
|
| 120 |
+
how the next scene consumes its saved predecessor. Changing either does not
|
| 121 |
+
alter completed frames or their saved AV latent, so it does not invalidate the
|
| 122 |
+
prefix. If the checkpoint's cached decoded tail is shorter than the newly
|
| 123 |
+
requested context, the loop re-decodes its complete saved video latent and
|
| 124 |
+
extracts the longer tail without regenerating the scene. Explicit per-scene
|
| 125 |
+
continuation and context overrides remain part of that scene's history.
|
| 126 |
+
|
| 127 |
+
Review Gate's checkpoint browser can set up this resume and preview the joined
|
| 128 |
+
partial through the selected predecessor.
|
| 129 |
+
|
| 130 |
+
**Manifest Load** also supports interrupted runs. It discovers the longest
|
| 131 |
+
contiguous active checkpoint prefix beginning at scene 1, verifies every scene
|
| 132 |
+
and artifact through that point, and emits a partial manifest when later scenes
|
| 133 |
+
have not been saved. Connect that output directly to **Assemble** to recover the
|
| 134 |
+
finished prefix without sampling again. A missing scene ends the prefix; an
|
| 135 |
+
orphaned later checkpoint is never joined across the gap. When every planned
|
| 136 |
+
scene is present, the same node emits the normal completed manifest.
|
| 137 |
+
|
| 138 |
+
### Restore an earlier scene revision
|
| 139 |
+
|
| 140 |
+
**Refresh** in Review Gate discovers the active checkpoint and every immutable
|
| 141 |
+
revision retained for that scene. Choose the scene to resume, then select the
|
| 142 |
+
desired version of each predecessor under **Checkpoint history**. Clicking
|
| 143 |
+
**Restore & load** validates the selected MP4, safetensors checkpoint, hashes,
|
| 144 |
+
shared prompt, and compatibility contract before atomically promoting the
|
| 145 |
+
selected prefix. The corresponding prompts, seeds, lengths, steps, and scene
|
| 146 |
+
identifiers are restored into the connected Plan, and Loop Start is armed for
|
| 147 |
+
the next scene.
|
| 148 |
+
|
| 149 |
+
The active versions are selected by default. Restoring an earlier version does
|
| 150 |
+
not delete the current one, so another revision can be promoted later. Exact
|
| 151 |
+
continuation requires the revision's checkpoint metadata and safetensors file;
|
| 152 |
+
an MP4 copied from `segments/` or `reviews/` alone cannot recreate the saved AV
|
| 153 |
+
latent. When only video survives, use Existing Video Context as a re-encoded
|
| 154 |
+
continuation instead.
|
| 155 |
+
|
| 156 |
+
Retrying, rerolling, and candidate collection intentionally retain earlier
|
| 157 |
+
files as immutable revisions; they are recovery points rather than abandoned
|
| 158 |
+
temporary files. Inactive leaf revisions can be deleted from the same panel or
|
| 159 |
+
the dedicated Checkpoint Manager to reclaim space.
|
| 160 |
+
Review Gate now retrieves a fresh server-side deletion preview before asking
|
| 161 |
+
for confirmation. Active revisions and revisions with dependent later scenes
|
| 162 |
+
cannot be deleted. Cleanup is limited to that revision's segment, safetensors
|
| 163 |
+
checkpoint, prompt/audio/blend sidecars, unshared preview, and versioned
|
| 164 |
+
metadata; Plan archives, assets, prompt history, assembled exports, and other
|
| 165 |
+
revisions are never included.
|
| 166 |
+
|
| 167 |
+
## Checkpoint Manager
|
| 168 |
+
|
| 169 |
+
Connect the active Plan to **MiniMax H3 Checkpoint Manager** when you want its
|
| 170 |
+
run preselected or want **Load selected branch** to restore saved Plan values.
|
| 171 |
+
The node outputs only `selected_manifest`; it is not a Plan pass-through and is
|
| 172 |
+
normally kept beside the generation route. Its run selector can inspect any
|
| 173 |
+
other folder under `output/h3_chains`.
|
| 174 |
+
|
| 175 |
+
Select a **branch heading** to choose the whole generated branch for output.
|
| 176 |
+
Clicking an individual clip only previews it; it does not shorten the output
|
| 177 |
+
or switch its branch. Use the downstream upscale/export range controls to
|
| 178 |
+
choose which scenes to process.
|
| 179 |
+
|
| 180 |
+
For an experiment, click **Use branch locally**. This saves the entire browsed
|
| 181 |
+
branch (the row containing the previewed clip), not just its prefix through
|
| 182 |
+
that clip. It pins the exact lineage on `selected_manifest` in this workflow
|
| 183 |
+
only, including the earlier chapters included in that selection. Browsing
|
| 184 |
+
other takes, refreshing, reopening the workflow, or activating another branch
|
| 185 |
+
in a different tab cannot move this pin. Saved files are still verified on
|
| 186 |
+
execution: a missing or corrupt pinned take fails instead of falling back to
|
| 187 |
+
the project's active branch. The output row and **local output** badges show
|
| 188 |
+
which revisions are pinned. **Follow branch selection** releases the pin;
|
| 189 |
+
output then follows branch-heading selections, never clip previews. An explicit
|
| 190 |
+
run switch asks before releasing it. Save the workflow to keep the pin on disk.
|
| 191 |
+
|
| 192 |
+
Older workflows can contain a partial pin saved while previewing an ancestor.
|
| 193 |
+
The output row flags this. Choose the intended branch heading and click
|
| 194 |
+
**Use branch locally** once to replace that old selection. Shared ancestors
|
| 195 |
+
can lead to multiple branches, so reopening a workflow never guesses which
|
| 196 |
+
descendant you intended or silently switches an existing snapshot.
|
| 197 |
+
|
| 198 |
+
**Make branch active (project)**, **Roll active branch back (project)** and
|
| 199 |
+
**Load selected branch** remain project-wide actions. Local selection neither
|
| 200 |
+
restores connected Plan settings nor arms generation/resume. Downstream nodes
|
| 201 |
+
retain their own save and deletion protections. Use the
|
| 202 |
+
manager's manifest output for the experiment, not Manifest Load (which reads the
|
| 203 |
+
project's active checkpoints).
|
| 204 |
+
|
| 205 |
+
The manager groups immutable scene revisions into inferred branches. A revision
|
| 206 |
+
can appear in more than one branch when it is their shared ancestor. Selecting
|
| 207 |
+
a revision shows its saved preview, prompt, seed, timing, canvas, storage,
|
| 208 |
+
parent, following scenes, and the exact video/audio frame context those
|
| 209 |
+
following scenes consume. Older checkpoints derive this graph from predecessor
|
| 210 |
+
revision and checkpoint hashes; newly saved checkpoints also carry a stable
|
| 211 |
+
branch id and effective context fields.
|
| 212 |
+
|
| 213 |
+
Plan chapters are checkpoint-management boundaries. The **All scenes** tab
|
| 214 |
+
shows a separate graph for each chapter. Project-wide activation of a Chapter 2
|
| 215 |
+
branch changes only Chapter 2 pointers and connected Plan scene values; Chapter
|
| 216 |
+
1 keeps its current active branch. The chapter-start checkpoint can retain its
|
| 217 |
+
original predecessor as provenance, but that structural edge does not choose
|
| 218 |
+
the preceding chapter. Explicit visual or audio context recorded by a scene is
|
| 219 |
+
still preserved as a generation dependency.
|
| 220 |
+
|
| 221 |
+
If a branch ends with an empty next-scene slot and saved candidates
|
| 222 |
+
exist elsewhere, the graph displays **Reuse saved clip**. Click it to preview
|
| 223 |
+
the available candidates and choose **Attach selected candidate**. This is
|
| 224 |
+
offered only when the candidate consumes neither predecessor video context nor
|
| 225 |
+
generated-audio continuity. The manager creates a new immutable lineage record
|
| 226 |
+
pointing at the chosen parent; the original video, audio, prompt, and checkpoint
|
| 227 |
+
files remain shared and are not regenerated or copied. Shared-file deletion is
|
| 228 |
+
reference-aware, so those files are retained until the last lineage record that
|
| 229 |
+
uses them is deleted.
|
| 230 |
+
|
| 231 |
+
Reuse is checked against the saved take's resolved context and audio policy,
|
| 232 |
+
not the current Plan. Candidates that cannot safely change parents show a
|
| 233 |
+
reason instead of disappearing. A rollback tip still appears as an active
|
| 234 |
+
branch even when its old descendants remain saved.
|
| 235 |
+
|
| 236 |
+
Rerendering an earlier scene can leave old pointer files on disk. Checkpoint
|
| 237 |
+
Manager, Plan Studio and recovery exclude those disconnected descendants from
|
| 238 |
+
the active chapter branch. They remain available as saved inactive takes; no
|
| 239 |
+
media, checkpoint, prompt, scene slot or chapter export is deleted. Use
|
| 240 |
+
**Make branch active / Roll active branch back** to explicitly reconcile those
|
| 241 |
+
old pointers. Recovery stops at the coherent saved prefix, and resuming through
|
| 242 |
+
an inactive tail requires selecting or attributing a matching lineage first,
|
| 243 |
+
even with history verification disabled.
|
| 244 |
+
|
| 245 |
+
If deleting the active branch tip rolls the run back while alternate leaf
|
| 246 |
+
revisions remain, select the surviving branch and click **Make branch active**.
|
| 247 |
+
The manager validates and promotes that revision's chapter lineage directly,
|
| 248 |
+
including when no Plan is connected. With an editable Plan connected, it also
|
| 249 |
+
restores the lineage's saved scene prompts, seeds, lengths, steps, context,
|
| 250 |
+
LoRA route, and boundary/audio overrides. Exact saved prompts reactivate their
|
| 251 |
+
existing Prompt Editor history revisions instead of adding duplicates.
|
| 252 |
+
Immutable revisions, workflow state, references, and assembled videos are left
|
| 253 |
+
untouched. You can then keep the chosen candidate active and continue deleting
|
| 254 |
+
the rejected inactive leaves.
|
| 255 |
+
**Load selected branch** remains the separate resume operation: it also restores
|
| 256 |
+
saved Plan inputs and arms Loop Start for the following scene.
|
| 257 |
+
|
| 258 |
+
Deletion is deliberately one scene revision at a time:
|
| 259 |
+
|
| 260 |
+
1. Select an inactive revision.
|
| 261 |
+
2. Inspect its complete file list, estimated reclaimed size, and preserved
|
| 262 |
+
categories.
|
| 263 |
+
3. If later revisions depend on it, select and delete those leaf revisions
|
| 264 |
+
first.
|
| 265 |
+
4. Confirm the now-safe leaf deletion. If anything changed after the preview,
|
| 266 |
+
the server refuses it and asks for a fresh preview.
|
| 267 |
+
|
| 268 |
+
This first release does not bulk-delete branches. The leaf-first workflow makes
|
| 269 |
+
the exact context consequences visible and avoids silently orphaning later
|
| 270 |
+
checkpoints.
|
| 271 |
+
|
| 272 |
+
Chapter recovery snapshots also protect the takes and recovery files they use.
|
| 273 |
+
Historical `supersedes` links do **not** protect a replaced take: they are an
|
| 274 |
+
audit trail, not recovery inputs. Actual scene, alternate and recovery-file
|
| 275 |
+
references still block deletion.
|
| 276 |
+
|
| 277 |
+
If an unwanted snapshot is the blocker, the deletion inspector offers
|
| 278 |
+
**Retire Chapter … snapshot …**. Inspect the scenes and confirm to move only
|
| 279 |
+
that snapshot's JSON from `chapters/<chapter>/manifests/` into the sibling
|
| 280 |
+
`retired_manifests/` directory. No clip, active pointer, processing take,
|
| 281 |
+
reference or assembled export is deleted. Other retained snapshots and branch
|
| 282 |
+
dependencies still block unsafe deletion; remove unused leaves first.
|
| 283 |
+
|
| 284 |
+
Retired snapshots no longer appear in Chapter Loader, release their recovery
|
| 285 |
+
pins and cannot be silently republished by a stale selection. Workflows pinned
|
| 286 |
+
to them need a new source. The archived JSON keeps the original bytes; it can
|
| 287 |
+
be moved back to `manifests/` to restore the snapshot only while all its inputs
|
| 288 |
+
still exist. Deleting those inputs later makes full recovery unavailable.
|
| 289 |
+
Retirement requires a fresh preview on confirmation and uses the Run mutation
|
| 290 |
+
lock. Workflow-ownership locking remains nightly-only.
|
| 291 |
+
|
| 292 |
+
### Saved processing tabs
|
| 293 |
+
|
| 294 |
+
Checkpoint Manager has **Original**, **DeRoPE**, and **Latent Upscale** tabs.
|
| 295 |
+
**Pixel Upscale** and **Other processing** also appear when those saved profiles
|
| 296 |
+
exist. Chapter filtering applies to the processing views. The original branch
|
| 297 |
+
rows stay in place, with each clip's saved processing takes shown beneath its
|
| 298 |
+
source revision, including retained older takes and multiple profiles.
|
| 299 |
+
|
| 300 |
+
The catalogue reads the existing `upscaled/<profile>/checkpoints/` directories
|
| 301 |
+
at run and chapter scope. It matches both source revision and checkpoint hash;
|
| 302 |
+
scene number alone never attaches a take to another branch. Attributed aliases
|
| 303 |
+
with matching saved content can share versions. Unresolved sources remain
|
| 304 |
+
visible in a separate section. Stage labels come from the saved recipe/backend,
|
| 305 |
+
not the profile folder name; the bundled combined LBH + DeRoPE recipe appears
|
| 306 |
+
under DeRoPE at its actual saved resolution.
|
| 307 |
+
|
| 308 |
+
Switching tabs or inspecting a derivative does not rewrite `selected_manifest`,
|
| 309 |
+
move a local pin, activate a branch, restore a Plan, or delete a checkpoint.
|
| 310 |
+
For a later latent-upscale pass, select a saved take in **DeRoPE**, then click
|
| 311 |
+
**Use DeRoPE branch locally**. This pins the whole saved processing branch to
|
| 312 |
+
the browsed original branch; the downstream Adapter still controls start/end.
|
| 313 |
+
Scenes absent from that DeRoPE branch automatically use their selected original
|
| 314 |
+
take. A saved but incomplete/corrupt latent is an error, not an original fallback.
|
| 315 |
+
Return to **Original → Use branch locally** to explicitly use originals again.
|
| 316 |
+
|
| 317 |
+
New saves keep an immutable processing-lineage snapshot. Older saves can use
|
| 318 |
+
their existing full/partial profile manifest if it identifies the chosen take.
|
| 319 |
+
A shared take with multiple processing descendants is ambiguous: choose a later
|
| 320 |
+
take unique to the desired processing branch. No "latest scene" mixing occurs.
|
| 321 |
+
|
| 322 |
+
The inspector reports canvas, RAW/delivered frames, audio route, full-latent
|
| 323 |
+
save status, profile and metadata location. A continuation tail is explicitly
|
| 324 |
+
not a full latent. Listing checks file availability without loading tensors or
|
| 325 |
+
hashing every large checkpoint; availability is not execution validation.
|
| 326 |
+
|
| 327 |
+
To remove a saved processing take, select it in its processing tab and click
|
| 328 |
+
**Delete processed version**. The file/size preview and confirmation cover only
|
| 329 |
+
that take's video, checkpoint, audio and prompt sidecars, its revision metadata,
|
| 330 |
+
its current processed pointer (if still selected), and affected full/partial
|
| 331 |
+
branch manifests. Removing a pointer does not promote another take. Older takes,
|
| 332 |
+
original generation checkpoints, shared references/caches and assembled videos
|
| 333 |
+
are kept. This also works for processing takes whose original is unavailable.
|
| 334 |
+
|
| 335 |
+
Deletion is blocked while another saved take depends on that processing source
|
| 336 |
+
or requires its saved branch; the inspector lists clickable dependents to
|
| 337 |
+
delete first. Independent **pixel** takes are the exception to sequence ordering:
|
| 338 |
+
the saved pixel backend with zero HQ context proves that a later clip does not
|
| 339 |
+
consume its predecessor's processed output. Such later clips are kept when an
|
| 340 |
+
earlier take is deleted. This applies to legacy full/partial manifests and new
|
| 341 |
+
immutable lineage snapshots, not just newly rendered clips. Actual source or
|
| 342 |
+
context references still block deletion, and unproven/other backends retain
|
| 343 |
+
their conservative branch protection.
|
| 344 |
+
|
| 345 |
+
Affected sequence manifests are invalidated, not shortened or spliced across
|
| 346 |
+
the missing scene. Surviving clip files, pointers and immutable metadata stay
|
| 347 |
+
unchanged and visible in their processing tab. An old lineage with a missing
|
| 348 |
+
take is no longer offered as a complete source branch. Rebuild the missing
|
| 349 |
+
scenes before resuming a full sequence; no other take is silently substituted.
|
| 350 |
+
|
| 351 |
+
Ownership is checked again on confirmation, and changed files or
|
| 352 |
+
dependencies require a fresh preview. An in-flight save cannot republish a
|
| 353 |
+
deleted processing dependency. A workflow-local pin is never silently redirected:
|
| 354 |
+
if it referenced the deleted take, explicitly select another source before running.
|
| 355 |
+
Deletion is permanent; the preview does not load or hash large tensor files.
|
| 356 |
+
|
| 357 |
+
### Alternate final-cut takes
|
| 358 |
+
|
| 359 |
+
Use an alternate when one accepted scene needs a prompt-level visual correction
|
| 360 |
+
but later scenes already depend on its original checkpoint.
|
| 361 |
+
|
| 362 |
+
1. Open that scene in Plan Studio and expand **Alternate final-cut take**.
|
| 363 |
+
2. Edit the alternate prompt and seed, then enable the draft.
|
| 364 |
+
3. Queue normally. Loop Start renders only that scene.
|
| 365 |
+
4. Approve the alternate in Review Gate.
|
| 366 |
+
|
| 367 |
+
Acceptance selects the alternate picture for preview, assembly, PNG export,
|
| 368 |
+
whole-chain latent finishing, and deferred latent/pixel/CAT upscale loops.
|
| 369 |
+
It does not replace the active generation
|
| 370 |
+
checkpoint: later scenes keep their original visual/audio ancestry, and final
|
| 371 |
+
audio for the corrected scene remains the original audio.
|
| 372 |
+
|
| 373 |
+
Deferred upscale resolves the selected ALT before reading source tensors or
|
| 374 |
+
reference conditioning, for both full-branch and chapter output. It uses the
|
| 375 |
+
ALT's video latent, prompt, seed and reference identity, but reads the original
|
| 376 |
+
audio separately without loading another full video tensor. Adapter/current
|
| 377 |
+
status names the selected ALT, and saved processing takes remain linked to the
|
| 378 |
+
base scene in Checkpoint Manager. A sealed chapter uses its frozen final-cut
|
| 379 |
+
selection; an unsealed full-branch input uses the current selection when the
|
| 380 |
+
loop starts.
|
| 381 |
+
|
| 382 |
+
Resume checks the actual picture source: an upscale previously made from the
|
| 383 |
+
original cannot be reused for a newly selected ALT (or vice versa). Restart at
|
| 384 |
+
the affected scene, or use a new profile. Saved DeRoPE must likewise belong to
|
| 385 |
+
the selected picture; scenes absent from a partial DeRoPE branch use their
|
| 386 |
+
selected ALT/original. Changing an ALT never overwrites generation checkpoints
|
| 387 |
+
or silently swaps an already selected DeRoPE latent for unprocessed media.
|
| 388 |
+
|
| 389 |
+
Plan Studio marks the selection `ALT`. Checkpoint Manager nests the immutable
|
| 390 |
+
alternate under its base take rather than drawing a new continuation branch.
|
| 391 |
+
Choose **Original** in Plan Studio to restore the base picture at any time.
|
| 392 |
+
|
| 393 |
+
An alternate must preserve the scene identity and duration. If a later scene
|
| 394 |
+
exists, set the blend entering that scene to `0`: its saved overlap still shows
|
| 395 |
+
the original base take, so assembly rejects a crossfade from an alternate.
|
| 396 |
+
|
| 397 |
+
### Whole-chain SeedVR2 finishing
|
| 398 |
+
|
| 399 |
+
SeedVR2 is best treated as a final whole-video backend, not as another
|
| 400 |
+
scene-recursive H3 pass. Select the final scene of a complete branch in
|
| 401 |
+
Checkpoint Manager and use this graph:
|
| 402 |
+
|
| 403 |
+
```text
|
| 404 |
+
Checkpoint Manager.selected_manifest
|
| 405 |
+
+ original H3 video VAE
|
| 406 |
+
↓
|
| 407 |
+
Full-Chain Latent Video Adapter
|
| 408 |
+
↓ native, file-backed VIDEO
|
| 409 |
+
SeedVR2 Direct Video Upscaler
|
| 410 |
+
↓
|
| 411 |
+
core Save Video
|
| 412 |
+
```
|
| 413 |
+
|
| 414 |
+
**Full-Chain Latent Video Adapter** verifies and decodes the original
|
| 415 |
+
safetensors video latent for every selected scene; it never reads the saved
|
| 416 |
+
H.264 scene previews. It trims repeated context, applies the saved incoming
|
| 417 |
+
blend schedule, and writes one lossless RGB movie before SeedVR2 starts.
|
| 418 |
+
SeedVR2 therefore chunks a continuous timeline rather than restarting at H3
|
| 419 |
+
scene boundaries. A chain with Existing Video Context also streams its saved
|
| 420 |
+
prelude into the same movie.
|
| 421 |
+
|
| 422 |
+
`decode_buffer=disk-backed` is the recommended default. MiniMax H3's VAE keeps
|
| 423 |
+
its native temporal chunking but writes the decoded float scene into a
|
| 424 |
+
temporary mmap. The adapter converts and encodes one frame at a time, retaining
|
| 425 |
+
only the next boundary window in normal RAM, then deletes that scene buffer.
|
| 426 |
+
Peak ordinary RAM is therefore bounded by latent/model overhead and the blend
|
| 427 |
+
window instead of the decoded duration of the full chain. `memory` is a
|
| 428 |
+
compatibility fallback that holds one decoded scene at a time; neither mode
|
| 429 |
+
holds the whole production.
|
| 430 |
+
|
| 431 |
+
The continuous source is content-addressed and reused at:
|
| 432 |
+
|
| 433 |
+
```text
|
| 434 |
+
output/h3_chains/<run_name>/upscaled/seedvr2/source/<cache_key>.mkv
|
| 435 |
+
```
|
| 436 |
+
|
| 437 |
+
The cache key includes the immutable checkpoint lineage, VAE implementation,
|
| 438 |
+
blend schedule, prelude, and selected audio policy. Disable `reuse_cache` to
|
| 439 |
+
force a fresh VAE decode. `audio_source=plan` recovers the saved final-audio
|
| 440 |
+
policy and source audio directly from the manifest. Both an explicit Source
|
| 441 |
+
Timeline and a legacy AUDIO connected once at Loop Start are now materialized
|
| 442 |
+
as path-backed run state. Only manifests created before that legacy promotion,
|
| 443 |
+
and therefore lacking a recovery descriptor, need the optional `source_audio`
|
| 444 |
+
socket.
|
| 445 |
+
|
| 446 |
+
Use the audio-preserving **SeedVR2 Direct Video Upscaler** from
|
| 447 |
+
[ethanfel/ComfyUI-SeedVR2_VideoUpscaler](https://github.com/ethanfel/ComfyUI-SeedVR2_VideoUpscaler).
|
| 448 |
+
It reads the adapter movie in `chunk_size` batches, returns one file-backed
|
| 449 |
+
H.264 VIDEO, and embeds the source audio in that VIDEO so it can connect
|
| 450 |
+
straight to core Save Video. Its separate AUDIO output remains available for
|
| 451 |
+
alternate muxing graphs.
|
| 452 |
+
|
| 453 |
+
### Export and recover a particular chapter
|
| 454 |
+
|
| 455 |
+
Connect **Load Manifest**, **Loop End**, or **Checkpoint Manager** to
|
| 456 |
+
**Chapter Delivery**, then connect `delivery_manifest` to **Assemble** or
|
| 457 |
+
**Export PNG Sequence + Audio**. Set `enabled=true` and `chapter_number=1`, `2`,
|
| 458 |
+
or another explicit chapter number. `0` selects the chapter containing the last
|
| 459 |
+
generated scene; it is not required when you want a particular older chapter.
|
| 460 |
+
|
| 461 |
+
An unfinished chapter exports only its generated scenes. Chapter Delivery saves
|
| 462 |
+
an immutable manifest with their exact revisions, editorial state, resolution,
|
| 463 |
+
and audio offsets. Later scenes or branch switches cannot extend that snapshot.
|
| 464 |
+
Explicitly deliver the chapter again to capture new scenes or changed takes.
|
| 465 |
+
Unchanged snapshots are reused; changed selections create another snapshot.
|
| 466 |
+
MP4 and PNG/WAV output lives under
|
| 467 |
+
`output/h3_chains/<run>/chapters/<number>_<chapter_id>/`. On main, each PNG export
|
| 468 |
+
uses a new numbered folder; it does not append to an earlier frame export.
|
| 469 |
+
|
| 470 |
+
Use **Chapter Recovery Load** with the run name and chapter number to load a saved
|
| 471 |
+
snapshot independently. An empty snapshot ID loads the newest; a full ID picks
|
| 472 |
+
an exact older version. This is a recovery manifest, not a copy of all media:
|
| 473 |
+
keep the run's referenced checkpoints and assets. Checkpoint Manager protects
|
| 474 |
+
artifacts needed by sealed snapshots from deletion.
|
| 475 |
+
|
| 476 |
+
To select chapter output without saving a snapshot, use Checkpoint Manager's
|
| 477 |
+
**Selected chapter only** scope. A local branch pin alone still outputs its
|
| 478 |
+
selected lineage, including earlier chapters; the scope controls the filtering.
|
| 479 |
+
|
| 480 |
+
### Deferred H3 upscale child runs
|
| 481 |
+
|
| 482 |
+
Select the generated branch heading you want in Checkpoint Manager and click
|
| 483 |
+
**Use branch locally** to keep that source fixed while experimenting,
|
| 484 |
+
then connect its **selected_manifest** output to **MiniMax H3 Checkpoint Upscale
|
| 485 |
+
Adapter**. The manager verifies the immutable lineage and embeds recovery-only
|
| 486 |
+
compatibility and Source Timeline metadata directly. No source Plan, Chain
|
| 487 |
+
Policy, or decoded source media is retained by the recursive upscale graph.
|
| 488 |
+
|
| 489 |
+
Use a separate adapter **profile** name for each upscale experiment. Results
|
| 490 |
+
live under `output/h3_chains/<run>/upscaled/<profile>/`; a local source pin is
|
| 491 |
+
not a separate output folder and does not isolate experiments sharing a profile.
|
| 492 |
+
|
| 493 |
+
To upscale or export just one chapter, set the manager's output scope to
|
| 494 |
+
**Selected chapter only**, select that chapter's branch heading, and click
|
| 495 |
+
**Use branch locally**. Changing the scope of an existing local pin keeps its
|
| 496 |
+
pinned tip; browsing another take does not move it. Earlier chapters may have
|
| 497 |
+
different resolutions: only the selected chapter's media and compatibility are
|
| 498 |
+
validated. Earlier pinned revision metadata is still read to preserve exact
|
| 499 |
+
source-audio and editorial timing. Selecting output does not seal a chapter or
|
| 500 |
+
change project-wide active branches.
|
| 501 |
+
|
| 502 |
+
Chapter output keeps original scene numbers: a Chapter 2 containing saved
|
| 503 |
+
scenes 8–10 outputs scenes 8–10, even if scenes 11–12 are still planned.
|
| 504 |
+
On Upscale Adapter, `start_clip=1` starts at the first selected scene (8 here),
|
| 505 |
+
`end_clip=0` means the last selected scene (10), and `start_clip=9` resumes after
|
| 506 |
+
verifying scene 8's saved HQ output. No HQ prefix from Chapter 1 is required.
|
| 507 |
+
Reference schedules retain their original Plan scene numbering.
|
| 508 |
+
|
| 509 |
+
Extending the selected branch from scene 8 to scenes 8–10 does not invalidate
|
| 510 |
+
an unchanged scene 8 upscale. Resume validates every completed scene's source
|
| 511 |
+
revision and media hashes, RAW/delivered frame clock, saved prompt/sampling
|
| 512 |
+
settings, and upscale profile, plus its saved output artifacts. New saves also
|
| 513 |
+
record a per-scene source contract. Older saves use their existing source
|
| 514 |
+
provenance without rewriting the files. The whole-manifest hash remains
|
| 515 |
+
provenance, not a reason to reject an unchanged prefix when later clips are
|
| 516 |
+
added. Changed completed sources or profile settings still require a new pass.
|
| 517 |
+
|
| 518 |
+
Chapter upscale profiles and finals are isolated under
|
| 519 |
+
`output/h3_chains/<run>/chapters/<number>_<chapter_id>/upscaled/<profile>/`.
|
| 520 |
+
Connect the upscale manifest to **H3 Chain Assemble**; no Chapter Delivery
|
| 521 |
+
filter is needed after a chapter-only selection. Use Chapter Delivery separately
|
| 522 |
+
if you also want to seal an immutable original-quality chapter snapshot.
|
| 523 |
+
|
| 524 |
+
```text
|
| 525 |
+
Checkpoint Manager → Upscale Adapter → Upscale Current Scene
|
| 526 |
+
→ backend graph
|
| 527 |
+
→ Upscale Segment Save
|
| 528 |
+
→ Upscale Loop End → H3 Chain Assemble
|
| 529 |
+
```
|
| 530 |
+
|
| 531 |
+
**Upscale Current Scene** prefers the optional `denoised_output` saved by
|
| 532 |
+
Segment + Checkpoint and falls back to the terminal sampler latent in older
|
| 533 |
+
checkpoints. It exposes the joint H3 AV latent as well as separate video and
|
| 534 |
+
audio latents:
|
| 535 |
+
|
| 536 |
+
- Combined-style nodes can consume `source_latent` directly.
|
| 537 |
+
- Video-only LBH nodes consume `source_video_latent`. **MiniMax H3 Pass-2 AV
|
| 538 |
+
Prepare** recombines their output with `source_audio_latent`, performs
|
| 539 |
+
NestedTensor-safe CONST re-noise on video only, and locks the saved audio
|
| 540 |
+
with a zero denoise mask.
|
| 541 |
+
- LTX 2.5 is a decoded-video V2V path, not an H3-latent path. Decode the H3
|
| 542 |
+
source latent, run the LTX refinement/upscale graph, and send its raw frame
|
| 543 |
+
batch to Upscale Segment Save.
|
| 544 |
+
|
| 545 |
+
For H3 pass-2 conditioning, **Upscale Reference Conditioning** first reads the
|
| 546 |
+
exact cache descriptor recorded on the selected source revision. Tagged and
|
| 547 |
+
Scheduled Ref2VA create that cache automatically: native H3 reference latents
|
| 548 |
+
remain in safetensors while compact Qwen presentation frames allow the saved
|
| 549 |
+
compiled prompt to be tokenized again. **H3 Conditioning Sync From Latents**
|
| 550 |
+
then compares the original scene video latent with the actual LBH output. It
|
| 551 |
+
applies the exact horizontal and vertical scale to `match` picture
|
| 552 |
+
`minimax_refs` and `minimax_keyframes`, while preserving `max` picture refs at
|
| 553 |
+
their Core H3 capped geometry. Pictures already rebuilt from a cache-v2 RGB
|
| 554 |
+
master at the pass-2 canvas are not scaled twice. It updates changed reference
|
| 555 |
+
H/W metadata and deliberately leaves text, temporal positions, and audio
|
| 556 |
+
conditioning untouched. A de-rope target may have a longer video time axis;
|
| 557 |
+
sync accepts that deliberate difference because it changes only spatial
|
| 558 |
+
reference geometry. Upscale Reference
|
| 559 |
+
Conditioning's default `exclude_video_keep_audio` policy removes both the Qwen
|
| 560 |
+
motion-video presentation and native motion-video latent because the pass-2
|
| 561 |
+
source latent already contains the generated motion; audio paired with a video
|
| 562 |
+
reference is converted to an audio-only reference. `keep_video_native` and
|
| 563 |
+
`resize_video` remain available for comparison, and the sync node follows the
|
| 564 |
+
selected conditioning policy automatically. Build sampler 2's new
|
| 565 |
+
Guider from the returned conditioning rather than reusing the original Guider.
|
| 566 |
+
Thus the child graph needs no reference registry or original picture/video/audio
|
| 567 |
+
connections. Both cache versions retain the encoded native reference blocks
|
| 568 |
+
used by sync; cache v2 additionally keeps original picture masters for
|
| 569 |
+
workflows that choose target-resolution VAE re-encoding instead.
|
| 570 |
+
|
| 571 |
+
The bundled **Deferred Upscale + De-Rope - H3 LBH 3D** workflow wraps
|
| 572 |
+
[ComfyUI-MAINodes](https://github.com/matlowai/ComfyUI-MAINodes)' stable
|
| 573 |
+
decoded time-smear recipe in the child loop:
|
| 574 |
+
|
| 575 |
+
```text
|
| 576 |
+
source x0 → H3 Jerk Oracle → Chain De-Rope Guard → H3 Time Smear
|
| 577 |
+
→ H3 video VAE encode → LBH 3D → Chain De-Rope Continuity
|
| 578 |
+
→ H3 V2V Init + Inject Schedule → sampler
|
| 579 |
+
→ Exact/Audio Recover → re-encode → Chain Recovered AV
|
| 580 |
+
```
|
| 581 |
+
|
| 582 |
+
Guard forces the parent scene's repeated prefix to rate 1 and protects the
|
| 583 |
+
last 17 frames when another selected scene follows. It enables Time Smear's
|
| 584 |
+
`expand_to_end` only at the branch tip. Freeze Mask consumes Time Smear's final
|
| 585 |
+
`hold_map_used` (including legal-grid padding) and freezes the protected prefix
|
| 586 |
+
inside H3 V2V Init. For Drift-Control scene 2+, Continuity then replaces that
|
| 587 |
+
target-resolution prefix with the prior HQ tail before sampling.
|
| 588 |
+
|
| 589 |
+
Audio uses the identical hold map. Decode the source RAW audio latent, run H3
|
| 590 |
+
Audio Smear, VAE-encode it into H3 V2V Init, and recover the pass-2 audio after
|
| 591 |
+
sampling. The workflow seeds audio at strength 0.5 to keep dialogue timing
|
| 592 |
+
aligned but uses MAINodes' safe final preset that retains the original
|
| 593 |
+
performance. Segment Save's optional `recovered_audio` input trims the same
|
| 594 |
+
repeated RAW prefix as video and records the recovered result. Chain Recovered
|
| 595 |
+
AV rejects a still-dilated latent and packs the re-encoded world-clock video
|
| 596 |
+
and audio for optional full-latent saving and compact Drift-Control resume.
|
| 597 |
+
|
| 598 |
+
This is the stable pixel-smear route, not MAINodes' experimental temporal
|
| 599 |
+
latent insertion. The expanded IMAGE batch stays on CPU, but high-motion
|
| 600 |
+
scenes can still become two to three times longer internally. Narrow the
|
| 601 |
+
adapter's scene range or reduce oracle aggressiveness when RAM or wall time is
|
| 602 |
+
too high. Spatial upscale and de-rope remain in the same regeneration pass so
|
| 603 |
+
a later independent upscale cannot undo the recovered motion timing.
|
| 604 |
+
|
| 605 |
+
#### Saving DeRoPE for a later deferred pass
|
| 606 |
+
|
| 607 |
+
The current combined example already wires recovered frames through VAE Encode
|
| 608 |
+
and **Chain Recovered AV**, and sends that recovered latent to Upscale Segment
|
| 609 |
+
Save and Loop End. The example now has **save_latent ON**, so new renders retain
|
| 610 |
+
the complete recovered latent for a later pass. Existing workflows keep their
|
| 611 |
+
stored setting; preview-only results do not gain a latent by opening the tab.
|
| 612 |
+
|
| 613 |
+
For a separate later latent-upscale pass, a DeRoPE save needs:
|
| 614 |
+
|
| 615 |
+
- `save_latent` enabled **before rendering**, with Recovered AV connected to
|
| 616 |
+
Segment Save's `upscaled_latent`. Enabling it changes the profile configuration;
|
| 617 |
+
use a new profile instead of resuming a prefix saved with the old setting.
|
| 618 |
+
- A full video latent re-encoded **after Exact Recover** on the original RAW
|
| 619 |
+
clock, including the continuation head (the saver trims delivered pixels
|
| 620 |
+
separately). Recovered AV checks the H3 temporal length; a stretched pass-2
|
| 621 |
+
intermediate is not a recovered output.
|
| 622 |
+
- Aligned recovered audio where appropriate. Joint AV saves reuse recovered
|
| 623 |
+
audio latents and the saved delivered waveform. Video-only saves that preserve
|
| 624 |
+
the original performance reuse the exact original take's audio latent, loading
|
| 625 |
+
only audio from that checkpoint. If recovered audio replaces the performance,
|
| 626 |
+
connect its re-encoded `audio_latent` to Recovered AV before saving.
|
| 627 |
+
- Source revision/hash and reference-cache provenance retained through each
|
| 628 |
+
stage. Saved child tensors use `upscaled_video`/`upscaled_audio` or
|
| 629 |
+
`upscaled_samples`; Current Scene translates these into its source AV streams.
|
| 630 |
+
Reference-cache lookup retains the original generation fingerprint and canvas,
|
| 631 |
+
then rebuilds conditioning for the processing canvas. New upscale results
|
| 632 |
+
remain linked through their DeRoPE parent to the original checkpoint.
|
| 633 |
+
|
| 634 |
+
Select a **different output profile** for the later pass. The original generation
|
| 635 |
+
and DeRoPE source files are not overwritten. Full latent headers and checksums
|
| 636 |
+
are checked at execution; a continuation tail or time-stretched intermediate is
|
| 637 |
+
not a valid recovered source.
|
| 638 |
+
|
| 639 |
+
When pass 2 should use a different reference set, insert **Upscale Reference +
|
| 640 |
+
Prompt Override** on the normal `H3_TAGGED_REFERENCES` line and connect its
|
| 641 |
+
`references` and `prompt_override` outputs to **Upscale Reference
|
| 642 |
+
Conditioning**. An explicit registry replaces, rather than ambiguously merging
|
| 643 |
+
with, the compiled cache. The node can remove comma-separated tags without
|
| 644 |
+
renumbering references by hand; the conditioning node recompiles the resulting
|
| 645 |
+
prompt, Qwen presentation, and native blocks together. Leave its reference
|
| 646 |
+
input unconnected to retain the automatic cache route. Connected picture/video
|
| 647 |
+
refs require the original H3 video VAE, while activated standalone or paired
|
| 648 |
+
audio additionally requires the H3 audio VAE. No Plan or Source Timeline is
|
| 649 |
+
introduced; sequential/source-timeline live refs should be converted to a
|
| 650 |
+
scene-local restart/standalone reference before use in deferred upscale.
|
| 651 |
+
|
| 652 |
+
Upscale Reference Conditioning encodes the exact saved compiled prompt by
|
| 653 |
+
default. Its optional `prompt_override` is encoded instead when supplied, so a
|
| 654 |
+
user can provide a short appearance/detail-only pass-2 prompt without repeating
|
| 655 |
+
the source scene's motion or camera instructions. Automatic natural-language
|
| 656 |
+
motion stripping is intentionally avoided because it cannot reliably separate
|
| 657 |
+
action from identity, framing, or continuity clauses. The bundled LBH workflow
|
| 658 |
+
therefore leaves the override blank and uses the original compiled prompt by
|
| 659 |
+
default. Its How To Run note retains a neutral preservation/detail replacement
|
| 660 |
+
prompt for an explicit copy/paste A/B test.
|
| 661 |
+
Missing reference caches are rebuilt automatically before applying
|
| 662 |
+
`missing_cache`, including in Pixel Conditioning and the CAT video wrapper.
|
| 663 |
+
Recovery uses the selected generation take's saved reference lineage (also
|
| 664 |
+
through DeRoPE), not the current Plan or current tag assignments. It verifies
|
| 665 |
+
archived/input project media against saved content hashes, decodes only active
|
| 666 |
+
references, and re-encodes them with the connected H3 VAEs. Native pictures,
|
| 667 |
+
video/audio references, and Qwen-only semantic anchors retain their separate
|
| 668 |
+
roles. No diffusion sampling or source-scene regeneration is needed.
|
| 669 |
+
|
| 670 |
+
Recovered caches use the existing deduplicated V3 tensor store and a run-local
|
| 671 |
+
`reference_cache/rebuilt_<identity>.json` index. Subsequent upscales reuse them;
|
| 672 |
+
original checkpoint metadata, source media and legacy bundles are not rewritten
|
| 673 |
+
or deleted. An absent cache bundle/object is recoverable; an existing corrupt
|
| 674 |
+
payload still fails integrity validation.
|
| 675 |
+
|
| 676 |
+
Recovery keeps presentation settings from an exact surviving cache manifest or
|
| 677 |
+
immutable saved recipe/reference lineage. Older takes without those settings
|
| 678 |
+
use `match`, semantic size `512`, and `timestamped_video`; the status explicitly
|
| 679 |
+
lists these defaults. Sequential/source-timeline references additionally need
|
| 680 |
+
their immutable saved Plan timing. Recovery never executes archived workflows.
|
| 681 |
+
|
| 682 |
+
Saved sizing is also recovered through static string input links and the modern
|
| 683 |
+
Current Tagged Ref2VA Scene's connected Tagged Scene Options. Unconnected
|
| 684 |
+
Options nodes or ambiguous/dynamic links are not treated as historical evidence.
|
| 685 |
+
The upscale `override_ref_image_size` applies to automatic caches and rebuilt
|
| 686 |
+
references as well as connected Tagged overrides. `inherit` keeps the saved
|
| 687 |
+
policy; explicit `match`/`max` rebuilds native picture conditioning as needed
|
| 688 |
+
without changing the source cache. Changing an old Match cache to Max uses its
|
| 689 |
+
picture masters, or reconstructs them from verified archived media when absent;
|
| 690 |
+
it never just relabels the smaller Match tensors. Existing Max caches keep their
|
| 691 |
+
native geometry. Semantic anchors and motion/audio policy remain separate.
|
| 692 |
+
|
| 693 |
+
`override_semantic_anchor_size` and `override_semantic_anchor_mode` expose the
|
| 694 |
+
same anchor choices as Tagged Scene Options, plus `inherit`. They apply to
|
| 695 |
+
cached references, missing-cache recovery, and connected Tagged references in
|
| 696 |
+
the latent, pixel, and inherited VIDEO conditioning nodes. For example, choose
|
| 697 |
+
`1280` and `timestamped_video` to explicitly restore those legacy settings.
|
| 698 |
+
Anchor size is independent of `override_ref_image_size=match/max` and the output
|
| 699 |
+
canvas. Connected anchor bundles supply their settings when inheriting.
|
| 700 |
+
Changing cached anchor settings rebuilds the presentation **and prompt labels**
|
| 701 |
+
from verified saved media into a separate reusable cache; the source checkpoint
|
| 702 |
+
and original cache remain unchanged. If the required media cannot be recovered,
|
| 703 |
+
connect explicit Tagged references rather than silently keeping the wrong
|
| 704 |
+
cached anchor settings. Status reports the explicit choices. Use a new upscale
|
| 705 |
+
profile for changed conditioning settings to avoid mixing previous processed
|
| 706 |
+
clips with the new pass. The new pixel/VIDEO widgets are appended after the
|
| 707 |
+
existing canvas controls to preserve saved workflows' positional widget values.
|
| 708 |
+
|
| 709 |
+
Connect `video_vae` for native visual reference rebuilds and `audio_vae` for
|
| 710 |
+
native audio. If an original file, saved reference identity, or required VAE is
|
| 711 |
+
unavailable, `error` names the recovery requirement; `text_only` explicitly
|
| 712 |
+
falls back without reference conditioning. Connected Tagged references remain
|
| 713 |
+
an intentional override and bypass automatic recovery.
|
| 714 |
+
|
| 715 |
+
Segment Save adopts each verified cache object into
|
| 716 |
+
`output/h3_chains/<run_name>/reference_cache/` and records only that run-local
|
| 717 |
+
descriptor. Copying or backing up the parent run therefore preserves everything
|
| 718 |
+
required to rebuild pass-2 Ref2VA conditioning.
|
| 719 |
+
|
| 720 |
+
New V3 scene caches are small JSON manifests pointing into
|
| 721 |
+
`reference_cache/objects/<tensor-content-hash>.safetensors`. Identical reference
|
| 722 |
+
originals, previews, and encoded latents are stored once across scenes/revisions
|
| 723 |
+
within the project. Shared staging objects are hard-linked into the project
|
| 724 |
+
where supported, otherwise copied once. V1/V2 scene-sized bundles remain readable
|
| 725 |
+
and unchanged until explicitly converted. Updating the nodes does not compact
|
| 726 |
+
or delete existing bundles. The [reference-cache converter](SCHEDULED_REFERENCES.md#converting-existing-bundles)
|
| 727 |
+
can split them losslessly, then retire each old bundle after a verified render
|
| 728 |
+
using the conversion successfully commits through an H3 saver. Small legacy JSON
|
| 729 |
+
addresses and conversion/retirement receipts remain for checkpoint compatibility.
|
| 730 |
+
Objects must not be deleted individually while any scene still references them.
|
| 731 |
+
|
| 732 |
+
Legacy checkpoints that still point into `output/h3_reference_cache/` migrate
|
| 733 |
+
without a rerender. Selecting their complete branch in Checkpoint Manager
|
| 734 |
+
hard-links the verified cache into the corresponding run (or copies it when a
|
| 735 |
+
hard link is unavailable) and returns a run-local descriptor. Migration never
|
| 736 |
+
deletes the global object or rewrites immutable revision metadata. Later branch
|
| 737 |
+
loads resolve the verified run-local equivalent first, so the old staging copy
|
| 738 |
+
can be archived or removed after a successful selection and upscale check.
|
| 739 |
+
Read-only workflow-local/chapter-only selections do not perform this migration;
|
| 740 |
+
checkpoints without an exact cache descriptor still use shared-cache discovery.
|
| 741 |
+
|
| 742 |
+
Send the backend's decoded **raw** frame batch to both Segment Save and Loop
|
| 743 |
+
End. They remove the parent scene's repeated context head exactly once, persist
|
| 744 |
+
the delivered HQ segment, and carry optional HQ context to the next iteration.
|
| 745 |
+
Set a distinct `profile` for each recipe so settings and outputs cannot collide.
|
| 746 |
+
The profile folder is:
|
| 747 |
+
|
| 748 |
+
```text
|
| 749 |
+
output/h3_chains/<run_name>/upscaled/<profile>/
|
| 750 |
+
├── segments/
|
| 751 |
+
├── checkpoints/
|
| 752 |
+
├── prompts/
|
| 753 |
+
├── audio/
|
| 754 |
+
├── partial/
|
| 755 |
+
├── upscale_manifest.json
|
| 756 |
+
└── final/
|
| 757 |
+
```
|
| 758 |
+
|
| 759 |
+
`save_latent` defaults off. Segment Save still writes a small verified
|
| 760 |
+
safetensors checkpoint containing the assembly audio, so the child run remains
|
| 761 |
+
resumable and mergeable without duplicating the much larger HQ sampler latent.
|
| 762 |
+
When the following source scene uses Drift-Control AV, that checkpoint also
|
| 763 |
+
contains only the preceding scene's 12-step HQ video tail. Pass-2 AV Prepare
|
| 764 |
+
splices this tail into scene 2+'s prefix, gives it zero added noise and a zero
|
| 765 |
+
denoise mask, and refines only the new video region. This compact context is
|
| 766 |
+
enough for exact interruption-safe HQ continuation without enabling full
|
| 767 |
+
latent saving. Older child checkpoints without the tail safely protect their
|
| 768 |
+
independently upscaled source prefix and report that fallback in node status.
|
| 769 |
+
Enable full latent saving only when you want to reopen/refine the HQ latent
|
| 770 |
+
itself. The transient `upscaled_latent` connection on Loop End remains usable
|
| 771 |
+
for scene continuity whether or not persistence is enabled.
|
| 772 |
+
|
| 773 |
+
For new parent renders, connect SamplerCustomAdvanced `denoised_output` to
|
| 774 |
+
Segment + Checkpoint's optional `denoised_latent` input. Existing checkpoints
|
| 775 |
+
remain valid and use their terminal sampler output. Keep the parent branch
|
| 776 |
+
until every selected child scene has been persisted; a completed child profile
|
| 777 |
+
contains its own HQ video segments and audio needed by H3 Chain Assemble.
|
| 778 |
+
Assemble recognizes the upscale manifest, reconstructs a recoverable Source
|
| 779 |
+
Timeline from the embedded parent manifest, and keeps the canonical HQ result
|
| 780 |
+
under `upscaled/<profile>/final/`. Its normal `copy_to_output` and
|
| 781 |
+
`output_subfolder` controls can additionally publish the MP4 in ComfyUI's
|
| 782 |
+
regular output tree. Legacy source-track runs without the embedded descriptor
|
| 783 |
+
still need their original full AUDIO connected to Assemble.
|
| 784 |
+
|
| 785 |
+
## Run Manager
|
| 786 |
+
|
| 787 |
+
Connect the active Plan output to **MiniMax H3 Run Manager**. It discovers runs
|
| 788 |
+
under the ComfyUI host's `output/h3_chains`, including remote Docker hosts.
|
| 789 |
+
Select a run and choose **Load selected archive into Plan**; after confirmation
|
| 790 |
+
it restores archived prompts and Plan controls without changing graph links.
|
| 791 |
+
|
| 792 |
+
The two names at the top are deliberately separate:
|
| 793 |
+
|
| 794 |
+
- **Active Plan** is the connected Plan's current `run_name`. Generation and
|
| 795 |
+
**Save assets to active Plan** use this name.
|
| 796 |
+
- **Selected archive** is only the folder highlighted in the browser. Selecting
|
| 797 |
+
it does not change the Plan. **Load selected archive into Plan** is the only
|
| 798 |
+
action that applies its archived prompts and settings.
|
| 799 |
+
|
| 800 |
+
When both names match, the archive is marked **ACTIVE PLAN**. When they differ,
|
| 801 |
+
the selected archive is labeled **not loaded**, so opening an old folder or
|
| 802 |
+
inspecting it cannot be mistaken for switching the generation run.
|
| 803 |
+
|
| 804 |
+
Restore prefers:
|
| 805 |
+
|
| 806 |
+
1. `api_prompt.json`;
|
| 807 |
+
2. `workflow.json`;
|
| 808 |
+
3. effective settings derived from `plan.json` for older runs.
|
| 809 |
+
|
| 810 |
+
The fallback retains exact scene lengths, steps, and seeds even when an old run
|
| 811 |
+
did not archive unused default-widget values.
|
| 812 |
+
|
| 813 |
+
## Archive reference assets
|
| 814 |
+
|
| 815 |
+
Connect loader outputs to Run Manager's dynamic **Connect loader asset** socket,
|
| 816 |
+
up to 12 assets. Classify each as Picture, Video, Audio reference, or Source
|
| 817 |
+
track so a short voice reference cannot be confused with a project soundtrack.
|
| 818 |
+
|
| 819 |
+
Wire all Semantic Picture Anchor nodes into one Semantic Anchor Bundle, then
|
| 820 |
+
connect the Bundle's **references** output to Run Manager's
|
| 821 |
+
**tagged_references** socket. The same line can feed Tagged Ref2VA and Plan
|
| 822 |
+
Studio. The interface discovers the Bundle's upstream image loaders and
|
| 823 |
+
archives/restores them as individual picture assets without consuming the 12
|
| 824 |
+
direct loader sockets.
|
| 825 |
+
|
| 826 |
+
- Archive images and audio default on.
|
| 827 |
+
- Archive video defaults off because video references can be large.
|
| 828 |
+
- Only files inside ComfyUI's input directory are eligible for fallback copies.
|
| 829 |
+
- Content-addressing deduplicates unchanged media and retains changed versions.
|
| 830 |
+
|
| 831 |
+
Restore first uses the original input-relative path. If it is missing and a
|
| 832 |
+
fallback exists, Run Manager copies the archived asset into a unique ComfyUI
|
| 833 |
+
input filename and updates a compatible loader. Targets are matched by persistent
|
| 834 |
+
binding identity, archived node ID/type, then unambiguous compatible loaders.
|
| 835 |
+
Ambiguous targets remain unchanged and are reported.
|
| 836 |
+
|
| 837 |
+
## Run folder contents
|
| 838 |
+
|
| 839 |
+
```text
|
| 840 |
+
output/h3_chains/<run_name>/
|
| 841 |
+
├── plan.json
|
| 842 |
+
├── workflow.json
|
| 843 |
+
├── api_prompt.json
|
| 844 |
+
├── manifest.json
|
| 845 |
+
├── prompt_history/<scene_id>/
|
| 846 |
+
├── segments/clip_0001.<revision>.mp4
|
| 847 |
+
├── segments/clip_0001.<revision>.prompt.txt
|
| 848 |
+
├── checkpoints/clip_0001.json
|
| 849 |
+
├── checkpoints/clip_0001.<revision>.json
|
| 850 |
+
├── checkpoints/clip_0001.<revision>.safetensors
|
| 851 |
+
├── generated_audio/
|
| 852 |
+
├── reference_cache/
|
| 853 |
+
│ ├── scene_0001.<scene-contract>.json
|
| 854 |
+
│ └── objects/<tensor-content-hash>.safetensors
|
| 855 |
+
├── chapters/<number>_<chapter_id>/
|
| 856 |
+
│ ├── manifests/<snapshot_id>.json
|
| 857 |
+
│ ├── final/
|
| 858 |
+
│ ├── frames/
|
| 859 |
+
│ └── upscaled/<profile>/ (including seedvr2/source/)
|
| 860 |
+
├── upscaled/<profile>/
|
| 861 |
+
└── final/<filename>.mp4
|
| 862 |
+
```
|
| 863 |
+
|
| 864 |
+
Regenerating a scene updates its active checkpoint pointer but retains all
|
| 865 |
+
earlier MP4s, prompt sidecars, metadata, safetensors, and generated WAVs. Each
|
| 866 |
+
revision records what it supersedes.
|
| 867 |
+
|
| 868 |
+
Workflow and API graph metadata are embedded in segment/final files using
|
| 869 |
+
ComfyUI's standard tags. `workflow.json` is the preferred file to drag back
|
| 870 |
+
into ComfyUI; `plan.json` remains the authoritative effective render record.
|
| 871 |
+
Keep run folders private when workflows contain credentials.
|
| 872 |
+
|
| 873 |
+
## Assembly
|
| 874 |
+
|
| 875 |
+
Assemble accepts completed or partial manifests, including an interrupted
|
| 876 |
+
prefix reconstructed by Manifest Load. Its filename supports date
|
| 877 |
+
tokens such as `%date:yyyy-MM-dd%`, `%year%`, `%month%`, `%day%`, `%hour%`,
|
| 878 |
+
`%minute%`, and `%second%`. Existing files are never overwritten; numbered
|
| 879 |
+
suffixes are added automatically.
|
| 880 |
+
|
| 881 |
+
### Recovery blend schedules
|
| 882 |
+
|
| 883 |
+
`blend_schedule` can override the Plan's global visual blend only during
|
| 884 |
+
assembly. `plan` preserves the recorded setting. A comma-separated schedule is
|
| 885 |
+
applied to scene boundaries in timeline order: `5,30` uses five frames for the
|
| 886 |
+
first join and thirty for every later join because the last value repeats.
|
| 887 |
+
`0` produces hard cuts. This does not change checkpoints, prompts, seeds, or
|
| 888 |
+
generated frames.
|
| 889 |
+
|
| 890 |
+
When the requested boundary fits inside the saved blend MP4, assembly reuses
|
| 891 |
+
that artifact directly. If it requests more overlap than Segment Save retained,
|
| 892 |
+
connect the original MiniMax H3 video VAE to `blend_video_vae`. Recovery then
|
| 893 |
+
re-decodes the existing safetensors checkpoint into a temporary lossless RGB
|
| 894 |
+
video and deletes it after assembly. Diffusion is never rerun. The final video
|
| 895 |
+
still receives the one H.264 encode required by any pixel-space crossfade.
|
| 896 |
+
|
| 897 |
+
Each scheduled value must not exceed that incoming scene's repeated context.
|
| 898 |
+
For example, a chain whose first join has five context frames and later joins
|
| 899 |
+
have thirty-nine can use `5,30`; requesting thirty at the first join is rejected.
|
| 900 |
+
|
| 901 |
+
### Optional scene-one color stabilization
|
| 902 |
+
|
| 903 |
+
Set Assemble's `color_stabilization` to `scene_1_anchor` to counter gradual
|
| 904 |
+
exposure or saturation drift in a completed chain. Assembly measures a
|
| 905 |
+
center-weighted sample of the first generated scene, then fits only a weak,
|
| 906 |
+
bounded luma/saturation correction for each later scene. A correction is capped
|
| 907 |
+
at six code values of luma and six percent of saturation, at half the measured
|
| 908 |
+
strength.
|
| 909 |
+
|
| 910 |
+
The exact join inherits the preceding scene's accepted correction. Starting
|
| 911 |
+
after the retained overlap, that correction moves smoothly to the next scene's
|
| 912 |
+
target over 72 frames. Consequently the option cannot introduce a new grade
|
| 913 |
+
step at the boundary and does not change motion, timing, or audio. A prelude is
|
| 914 |
+
not used as the reference: the first generated scene remains the anchor.
|
| 915 |
+
|
| 916 |
+
This is an assembly grade, not diffusion conditioning. It does not change
|
| 917 |
+
checkpoints or future continuation input. It is disabled by default. Enabling
|
| 918 |
+
it uses the same single pixel-processing encode as a visual blend; on a hard-cut
|
| 919 |
+
assembly it replaces the otherwise lossless stream copy with one encode.
|
| 920 |
+
|
| 921 |
+
For an experimental correction that can influence later generation instead of
|
| 922 |
+
only the final MP4, choose **Color-Stable Drift AV** through Advanced Policy or
|
| 923 |
+
on a scene's incoming transition. It applies a bounded scene-one correction to
|
| 924 |
+
the disposable copied video latent as a VAE delta, tapering from zero to full
|
| 925 |
+
strength across the 39-frame context. Scene 1 remains the anchor, scene 2 is
|
| 926 |
+
neutral, and scene 3 onward can receive a corrected predecessor tail. It does
|
| 927 |
+
not alter the saved predecessor checkpoint or audio. Because this changes
|
| 928 |
+
generation conditioning, it is recorded in the incoming-boundary dependency
|
| 929 |
+
and is intentionally separate from this assembly-only option.
|
| 930 |
+
|
| 931 |
+
Enable `copy_to_output` to keep the canonical final in the run folder and also
|
| 932 |
+
publish an MP4 into the regular ComfyUI output tree. `output_subfolder` is
|
| 933 |
+
relative to that output root, supports nested folders and the same date tokens,
|
| 934 |
+
and may be empty to place the copy directly in `output/`. The existing
|
| 935 |
+
`filename` value is used for both copies, and collisions are versioned.
|
| 936 |
+
|
| 937 |
+
## Stream pixel-upscale VIDEO to PNG, scene by scene
|
| 938 |
+
|
| 939 |
+
**Export PNG Sequence + Audio** also has a VIDEO passthrough mode for pixel
|
| 940 |
+
upscales with no saved latent. Put it **inside** the scene loop, before the
|
| 941 |
+
lossy MP4 segment save:
|
| 942 |
+
|
| 943 |
+
```text
|
| 944 |
+
Final pixel refiner VIDEO -> Export PNG.video -> Segment Save.video
|
| 945 |
+
-> Loop End.video
|
| 946 |
+
Current Scene.state -> Export PNG.state
|
| 947 |
+
```
|
| 948 |
+
|
| 949 |
+
Disconnect the exporter's `manifest`, `video_vae` and `audio_vae` in this mode.
|
| 950 |
+
Do not connect the final Loop End manifest back into the in-loop exporter.
|
| 951 |
+
The original four output positions are unchanged; the new `video` output is
|
| 952 |
+
the exact incoming VIDEO, released downstream after the current scene is saved.
|
| 953 |
+
Segment Save still preserves the original audio. The PNG node does not turn
|
| 954 |
+
compressed MP4s back into supposedly lossless originals.
|
| 955 |
+
|
| 956 |
+
Use `output_folder` for a chosen subfolder of ComfyUI output (relative or
|
| 957 |
+
absolute). When blank, the sequence goes under the upscale profile's
|
| 958 |
+
`frames/<export_name>/`, including the chapter scope when applicable. The first
|
| 959 |
+
exported scene starts at `first_frame_number`; subsequent scenes append without
|
| 960 |
+
resetting numbering. Each scene's repeated RAW context frames are removed using
|
| 961 |
+
the current state, just as in Segment Save. This is the sequential upscale
|
| 962 |
+
delivery clock, not a later editorial reordering or blend pass.
|
| 963 |
+
|
| 964 |
+
`png_bit_depth` is a user choice: **8** is the existing default; **16** preserves
|
| 965 |
+
the RGB16 file-backed intermediate's precision. Both use lossless PNG compression,
|
| 966 |
+
but 8-bit explicitly quantizes higher-precision input. Neither is an H3 latent
|
| 967 |
+
checkpoint; these are full-resolution pixel backups with provenance and hashes.
|
| 968 |
+
Keep the upscaler's file-backed VIDEO path for bounded memory. Native in-memory
|
| 969 |
+
VIDEO, lazy trims/crops, wrong frame counts and mismatched frame clocks are
|
| 970 |
+
rejected rather than silently materialized or exported incorrectly.
|
| 971 |
+
|
| 972 |
+
Existing controls remain: `png_compression`, `embed_workflow`, `save_workers`,
|
| 973 |
+
`checkpoint_verification`, and `reuse_existing`. Compression affects speed/size,
|
| 974 |
+
not precision. The decoder streams one frame at a time and never loads all
|
| 975 |
+
scenes; at most `save_workers` PNG jobs are in flight (0 chooses up to eight).
|
| 976 |
+
There is no VAE encode/decode or GPU work in this export path.
|
| 977 |
+
|
| 978 |
+
Each complete scene is committed to a continuous image sequence plus
|
| 979 |
+
`export.json` before passthrough. The index records source identity, RAW trim,
|
| 980 |
+
numbering, bit depth, profile, source manifest, and PNG hashes. Interrupted
|
| 981 |
+
decode/save operations roll back that scene and keep every earlier scene intact.
|
| 982 |
+
Resume at the next missing scene with the same folder/settings. Exact repeated
|
| 983 |
+
scenes can be reused; `cached` skips hashing when previous PNG size/mtime match,
|
| 984 |
+
but verifies SHA-256 if only the timestamp differs. `strict` always hashes.
|
| 985 |
+
A timestamp difference alone never invalidates byte-identical PNGs or WAVs.
|
| 986 |
+
Incoming VIDEO files are always hashed. Different takes, changed
|
| 987 |
+
settings, missing/modified PNGs, untracked frames or out-of-order scenes never
|
| 988 |
+
overwrite a sequence: select a new folder/export name, or resume the missing
|
| 989 |
+
scene. With reuse disabled, use a new folder for a fresh export. Abrupt process
|
| 990 |
+
death during publication can leave untracked frames; these are kept and reported,
|
| 991 |
+
not silently overwritten. Concurrent writers to one folder are rejected.
|
| 992 |
+
|
| 993 |
+
Hard links are an optional publishing optimization, not a storage requirement.
|
| 994 |
+
If a network share rejects them (including permission denied / errno 13), the
|
| 995 |
+
exporter falls back to an exclusive file copy with a 1 MiB buffer. Existing
|
| 996 |
+
files are never replaced, and genuine write-permission or storage failures
|
| 997 |
+
still stop the export; no filesystem permissions or project ownership change.
|
| 998 |
+
|
| 999 |
+
The sequence is a verified export, not an editable working copy. Editing a
|
| 1000 |
+
saved PNG makes its checksum differ and prevents VIDEO-mode reuse or append
|
| 1001 |
+
when detected, including when exporting a later scene. Even metadata-only edits
|
| 1002 |
+
or recompressing identical pixels can change the file hash. Existing PNGs are
|
| 1003 |
+
never overwritten. Keep intentional retouches in a separate working copy;
|
| 1004 |
+
the VIDEO passthrough still contains the incoming video, not edits made to the
|
| 1005 |
+
PNG files. This node does not currently adopt edits into its saved index.
|
| 1006 |
+
|
| 1007 |
+
## Re-decode checkpoints to PNG and WAV
|
| 1008 |
+
|
| 1009 |
+
Connect a manifest to **Export PNG Sequence + Audio**, then connect the original
|
| 1010 |
+
H3 video VAE, audio VAE, or both. Video-only writes PNGs, audio-only writes one
|
| 1011 |
+
continuous `audio.wav`, and both inputs produce a synchronized image sequence
|
| 1012 |
+
and soundtrack in the same folder. The WAV is decoded from the checkpointed H3
|
| 1013 |
+
audio latents, preserves the generated AV boundary ownership, and follows the
|
| 1014 |
+
same selected scene order and latent-safe trims as the gap-free PNG sequence.
|
| 1015 |
+
|
| 1016 |
+
The node verifies each safetensors checkpoint, decodes one scene at a time,
|
| 1017 |
+
removes repeated overlap, converts small frame chunks to the selected 8-bit or
|
| 1018 |
+
16-bit RGB depth in one
|
| 1019 |
+
operation, and writes each chunk through bounded parallel atomic PNG workers.
|
| 1020 |
+
ComfyUI's progress bar covers verification, GPU decode, and saving. The server
|
| 1021 |
+
log reports verify, checkpoint-load, decode, conversion, and save timings. The
|
| 1022 |
+
deliverables and `export.json` are written under:
|
| 1023 |
+
|
| 1024 |
+
```text
|
| 1025 |
+
output/h3_chains/<run_name>/frames/<export_name>/
|
| 1026 |
+
```
|
| 1027 |
+
|
| 1028 |
+
For a Chapter Delivery manifest the equivalent path is
|
| 1029 |
+
`chapters/<number>_<chapter_id>/frames/<export_name>/`; `export.json` records
|
| 1030 |
+
the chapter number and immutable snapshot id.
|
| 1031 |
+
|
| 1032 |
+
Chapter exports default to `reuse_existing = true`. A later export using the
|
| 1033 |
+
same export name looks for a successful matching export and reuses its unchanged
|
| 1034 |
+
PNG prefix. New scenes continue the existing frame numbering in that folder,
|
| 1035 |
+
without decoding or rewriting the earlier PNGs. An identical export skips both
|
| 1036 |
+
video and audio decoding. When new scenes extend the chapter, the synchronized
|
| 1037 |
+
`audio.wav` is rebuilt and atomically replaced so audio joins remain correct;
|
| 1038 |
+
WAV bytes are not blindly appended.
|
| 1039 |
+
|
| 1040 |
+
Changed checkpoint revisions, trims, placements, frame numbering, or relevant
|
| 1041 |
+
export settings create a new numbered folder instead of mixing old and new
|
| 1042 |
+
output. A shorter/older chapter snapshot never truncates a longer export.
|
| 1043 |
+
Missing, modified, or untracked PNGs, interrupted exports, and legacy exports
|
| 1044 |
+
without the new verification records also use a fresh folder; their existing
|
| 1045 |
+
files are left untouched. `cached` checks recorded size and modification time;
|
| 1046 |
+
`strict` additionally re-hashes the reusable PNGs and WAV. Concurrent appenders
|
| 1047 |
+
to the same chapter/export name are rejected while the first export is running.
|
| 1048 |
+
|
| 1049 |
+
Set `reuse_existing = false` to force a fresh folder, especially after changing
|
| 1050 |
+
VAE weights, precision, ComfyUI version, or decode settings. The reuse signature
|
| 1051 |
+
identifies the VAE class, not the full weights. Whole-Run exports retain their
|
| 1052 |
+
existing fresh-folder behaviour regardless of this switch.
|
| 1053 |
+
|
| 1054 |
+
Reused PNGs retain their original embedded workflow/manifest metadata.
|
| 1055 |
+
`export.json` is the current export index and points to the latest selected
|
| 1056 |
+
immutable chapter snapshot; earlier indexes remain in `export.history/`.
|
| 1057 |
+
`complete` means that this export succeeded; `chapter_complete` separately
|
| 1058 |
+
indicates whether all planned chapter scenes have been generated. The node's
|
| 1059 |
+
frame count includes reused frames, and its status reports reused/new counts.
|
| 1060 |
+
MP4 assembly still creates a new assembled movie; incremental PNG reuse does
|
| 1061 |
+
not imply in-place MP4 appending.
|
| 1062 |
+
|
| 1063 |
+
PNG compression is lossless. Use the same VAE, ComfyUI version, precision, and
|
| 1064 |
+
decode settings for the closest reconstruction. The checkpointed latent is
|
| 1065 |
+
exact, but a new VAE decode is not guaranteed to be bit-identical to an older
|
| 1066 |
+
decode made under different settings. New nodes use compression level 1 and
|
| 1067 |
+
`save_workers = 0`, which automatically selects at most eight workers. Set the
|
| 1068 |
+
worker count to 1 for serial saving or raise it explicitly only when the output
|
| 1069 |
+
storage can sustain more concurrent writes.
|
| 1070 |
+
|
| 1071 |
+
`checkpoint_verification = cached` still performs a complete SHA-256 check the
|
| 1072 |
+
first time. Later exports skip re-hashing only while the checkpoint's recorded
|
| 1073 |
+
hash, byte size, and nanosecond modification time all match. Select `strict` to
|
| 1074 |
+
re-hash every checkpoint on every export. During execution,
|
| 1075 |
+
`export.partial.json` is updated after every converted chunk and retained if the
|
| 1076 |
+
export fails or is interrupted; a successful export replaces it with
|
| 1077 |
+
`export.json`, including the effective settings and per-scene timings.
|
ComfyUI-MiniMaxH3-Context-Loop/docs/SCENE_AUTHORING.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Scene authoring
|
| 2 |
+
|
| 3 |
+
## Plan structure
|
| 4 |
+
|
| 5 |
+
The Plan node provides a visual editor and stores ordinary JSON underneath.
|
| 6 |
+
Put shared instructions in `prompt_prefix`; each scene should describe what
|
| 7 |
+
changes.
|
| 8 |
+
|
| 9 |
+
```json
|
| 10 |
+
{
|
| 11 |
+
"prompt_prefix": "Keep the same performer, wardrobe and visual language.",
|
| 12 |
+
"defaults": {"duration_seconds": 15, "steps": 20},
|
| 13 |
+
"shots": [
|
| 14 |
+
{"id": "intro", "prompt": "Instrumental opening in the elevator.", "seed": 123},
|
| 15 |
+
{"id": "street", "prompt": "Continue outside into the rain.", "seed": 456}
|
| 16 |
+
]
|
| 17 |
+
}
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
Prompts may be multiline strings or arrays of lines. Seconds are rounded up to
|
| 21 |
+
H3's valid `17k+5` frame grid. Use `length` when exact raw frames matter.
|
| 22 |
+
|
| 23 |
+
The [complete format guide](../H3_CHAIN_FORMAT_GUIDE.md) documents every Plan
|
| 24 |
+
and per-scene field, raw versus delivered length, prompt structure, seeds, and
|
| 25 |
+
timing.
|
| 26 |
+
|
| 27 |
+
## Chapter resolution
|
| 28 |
+
|
| 29 |
+
Click the chapter marker in **Plan Studio** to choose **Inherit from Plan** or
|
| 30 |
+
**Chapter resolution**, with Width and Height in multiples of 32. Each chapter
|
| 31 |
+
has one size; the next chapter inherits the Plan default, not the previous
|
| 32 |
+
chapter's override. Wire **Current Shot** width/height to conditioning so its
|
| 33 |
+
latent and reference preparation match the active chapter. The combined
|
| 34 |
+
**Current Tagged Ref2VA Scene** node already handles this internally.
|
| 35 |
+
|
| 36 |
+
The existing scene lock also pins a chapter to its locked saved scenes' size.
|
| 37 |
+
For example, lock Chapter 1's saved scenes, then change the Plan default or set
|
| 38 |
+
Chapter 2's resolution. Chapter 1 stays at its saved size. A conflicting explicit
|
| 39 |
+
size for Chapter 1 is rejected until its saved scenes are unlocked. Locking an
|
| 40 |
+
ungenerated slot cannot pin a size. Locks do not bypass prompt/model identity,
|
| 41 |
+
artifact-integrity, or native latent compatibility checks.
|
| 42 |
+
|
| 43 |
+
Different-sized chapters need an independent video boundary: set the new
|
| 44 |
+
chapter's first scene's video context to zero for AV masking or latent Guide.
|
| 45 |
+
Export each chapter separately with **Chapter Delivery**. Whole-run assembly
|
| 46 |
+
cannot combine different sizes automatically.
|
| 47 |
+
Saved media is never resized; recovery snapshots retain the resolved chapter size.
|
| 48 |
+
|
| 49 |
+
In Plan JSON the optional field is, for example:
|
| 50 |
+
|
| 51 |
+
```json
|
| 52 |
+
{"id":"chapter_2","title":"Chapter 2","start_scene_id":"scene_08",
|
| 53 |
+
"resolution":{"width":1344,"height":768},"text":""}
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
Omit `resolution` (or set it to `null`) to inherit. Chapter titles and notes
|
| 57 |
+
remain editorial only and do not change generation identity.
|
| 58 |
+
|
| 59 |
+
## Per-scene LoRA routes
|
| 60 |
+
|
| 61 |
+
Each scene has a **Scene LoRA route** selector: Base or LoRA A-D. Connect
|
| 62 |
+
Current Shot `state` to **MiniMax H3 Scene LoRA Scheduler**, connect the normal
|
| 63 |
+
base MODEL to `base_model`, and connect MODEL outputs from ordinary ComfyUI
|
| 64 |
+
LoRA loaders to the A-D inputs. The scheduler only routes those prepared
|
| 65 |
+
models; it never chooses a file or applies a LoRA.
|
| 66 |
+
|
| 67 |
+
One route can contain a stack of ordinary LoRA loaders. The same LoRA at two
|
| 68 |
+
strengths can be represented by two branches. Inputs are lazy, so a connected
|
| 69 |
+
branch is not evaluated until a scene selects it. Put patches shared by every
|
| 70 |
+
scene after the scheduler. When using Drift-Control, send the scheduled MODEL
|
| 71 |
+
through Chain Context's MODEL input/output before the sampler.
|
| 72 |
+
|
| 73 |
+
## Scene Prompt Editor
|
| 74 |
+
|
| 75 |
+
Connect Plan to **MiniMax H3 Scene Prompt Editor** for a large synchronized
|
| 76 |
+
textarea. It edits the selected scene's real `shots[n].prompt`; there is no
|
| 77 |
+
second prompt copy.
|
| 78 |
+
|
| 79 |
+
- Arrow buttons or `Alt+Left/Right` change scenes.
|
| 80 |
+
- Typing `@` offers the connected Picture/Video/Audio aliases. In Tagged mode,
|
| 81 |
+
inserting an inactive alias activates it; scheduled references remain limited
|
| 82 |
+
to the selected scene.
|
| 83 |
+
- Typing `#` offers valid dedicated Semantic Picture Anchors (and legacy
|
| 84 |
+
Tagged Picture compatibility anchors) only.
|
| 85 |
+
- Typing `<` offers H3 subjects, dialogue tags, and available native reference
|
| 86 |
+
labels, plus `<scenetrans>` and `<|cutoff|>` dialogue-flow markers. Typing
|
| 87 |
+
`[` filters 12 shot markers, multilingual dialogue markers, and canonical
|
| 88 |
+
summary-intent combinations; for example, `[re` lists the supported
|
| 89 |
+
reference-generation combinations. Typing `(S` offers stable speaker IDs.
|
| 90 |
+
- Typing the start of an H3 section name at the beginning of a line completes
|
| 91 |
+
`subject_definitions:`, `integrated_multimodal_description:`, and the other
|
| 92 |
+
standard sections. `Ctrl+Space` (`Cmd+Space` on macOS) opens the full H3
|
| 93 |
+
completion catalog. Use arrows to select and `Enter` or `Tab` to insert.
|
| 94 |
+
- **@ Reference** remains available for browsing connected media previews.
|
| 95 |
+
- **Dialogue** wraps a selection in `<d>` tags.
|
| 96 |
+
- Choose **Auto schema**, T2VA, I2VA, FL2VA, L2VA, or Ref2VA, then open
|
| 97 |
+
**Sections** to inspect the exact required order, jump to a category, insert
|
| 98 |
+
a missing category, or normalize the mode's keyframe-alignment line.
|
| 99 |
+
Diagnostics cover section spelling/order, shot numbering and timestamps,
|
| 100 |
+
Ref2VA task directives and definitions, dialogue/language/flow balance, and
|
| 101 |
+
connected native references. FL2VA/L2VA expose duration and final-shot
|
| 102 |
+
controls for their exact alignment sentence.
|
| 103 |
+
- Live word/character counts stay in the footer. Both dedicated editors can
|
| 104 |
+
switch between the ordinary prompt source and rich H3 token presentation;
|
| 105 |
+
this changes presentation only, never Plan text.
|
| 106 |
+
- `#picture` adds an untimed Qwen-only semantic Picture when the picture is
|
| 107 |
+
supplied through Semantic Anchor Bundle. Add a time as
|
| 108 |
+
`#picture[2.50s]` for a scene-local Qwen checkpoint; with the Bundle set to
|
| 109 |
+
`picture_storyboard`, the time becomes an approximate prompt instruction.
|
| 110 |
+
Neither form consumes a native H3 reference slot.
|
| 111 |
+
- `A−` and `A+` change persistent type size.
|
| 112 |
+
- The node may sit inline before Loop Start or on an editor-only branch.
|
| 113 |
+
|
| 114 |
+
The reference tray discovers downstream Scheduled Ref2VA, core Ref2VA, and core
|
| 115 |
+
Image to Video nodes without introducing an execution socket or graph cycle.
|
| 116 |
+
Hovering a loader-backed reference previews its image, video, or audio; computed
|
| 117 |
+
tensors remain usable even when no browser-playable source file can be found.
|
| 118 |
+
|
| 119 |
+
The schema inspector and completion catalog are shared by the focused Scene
|
| 120 |
+
Prompt Editor and the optimizer-enabled Rich Scene Prompt Editor. They are
|
| 121 |
+
backported from the standalone
|
| 122 |
+
[H3 Prompt IDE](https://github.com/ethanfel/ComfyUI-H3-Prompt-IDE), while these
|
| 123 |
+
nodes retain Motion Context Plan synchronization, revisions, `@tags`,
|
| 124 |
+
semantic anchors, and optimizer integration.
|
| 125 |
+
|
| 126 |
+
### Editor interactions
|
| 127 |
+
|
| 128 |
+
Click a Subject, speaker, dialogue, lyrics, caption or flow token to replace
|
| 129 |
+
or remove it. Pasted tokens become interactive immediately. Connected media
|
| 130 |
+
references keep their existing reference/syntax/time popup and previews.
|
| 131 |
+
Ctrl/Cmd-click an ordinary `[Shot N]`, language, task directive or retention
|
| 132 |
+
marker to change it without replacing the surrounding sentence.
|
| 133 |
+
|
| 134 |
+
Inside `retention_analysis:`, completion offers visual or audio retention
|
| 135 |
+
values according to the reference kind, including known `@aliases` and
|
| 136 |
+
`#picture` anchors. Inside `detailed_description:`, typing
|
| 137 |
+
`[Shot N] At` offers `At 00:00.000, `, selecting the seconds/milliseconds
|
| 138 |
+
for immediate editing. Ctrl/Cmd+Space also offers the timestamp after a plain
|
| 139 |
+
`At` in that section.
|
| 140 |
+
|
| 141 |
+
ComfyUI **Settings → MiniMax H3 Context Loop → Prompt editor** controls the
|
| 142 |
+
default Rich/Plain presentation, automatic suggestions, optional trailing
|
| 143 |
+
spaces, and the new marker replacement menus. Saved per-node presentation
|
| 144 |
+
choices win. Trailing spaces are off by default to preserve existing typing
|
| 145 |
+
behavior; semantic anchors never receive an inserted space before their time.
|
| 146 |
+
Turning off automatic suggestions still allows Ctrl/Cmd+Space.
|
| 147 |
+
|
| 148 |
+
Ctrl/Cmd+S saves the workflow while the editor is focused. Ctrl/Cmd+Z remains
|
| 149 |
+
scene-local. Token diagnostics flag malformed lyrics/caption pairs, incorrect
|
| 150 |
+
special-token case, and legacy `<cutoff>`; they do not rewrite saved prompts
|
| 151 |
+
or change generation. No task-aware Edit templates or new node sockets are
|
| 152 |
+
introduced by this port.
|
| 153 |
+
|
| 154 |
+
## Prompt revisions
|
| 155 |
+
|
| 156 |
+
The compact `‹ Active current / total ›` selector below the editor activates a
|
| 157 |
+
prompt version in the Plan; it is not a read-only history browser. Each version
|
| 158 |
+
is labeled **Active draft**, **Active executed**, **Draft history**, or
|
| 159 |
+
**Executed history**. Typing updates one active draft rather than creating a
|
| 160 |
+
revision per keystroke. When Current Shot executes, that exact prompt becomes
|
| 161 |
+
immutable; typing from it creates a child draft. Activating an older version
|
| 162 |
+
explicitly replaces the selected scene prompt in the Plan.
|
| 163 |
+
|
| 164 |
+
History is stored outside Plan JSON and loaded only for the selected scene:
|
| 165 |
+
|
| 166 |
+
```text
|
| 167 |
+
output/h3_chains/<run_name>/prompt_history/<scene_id>/
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
The Plan retains only the active prompt, keeping workflow JSON readable and
|
| 171 |
+
load time independent of old revisions.
|
| 172 |
+
|
| 173 |
+
## Seeds and bounded runs
|
| 174 |
+
|
| 175 |
+
Set a scene seed explicitly when repeatability matters. If omitted, Plan derives
|
| 176 |
+
a deterministic seed from `base_seed` and scene identity.
|
| 177 |
+
|
| 178 |
+
Loop Start's `scene_range` accepts one continuous selection:
|
| 179 |
+
|
| 180 |
+
| Value | Result |
|
| 181 |
+
|---|---|
|
| 182 |
+
| blank | `start_clip` through the end |
|
| 183 |
+
| `3` | scene 3 only |
|
| 184 |
+
| `3:8` | scenes 3 through 8, inclusive |
|
| 185 |
+
|
| 186 |
+
A range starting above scene 1 requires the preceding checkpoint. Disjoint
|
| 187 |
+
selections are rejected because skipped scenes would break the motion chain.
|
| 188 |
+
|
| 189 |
+
## Prompt Assistant status
|
| 190 |
+
|
| 191 |
+
The embedded Prompt Assistant is currently dormant so the editor retains its
|
| 192 |
+
compact manual workflow. Use the `comfyui-mcp` sidebar Agent panel for Codex or
|
| 193 |
+
Hermes assistance. The implementation, safety model, and future console-agent
|
| 194 |
+
design are preserved in the
|
| 195 |
+
[Prompt Assistant study](../AGENT_PROMPT_ASSISTANT_STUDY.md).
|