dzmdd commited on
Commit
bc50ea0
·
verified ·
1 Parent(s): e488ce7

Upload folder using huggingface_hub (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +9 -0
  2. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_ops.cpython-312.pyc +0 -0
  3. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_support.cpython-312.pyc +0 -0
  4. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/master_audio_context.cpython-312.pyc +0 -0
  5. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/motion_context_upstream.cpython-312.pyc +0 -0
  6. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/nodes.cpython-312.pyc +0 -0
  7. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_layout.cpython-312.pyc +0 -0
  8. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_payload.cpython-312.pyc +0 -0
  9. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/probe_node.cpython-312.pyc +0 -0
  10. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_checkpoint_delete.cpython-312.pyc +0 -0
  11. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_persistence.cpython-312.pyc +0 -0
  12. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/project_assets.cpython-312.pyc +0 -0
  13. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_history.cpython-312.pyc +0 -0
  14. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_optimizer.cpython-312.pyc +0 -0
  15. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_migration.cpython-312.pyc +0 -0
  16. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_recovery.cpython-312.pyc +0 -0
  17. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_store.cpython-312.pyc +0 -0
  18. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_usage.cpython-312.pyc +0 -0
  19. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_video_fade.cpython-312.pyc +0 -0
  20. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/run_manager.cpython-312.pyc +0 -0
  21. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/source_av_target.cpython-312.pyc +0 -0
  22. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/tokenizer_compat.cpython-312.pyc +0 -0
  23. ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc +3 -0
  24. ComfyUI-MiniMaxH3-Context-Loop/asset_store.py +397 -0
  25. ComfyUI-MiniMaxH3-Context-Loop/assets/minimax-h3-context-loop.svg +98 -0
  26. ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png +3 -0
  27. ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.svg +85 -0
  28. ComfyUI-MiniMaxH3-Context-Loop/assets/workflow-overview.svg +188 -0
  29. ComfyUI-MiniMaxH3-Context-Loop/audio_track_contract.py +26 -0
  30. ComfyUI-MiniMaxH3-Context-Loop/av_timing.py +96 -0
  31. ComfyUI-MiniMaxH3-Context-Loop/chain_nodes.py +0 -0
  32. ComfyUI-MiniMaxH3-Context-Loop/chapter_resolution.py +97 -0
  33. ComfyUI-MiniMaxH3-Context-Loop/chapter_snapshot_retirement.py +107 -0
  34. ComfyUI-MiniMaxH3-Context-Loop/checkpoint_manager.py +1541 -0
  35. ComfyUI-MiniMaxH3-Context-Loop/checkpoint_variants.py +278 -0
  36. ComfyUI-MiniMaxH3-Context-Loop/contracts_v05.py +569 -0
  37. ComfyUI-MiniMaxH3-Context-Loop/deferred_checkpoint_source.py +152 -0
  38. ComfyUI-MiniMaxH3-Context-Loop/docs/ADVANCED_WORKFLOWS.md +246 -0
  39. ComfyUI-MiniMaxH3-Context-Loop/docs/AUDIO_AND_CONTINUITY.md +522 -0
  40. ComfyUI-MiniMaxH3-Context-Loop/docs/COMPATIBILITY.md +132 -0
  41. ComfyUI-MiniMaxH3-Context-Loop/docs/FEATURE_TRACEABILITY.md +81 -0
  42. ComfyUI-MiniMaxH3-Context-Loop/docs/GETTING_STARTED.md +186 -0
  43. ComfyUI-MiniMaxH3-Context-Loop/docs/MASKED_EDITING.md +261 -0
  44. ComfyUI-MiniMaxH3-Context-Loop/docs/MIGRATING_TO_0_5.md +103 -0
  45. ComfyUI-MiniMaxH3-Context-Loop/docs/NODE_REFERENCE.md +152 -0
  46. ComfyUI-MiniMaxH3-Context-Loop/docs/PROJECT_ASSETS.md +137 -0
  47. ComfyUI-MiniMaxH3-Context-Loop/docs/README.md +60 -0
  48. ComfyUI-MiniMaxH3-Context-Loop/docs/REFERENCE_VIDEO_FADE.md +114 -0
  49. ComfyUI-MiniMaxH3-Context-Loop/docs/RUNS_AND_RECOVERY.md +1077 -0
  50. 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

  • SHA256: 7a300c71db8a46179c50668a2cbb22d901bbccb3fe62009680da0c3b3bcef8d4
  • Pointer size: 131 Bytes
  • Size of remote file: 245 kB
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).