diff --git a/.gitattributes b/.gitattributes
index 33251842d09934c50e90f811ff99ad6b7942f144..0bad2d2dc8d98f0b8fc0eeaadef130982a43e226 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1289,3 +1289,12 @@ ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/assets/uniform_vs_adaptive_sbs.m
ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/docs/latents/frame0.png filter=lfs diff=lfs merge=lfs -text
ComfyUI-MiniMaxH3-Context-Loop/ComfyUI-MAINodes/docs/latents/frame72.png filter=lfs diff=lfs merge=lfs -text
ComfyUI-MiniMaxH3-Context-Loop/__pycache__/chain_nodes.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
+ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
+ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png filter=lfs diff=lfs merge=lfs -text
+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
+ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/h3_v06_courier_greenhouse_arrival.png filter=lfs diff=lfs merge=lfs -text
+ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/h3_v06_courier_greenhouse_delivery.png filter=lfs diff=lfs merge=lfs -text
+ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/jigen_market_garden_doom_last.png filter=lfs diff=lfs merge=lfs -text
+ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/jigen_market_garden_doom_opening.png filter=lfs diff=lfs merge=lfs -text
+ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/soldier_crabs_bribie_island_cc0.webm filter=lfs diff=lfs merge=lfs -text
+ComfyUI-MiniMaxH3-Context-Loop/example_workflows/assets/soldier_crabs_reference_cc0.png filter=lfs diff=lfs merge=lfs -text
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_ops.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_ops.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d4991a70eb4dce8ea47b8ffb4e57f1e7e14bb037
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_ops.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_support.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_support.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3ee2830e31dfc922301de20a7c2f1e3d4be55fe4
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/masking_support.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/master_audio_context.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/master_audio_context.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..68994ac009a926e103c775c77fe1d4dc1300354d
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/master_audio_context.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/motion_context_upstream.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/motion_context_upstream.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..9a1afcf6d3528d46ec6062cfcac82b04861b232a
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/motion_context_upstream.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/nodes.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/nodes.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..953cb4cceccc77c2235bd6cb2401a462825b2f1e
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/nodes.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_layout.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_layout.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e01992ebe80738fd74784c396c7c0eb25e93364f
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_layout.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_payload.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_payload.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..a9a44a2304df94d7ad820714f29e92af5ed9cbc4
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/patch_payload.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/probe_node.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/probe_node.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..95fa91eb82b27bafc45624ecf0f7fb91e5930efb
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/probe_node.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_checkpoint_delete.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_checkpoint_delete.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..fe1de0685bc7c3a1460c6c2c70b846d06d1fc947
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_checkpoint_delete.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_persistence.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_persistence.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f15bfd522d6d10e51b0962ffc54dfb42440a0075
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/processing_persistence.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/project_assets.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/project_assets.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c5b3577210c45a7bd205e0c1b0e0690d43a71795
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/project_assets.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_history.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_history.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..7fc71e55c4f855ecdb1be67bc95274b789ec2f65
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_history.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_optimizer.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_optimizer.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..0fbcf85afc6109db73237748841d53ffbee1a68f
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/prompt_optimizer.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_migration.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_migration.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ebd7f19db5bf40ff21a3673c2d4949e7c021a9c6
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_migration.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_recovery.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_recovery.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..946e2dffa67fc3cb919c8e83426d09db07c7a212
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_recovery.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_store.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_store.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..1996bb150a3cf472b9bbff7010cb1e60f00f69b8
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_store.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_usage.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_usage.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2e667bd027e1df026c9dcf7aa04b4c6493dcc74d
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_cache_usage.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_video_fade.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_video_fade.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..90920b10c56130b4bfc49f311091a48c345418ca
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/reference_video_fade.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/run_manager.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/run_manager.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..755af167d3a4125d8b9df4bdd3f2172827bf3aa9
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/run_manager.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/source_av_target.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/source_av_target.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e0cf33cbf563975f553880b6c1ee8e3fd5b3a336
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/source_av_target.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/tokenizer_compat.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/tokenizer_compat.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..8c45cba101e6acf4b55e3c77b452249a01619f61
Binary files /dev/null and b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/tokenizer_compat.cpython-312.pyc differ
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..98188d9d9d720a8ba932f3ff8549084335fc10cd
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/__pycache__/upscale_nodes.cpython-312.pyc
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:97a887900e16849e99ee7f73dff27148020bcfd63e4c26c37464afe39df2b0ee
+size 167903
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/asset_store.py b/ComfyUI-MiniMaxH3-Context-Loop/asset_store.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcf952e093ca041ca8acbb6bbcef098651919359
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/asset_store.py
@@ -0,0 +1,397 @@
+"""Portable, versioned media bindings for saved H3 chain runs."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import shutil
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+
+# The canvas keeps a small number of direct loader sockets. Semantic Anchor
+# Bundle contributes many loader bindings through one dedicated Run Manager
+# socket, so the persisted manifest accepts a larger bounded collection.
+MAX_DIRECT_ASSET_BINDINGS = 12
+MAX_ASSET_BINDINGS = 128
+ASSET_MANIFEST_FORMAT = "h3_chain_assets_v1"
+ASSET_ROLES = ("picture", "video", "audio_reference", "source_track")
+
+
+def _safe_name(value: Any, fallback: str = "asset") -> str:
+ text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(value or "").strip())
+ text = text.strip("._-")
+ return (text or fallback)[:128]
+
+
+def _utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat(
+ timespec="seconds").replace("+00:00", "Z")
+
+
+def _atomic_json(path: str, value: Any) -> None:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ temporary = "%s.%s.tmp" % (path, uuid.uuid4().hex)
+ try:
+ with open(temporary, "w", encoding="utf-8") as handle:
+ json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True)
+ handle.write("\n")
+ os.replace(temporary, path)
+ finally:
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
+
+
+def _read_json(path: str) -> Any:
+ with open(path, "r", encoding="utf-8") as handle:
+ return json.load(handle)
+
+
+def _inside(root: str, path: str) -> bool:
+ try:
+ return os.path.commonpath(
+ (os.path.realpath(root), os.path.realpath(path))) == os.path.realpath(root)
+ except ValueError:
+ return False
+
+
+def _file_sha256(path: str) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as handle:
+ while True:
+ chunk = handle.read(1024 * 1024)
+ if not chunk:
+ break
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _role_group(role: str) -> str:
+ if role == "picture":
+ return "images"
+ if role == "video":
+ return "video"
+ return "audio"
+
+
+def _binding_id(value: Any) -> str:
+ text = str(value or "").strip()
+ if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", text):
+ return text
+ return uuid.uuid4().hex
+
+
+class RunAssetStore:
+ """Save loader-backed media and materialize missing-input fallbacks."""
+
+ def __init__(self, output_root: str, input_root: str | None):
+ self.output_root = os.path.realpath(os.path.abspath(output_root))
+ self.input_root = (os.path.realpath(os.path.abspath(input_root))
+ if input_root is not None else None)
+ self.chains_root = os.path.join(self.output_root, "h3_chains")
+
+ def _run_dir(self, run_name: Any) -> tuple[str, str]:
+ run = _safe_name(run_name, "")
+ if not run:
+ raise ValueError("A non-empty H3 chain run_name is required.")
+ path = os.path.realpath(os.path.join(self.chains_root, run))
+ if not _inside(self.output_root, path):
+ raise ValueError("H3 asset run path escapes the output directory.")
+ return path, run
+
+ def _manifest_path(self, run_name: Any) -> tuple[str, str, str]:
+ directory, run = self._run_dir(run_name)
+ return (os.path.join(directory, "references", "manifest.json"),
+ directory, run)
+
+ def _source_path(self, value: Any) -> str | None:
+ if self.input_root is None or not isinstance(value, str):
+ return None
+ name = value.strip()
+ if not name:
+ return None
+ if name.endswith("[input]"):
+ name = name[:-7].rstrip()
+ elif name.endswith("[output]") or name.endswith("[temp]"):
+ return None
+ path = os.path.abspath(os.path.join(self.input_root, name))
+ if not _inside(self.input_root, path) or not os.path.isfile(path):
+ return None
+ return path
+
+ def _archived_path(self, run_dir: str, relative: Any) -> str | None:
+ if not isinstance(relative, str) or not relative:
+ return None
+ path = os.path.realpath(os.path.join(run_dir, relative))
+ if not _inside(run_dir, path) or not os.path.isfile(path):
+ return None
+ return path
+
+ def _copy_to_archive(self, source: str, run_dir: str,
+ role: str) -> dict[str, Any]:
+ group = _role_group(role)
+ destination_dir = os.path.join(run_dir, "references", group)
+ os.makedirs(destination_dir, exist_ok=True)
+ suffix = os.path.splitext(source)[1][:24]
+ basename = _safe_name(os.path.splitext(os.path.basename(source))[0])
+ temporary = os.path.join(destination_dir, ".%s.tmp" % uuid.uuid4().hex)
+ digest = hashlib.sha256()
+ size = 0
+ try:
+ with open(source, "rb") as source_handle, open(temporary, "xb") as target:
+ while True:
+ chunk = source_handle.read(1024 * 1024)
+ if not chunk:
+ break
+ target.write(chunk)
+ digest.update(chunk)
+ size += len(chunk)
+ sha256 = digest.hexdigest()
+ filename = "%s_%s%s" % (sha256, basename, suffix)
+ destination = os.path.join(destination_dir, filename)
+ if (os.path.isfile(destination)
+ and os.path.getsize(destination) == size
+ and _file_sha256(destination) == sha256):
+ os.unlink(temporary)
+ else:
+ os.replace(temporary, destination)
+ relative = os.path.relpath(destination, run_dir).replace(os.sep, "/")
+ return {
+ "relative_path": relative,
+ "sha256": sha256,
+ "size": size,
+ "saved_at": _utc_now(),
+ "original_basename": os.path.basename(source),
+ }
+ finally:
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
+
+ @staticmethod
+ def _normalized_binding(raw: Any) -> dict[str, Any] | None:
+ if not isinstance(raw, dict):
+ return None
+ role = str(raw.get("role") or "").strip()
+ if role not in ASSET_ROLES:
+ return None
+ output_slot = raw.get("output_slot", 0)
+ node_id = raw.get("node_id")
+ try:
+ output_slot = int(output_slot)
+ except (TypeError, ValueError):
+ output_slot = 0
+ return {
+ "binding_id": _binding_id(raw.get("binding_id")),
+ "label": str(raw.get("label") or role).strip()[:160],
+ "role": role,
+ "node_id": str(node_id) if node_id is not None else "",
+ "node_type": str(raw.get("node_type") or "").strip()[:160],
+ "node_title": str(raw.get("node_title") or "").strip()[:160],
+ "output_slot": max(0, output_slot),
+ "output_type": str(raw.get("output_type") or "").strip()[:80],
+ "widget_name": str(raw.get("widget_name") or "").strip()[:120],
+ "original_value": str(raw.get("original_value") or "").strip()[:4096],
+ }
+
+ def load_manifest(self, run_name: Any) -> dict[str, Any] | None:
+ path, _directory, _run = self._manifest_path(run_name)
+ if not os.path.isfile(path):
+ return None
+ document = _read_json(path)
+ if (not isinstance(document, dict)
+ or document.get("format") != ASSET_MANIFEST_FORMAT
+ or not isinstance(document.get("bindings"), list)):
+ raise ValueError("Saved H3 reference manifest has an unsupported format.")
+ return document
+
+ def summary(self, run_name: Any) -> dict[str, Any]:
+ _manifest_path, run_dir, _run = self._manifest_path(run_name)
+ manifest = self.load_manifest(run_name)
+ if manifest is None:
+ return {"asset_count": 0, "asset_bytes": 0}
+ archived = 0
+ for binding in manifest.get("bindings", []):
+ archive = binding.get("archive") if isinstance(binding, dict) else None
+ if isinstance(archive, dict):
+ archived += 1
+ total = 0
+ archive_files = 0
+ # Count the actual retained snapshots, including older versions and
+ # safe orphaned files left by a removed binding. Recovery data is not
+ # deleted implicitly merely to make a manifest smaller.
+ for group in ("images", "audio", "video"):
+ directory = os.path.join(run_dir, "references", group)
+ if not os.path.isdir(directory):
+ continue
+ with os.scandir(directory) as entries:
+ for entry in entries:
+ if entry.name.startswith(".") or not entry.is_file(
+ follow_symlinks=False):
+ continue
+ archive_files += 1
+ total += max(0, entry.stat(follow_symlinks=False).st_size)
+ return {
+ "asset_count": len(manifest.get("bindings", [])),
+ "archived_asset_count": archived,
+ "asset_file_count": archive_files,
+ "asset_bytes": total,
+ }
+
+ def save(self, run_name: Any, bindings: Any,
+ policies: dict[str, Any] | None = None) -> dict[str, Any]:
+ manifest_path, run_dir, run = self._manifest_path(run_name)
+ if not isinstance(bindings, list):
+ raise ValueError("H3 asset bindings must be a JSON list.")
+ if len(bindings) > MAX_ASSET_BINDINGS:
+ raise ValueError("H3 Run Manager supports at most %d asset bindings."
+ % MAX_ASSET_BINDINGS)
+ policies = policies if isinstance(policies, dict) else {}
+ enabled = {
+ "images": bool(policies.get("images", True)),
+ "audio": bool(policies.get("audio", True)),
+ "video": bool(policies.get("video", False)),
+ }
+ try:
+ old_manifest = self.load_manifest(run) or {}
+ except (OSError, ValueError, json.JSONDecodeError):
+ old_manifest = {}
+ old_bindings = {
+ item.get("binding_id"): item
+ for item in old_manifest.get("bindings", [])
+ if isinstance(item, dict) and item.get("binding_id")
+ }
+ saved = []
+ warnings = []
+ for raw in bindings:
+ binding = self._normalized_binding(raw)
+ if binding is None:
+ warnings.append("Ignored one binding with an unsupported media role.")
+ continue
+ previous = old_bindings.get(binding["binding_id"], {})
+ versions = list(previous.get("versions") or [])
+ prior_archive = previous.get("archive")
+ same_source = (
+ previous.get("original_value") == binding["original_value"])
+ source = self._source_path(binding["original_value"])
+ group = _role_group(binding["role"])
+ archive = (prior_archive if same_source
+ and isinstance(prior_archive, dict) else None)
+ if (isinstance(prior_archive, dict) and not same_source
+ and not any(item.get("sha256") == prior_archive.get("sha256")
+ for item in versions if isinstance(item, dict))):
+ versions.append(prior_archive)
+ if source is not None and enabled[group]:
+ next_archive = self._copy_to_archive(source, run_dir, binding["role"])
+ if (archive and archive.get("sha256") != next_archive.get("sha256")
+ and not any(item.get("sha256") == archive.get("sha256")
+ for item in versions if isinstance(item, dict))):
+ versions.append(archive)
+ archive = next_archive
+ elif not enabled[group] and archive:
+ if not any(item.get("sha256") == archive.get("sha256")
+ for item in versions if isinstance(item, dict)):
+ versions.append(archive)
+ archive = None
+ if source is None:
+ warnings.append(
+ "%s: original input file is unavailable%s." % (
+ binding["label"],
+ " and no archived fallback exists" if not archive else ""))
+ binding["archive_policy"] = (
+ "fallback_copy" if enabled[group] else "path_only")
+ binding["source_available_when_saved"] = source is not None
+ if archive:
+ binding["archive"] = archive
+ if versions:
+ binding["versions"] = versions
+ saved.append(binding)
+
+ document = {
+ "format": ASSET_MANIFEST_FORMAT,
+ "run_name": run,
+ "updated_at": _utc_now(),
+ "policies": enabled,
+ "bindings": saved,
+ }
+ _atomic_json(manifest_path, document)
+ summary = self.summary(run)
+ return {
+ "run_name": run,
+ "manifest": os.path.relpath(manifest_path, self.output_root).replace(os.sep, "/"),
+ "bindings": saved,
+ "warnings": warnings,
+ **summary,
+ }
+
+ def _materialize_archive(self, run: str, archived_path: str,
+ archive: dict[str, Any]) -> str:
+ if self.input_root is None:
+ raise ValueError("The ComfyUI input directory is unavailable.")
+ os.makedirs(self.input_root, exist_ok=True)
+ digest = str(archive.get("sha256") or "")
+ basename = _safe_name(
+ archive.get("original_basename") or os.path.basename(archived_path))
+ filename = "h3_%s_%s_%s" % (run, digest[:16] or "asset", basename)
+ destination = os.path.join(self.input_root, filename)
+ if not _inside(self.input_root, destination):
+ raise ValueError("Restored H3 asset path escapes the input directory.")
+ expected = str(archive.get("sha256") or "")
+ destination_ready = (os.path.isfile(destination)
+ and (not expected
+ or _file_sha256(destination) == expected))
+ if not destination_ready:
+ temporary = "%s.%s.tmp" % (destination, uuid.uuid4().hex)
+ try:
+ shutil.copy2(archived_path, temporary)
+ if expected and _file_sha256(temporary) != expected:
+ raise ValueError("Archived fallback failed its SHA-256 check.")
+ os.replace(temporary, destination)
+ finally:
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
+ return os.path.basename(destination)
+
+ def prepare_restore(self, run_name: Any) -> dict[str, Any]:
+ _path, run_dir, run = self._manifest_path(run_name)
+ manifest = self.load_manifest(run)
+ if manifest is None:
+ return {"run_name": run, "bindings": [], "warnings": []}
+ restored = []
+ warnings = []
+ for original in manifest.get("bindings", []):
+ if not isinstance(original, dict):
+ continue
+ binding = dict(original)
+ source = self._source_path(binding.get("original_value"))
+ if source is not None:
+ binding["restore_value"] = binding.get("original_value")
+ binding["restore_source"] = "original_input"
+ else:
+ archive = binding.get("archive")
+ archived_path = self._archived_path(
+ run_dir, archive.get("relative_path")
+ if isinstance(archive, dict) else None)
+ if archived_path is None:
+ binding["restore_source"] = "missing"
+ warnings.append("%s: original and archived files are missing."
+ % binding.get("label", binding.get("role", "asset")))
+ else:
+ try:
+ binding["restore_value"] = self._materialize_archive(
+ run, archived_path, archive)
+ binding["restore_source"] = "archived_fallback"
+ except (OSError, ValueError) as exc:
+ binding["restore_source"] = "missing"
+ warnings.append("%s: %s" % (
+ binding.get("label", binding.get("role", "asset")), exc))
+ restored.append(binding)
+ return {"run_name": run, "bindings": restored, "warnings": warnings}
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/assets/minimax-h3-context-loop.svg b/ComfyUI-MiniMaxH3-Context-Loop/assets/minimax-h3-context-loop.svg
new file mode 100644
index 0000000000000000000000000000000000000000..db7d28cd0fb3147bb6318950b5023739cf4eac4e
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/assets/minimax-h3-context-loop.svg
@@ -0,0 +1,98 @@
+
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png b/ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png
new file mode 100644
index 0000000000000000000000000000000000000000..5d1867cae20c0732e4f37e7a442cb0ee834c8758
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7a300c71db8a46179c50668a2cbb22d901bbccb3fe62009680da0c3b3bcef8d4
+size 244689
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.svg b/ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.svg
new file mode 100644
index 0000000000000000000000000000000000000000..96464098cb34f2a91177e606c573d6071bfe4b15
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/assets/social-preview.svg
@@ -0,0 +1,85 @@
+
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/assets/workflow-overview.svg b/ComfyUI-MiniMaxH3-Context-Loop/assets/workflow-overview.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d1d5a6eb92795f63fa1064ec87cac08c69e65c31
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/assets/workflow-overview.svg
@@ -0,0 +1,188 @@
+
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/audio_track_contract.py b/ComfyUI-MiniMaxH3-Context-Loop/audio_track_contract.py
new file mode 100644
index 0000000000000000000000000000000000000000..7707490d7b8bae86ac87514f6ae44fa0649e8c0c
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/audio_track_contract.py
@@ -0,0 +1,26 @@
+"""JSON-only contracts for grouped project audio; no media or runtime imports."""
+
+AUDIO_TRACK_ROLES = ("full_mix", "vocals", "instrumental")
+
+
+def audio_track_bindings(value, assets):
+ if value is None:
+ return None
+ if not isinstance(value, dict) or set(value) - set(AUDIO_TRACK_ROLES):
+ raise ValueError("Audio tracks must map full_mix, vocals, and instrumental to asset IDs.")
+ known = {str(asset.get("id") or ""): asset for asset in assets}
+ result = {}
+ for role in AUDIO_TRACK_ROLES:
+ raw = value.get(role, "")
+ if raw is not None and not isinstance(raw, str):
+ raise ValueError("Audio track IDs must be strings.")
+ asset_id = str(raw or "").strip()
+ if asset_id and (asset_id not in known or known[asset_id].get("kind") != "audio"):
+ raise ValueError("Audio track %s must reference an existing audio asset in this project." % role)
+ result[role] = asset_id
+ selected = [asset_id for asset_id in result.values() if asset_id]
+ if not selected:
+ raise ValueError("Select at least one audio track, or reset to the original single track.")
+ if len(selected) != len(set(selected)):
+ raise ValueError("The same audio asset cannot be both a mix and a stem, or both stems.")
+ return result
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/av_timing.py b/ComfyUI-MiniMaxH3-Context-Loop/av_timing.py
new file mode 100644
index 0000000000000000000000000000000000000000..380a16492950635683b2ca2452942e5cfe4cad98
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/av_timing.py
@@ -0,0 +1,96 @@
+"""Exact MiniMax H3 picture/audio timeline helpers.
+
+H3 picture runs live on a 24 fps clock while generated audio latents live on
+a 40 Hz clock. A decoded audio stream can consequently land a few
+milliseconds either side of its exact frame-derived duration. Small grid
+mismatches are time-conformed here instead of being repaired with silence.
+"""
+
+from __future__ import annotations
+
+from fractions import Fraction
+import logging
+
+import torch
+
+try:
+ import torchaudio
+except ImportError:
+ torchaudio = None
+
+
+_LOG = logging.getLogger("minimax_h3_context_loop.av_timing")
+
+# Private AUDIO mapping fields passed only between Loop Trim and Segment Save.
+# Keeping this transport inside the existing AUDIO value avoids another public
+# socket and another copy of the same decoded stream in user workflows.
+AUDIO_WITH_OVERLAP_WAVEFORM_KEY = "_h3_audio_with_overlap_waveform"
+AUDIO_WITH_OVERLAP_FRAMES_KEY = "_h3_audio_with_overlap_frames"
+AUDIO_TRIM_FRAMES_KEY = "_h3_audio_trim_frames"
+
+
+def sample_boundary_from_seconds(seconds: float, sample_rate: int) -> int:
+ """Return the nearest PCM sample on one absolute time boundary."""
+ return int(round(float(seconds) * int(sample_rate)))
+
+
+def sample_boundary_from_frames(
+ frame_position: int, sample_rate: int, fps: int = 24) -> int:
+ """Return the nearest PCM sample on one absolute video-frame boundary."""
+ return int(round(int(frame_position) / float(fps) * int(sample_rate)))
+
+
+def conform_waveform_length(
+ waveform, samples: int, label: str,
+ max_fractional_change: float = 0.005):
+ """Time-conform a small decoder/grid mismatch to an exact sample span.
+
+ A larger discrepancy is likely incorrect wiring or timing metadata and is
+ rejected instead of being hidden by a large resample.
+ """
+ target = int(samples)
+ current = int(waveform.shape[-1])
+ if current == target:
+ return waveform
+ if current <= 0 or target <= 0:
+ raise ValueError(
+ "%s has an invalid audio sample length %d -> %d." %
+ (label, current, target))
+
+ fractional_change = abs(target - current) / float(target)
+ if fractional_change > float(max_fractional_change):
+ raise ValueError(
+ "%s differs from its exact frame timeline by %.3f%% "
+ "(%d -> %d samples), too large for H3 audio time-conformance." %
+ (label, fractional_change * 100.0, current, target))
+
+ ratio = Fraction(target, current).limit_denominator(10000)
+ if torchaudio is not None:
+ conformed = torchaudio.functional.resample(
+ waveform, int(ratio.denominator), int(ratio.numerator))
+ else:
+ shape = tuple(waveform.shape)
+ conformed = torch.nn.functional.interpolate(
+ waveform.reshape(-1, 1, current),
+ size=target,
+ mode="linear",
+ align_corners=False,
+ ).reshape(*shape[:-1], target)
+
+ # A rational approximation or resampler backend may miss by one sample.
+ # Correct the residual by interpolation so silence is never introduced.
+ if int(conformed.shape[-1]) != target:
+ shape = tuple(conformed.shape)
+ conformed = torch.nn.functional.interpolate(
+ conformed.reshape(-1, 1, int(conformed.shape[-1])),
+ size=target,
+ mode="linear",
+ align_corners=False,
+ ).reshape(*shape[:-1], target)
+
+ _LOG.info(
+ "%s time-conformed %d -> %d audio samples (%+.4f%%) to match "
+ "the exact video timeline",
+ label, current, target,
+ (target - current) / float(current) * 100.0)
+ return conformed
diff --git a/ComfyUI-MiniMaxH3-Context-Loop/chain_nodes.py b/ComfyUI-MiniMaxH3-Context-Loop/chain_nodes.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d12a2bab9f06ad5796a5e4734ce9e26f3b563f6
--- /dev/null
+++ b/ComfyUI-MiniMaxH3-Context-Loop/chain_nodes.py
@@ -0,0 +1,29456 @@
+"""Disk-backed recursive clip chains specialized for MiniMax H3.
+
+The visible graph contains one H3 sampling body. Chain Start and Chain End
+recursively clone that body with ComfyUI's GraphBuilder, carrying only the
+previous clip's context tail and compact AV latent into the next iteration.
+Each iteration is persisted before recursion, so a long chain can resume from
+the first unfinished clip instead of starting over.
+
+The recursive graph traversal is adapted from Ethanfel's SxCP loop nodes in
+ComfyUI-Prompt-Builder, using the same ComfyUI expansion pattern with a single,
+typed MiniMax chain state rather than arbitrary carry sockets.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import gc
+import hashlib
+import ipaddress
+import itertools
+import json
+import logging
+import math
+import os
+import re
+import secrets
+import shutil
+import subprocess
+import struct
+import sys
+import time
+import uuid
+import wave
+from collections import deque
+from contextlib import contextmanager, nullcontext
+from datetime import datetime, timezone
+from fractions import Fraction
+from typing import Any
+
+import folder_paths
+
+try:
+ import av
+except ImportError: # ComfyUI normally ships PyAV.
+ av = None
+
+try:
+ import torch
+except ImportError: # ComfyUI always ships torch; keeps local imports clear.
+ torch = None
+
+try:
+ import numpy as np
+except ImportError: # NumPy ships with ComfyUI and PyAV.
+ np = None
+
+try:
+ from PIL import Image, ImageOps, PngImagePlugin
+except ImportError: # Pillow ships with ComfyUI.
+ Image = ImageOps = PngImagePlugin = None
+
+try:
+ from safetensors.torch import load_file as _st_load, save_file as _st_save
+except ImportError:
+ _st_load = _st_save = None
+
+try:
+ from comfy_execution.graph_utils import GraphBuilder, ExecutionBlocker, is_link
+except ImportError:
+ GraphBuilder = None
+ ExecutionBlocker = None
+
+ def is_link(value):
+ return isinstance(value, list) and len(value) == 2
+
+try:
+ from aiohttp import web
+ from server import PromptServer
+except ImportError:
+ web = None
+ PromptServer = None
+
+from .nodes import (
+ MiniMaxH3MotionContext,
+ _claim_inline_patch_ownership,
+ _prepare_native_guide_conditioning,
+ _resize,
+ _streams_from_latent,
+)
+from .motion_context_upstream import apply_motion_context
+from .chapter_resolution import (
+ normalize_resolution, scene_resolution, saved_resolution,
+ apply_chapter_resolutions, common_saved_resolution,
+)
+from .prompt_history import PromptHistoryStore
+from .prompt_optimizer import optimize_prompt_payload
+from .run_manager import RunArchiveManager, archive_policy_inputs
+from .asset_store import MAX_DIRECT_ASSET_BINDINGS, RunAssetStore
+from .reference_cache_store import (
+ FORMAT as REFERENCE_CACHE_FORMAT, ReferenceTensorStore, objects_digest,
+)
+from .reference_cache_migration import ReferenceCacheMigrator, matches_legacy
+from .reference_cache_usage import note_converted_use, confirm_saved_use
+from .project_assets import (
+ PROJECT_ASSET_FORMAT,
+ ProjectAssetStore,
+)
+from .av_timing import (
+ AUDIO_TRIM_FRAMES_KEY,
+ AUDIO_WITH_OVERLAP_FRAMES_KEY,
+ AUDIO_WITH_OVERLAP_WAVEFORM_KEY,
+ conform_waveform_length,
+ sample_boundary_from_frames,
+)
+from .contracts_v05 import (
+ ADVANCED_TRANSITION_PRESETS,
+ AV_TRANSITION_CONTEXT_LENGTHS,
+ AUDIO_POLICY_VERSION,
+ CHAIN_POLICY_VERSION,
+ CONTEXT_SPATIAL_PROXY_MODES,
+ CONTEXT_SPATIAL_PROXY_RECIPE,
+ CONTINUATION_POLICIES,
+ DETAIL_AV_RECIPE,
+ DRIFT_CONTROL_AV_RECIPE,
+ LATENT_COLOR_CARRY_RECIPE,
+ DEPENDENCY_SCOPES,
+ FINAL_AUDIO_POLICIES,
+ GENERATION_AUDIO_PROFILES,
+ GENERATION_SCENE_PROFILES,
+ GENERATED_CONTINUITY_POLICIES,
+ LIP_SYNC_OPTIONS_VERSION,
+ PRIMARY_TRANSITION_PRESETS,
+ PREFLIGHT_VERSION,
+ SCENE_DEPENDENCY_VERSION,
+ SOURCE_REFERENCE_POLICIES,
+ SOURCE_TIMELINE_VERSION,
+ TRANSITION_CONTEXT_LENGTHS,
+ TRANSITION_POLICY_VERSION,
+ audio_policy as _contract_audio_policy,
+ chain_policy as _contract_chain_policy,
+ compose_chain_policy as _contract_compose_chain_policy,
+ migrate_continuation_mode,
+ migrate_legacy_audio_mode,
+ masked_song_options as _contract_masked_song_options,
+ paired_audio_policy as _contract_paired_audio_policy,
+ transition_policy as _contract_transition_policy,
+)
+from .checkpoint_manager import (
+ CheckpointDeleteBlocked,
+ CheckpointGraphManager,
+ checkpoint_audio_context_length,
+ checkpoint_revision_token,
+ checkpoint_run_lock,
+)
+
+
+_LOG = logging.getLogger("minimax_h3_context_loop.chain")
+
+FPS = 24
+AUDIO_HZ = 40
+PLAN_VERSION = 2
+MAX_SHOTS = 128
+MAX_SEED = 0xFFFFFFFFFFFFFFFF
+MAX_REVIEW_CANDIDATES = 20
+MAX_H3_FRAMES = 3592 # largest 17k+5 value accepted by H3's 3600-frame socket
+PNG_EXPORT_AUTO_WORKERS_CAP = 8
+PNG_EXPORT_MIN_CHUNK_FRAMES = 8
+PNG_EXPORT_MAX_CHUNK_FRAMES = 64
+PNG_EXPORT_HASH_CACHE_FORMAT = "h3_png_checkpoint_hash_cache_v1"
+CHAPTER_MANIFEST_FORMAT = "h3_chain_chapter_manifest_v1"
+BOUNDARY_TONE_MATCH_MODES = ("off", "auto")
+BOUNDARY_TONE_MATCH_CONTEXT_FRAMES = 8
+BOUNDARY_TONE_MATCH_SEARCH_FRAMES = 4
+BOUNDARY_TONE_MATCH_MIN_JUMP = 1.25
+BOUNDARY_TONE_MATCH_MAX_SHIFT = 6.0
+ASSEMBLY_COLOR_STABILIZATION_MODES = ("off", "scene_1_anchor")
+ASSEMBLY_COLOR_STABILIZATION_RAMP_FRAMES = 72
+ASSEMBLY_COLOR_STABILIZATION_STRENGTH = 0.5
+ASSEMBLY_COLOR_STABILIZATION_MAX_LUMA_SHIFT = 6.0
+ASSEMBLY_COLOR_STABILIZATION_MAX_SATURATION_CHANGE = 0.06
+MASKED_AUDIO_CONTRACT = "raw_source_window_v2"
+PLAN_STUDIO_PREVIEW_TTL_SECONDS = 6 * 60 * 60
+PLAN_STUDIO_PREVIEW_MAX_SESSIONS = 32
+PLAN_STUDIO_PREVIEW_MAX_HEIGHT = 480
+PLAN_STUDIO_WAVEFORM_SAMPLE_RATE = 192
+PLAN_STUDIO_WAVEFORM_POINTS_PER_SECOND = 12
+PLAN_STUDIO_THUMBNAIL_WIDTH = 320
+_PLAN_STUDIO_SOURCE_PREVIEWS: dict[str, dict[str, Any]] = {}
+_PLAN_STUDIO_PREVIEW_BUILD_LOOP: asyncio.AbstractEventLoop | None = None
+_PLAN_STUDIO_PREVIEW_BUILD_GATE: asyncio.Semaphore | None = None
+_PLAN_STUDIO_PREVIEW_BUILD_TASKS: dict[str, asyncio.Task[str]] = {}
+_PLAN_STUDIO_THUMBNAIL_BUILD_LOOP: asyncio.AbstractEventLoop | None = None
+_PLAN_STUDIO_THUMBNAIL_BUILD_GATE: asyncio.Semaphore | None = None
+_PLAN_STUDIO_THUMBNAIL_BUILD_TASKS: dict[str, asyncio.Task[str]] = {}
+H3_CONTEXT_LENGTHS = (
+ 1, 5, 22, 39, 56, 73, 90, 107, 124,
+ 141, 158, 175, 192, 209, 226, 243,
+)
+AUDIO_MODES = ("source_track", "generated_audio", "source_plus_timeline")
+CONTINUATION_MODES = (
+ "guide", "tone_carry_guide", "latent_guide", "tapered_guide",
+ "masked_av", "tapered_av", "feathered_av", "audio_feathered_av",
+ "drift_control_av", "color_stable_drift_av")
+SCENE_LORA_ROUTES = (
+ "base", *(chr(ord("a") + offset) for offset in range(26)))
+LOOP_MEMORY_POLICIES = ("off", "unload_models", "fresh_scene")
+GUIDE_CONTINUATION_MODES = frozenset((
+ "guide", "tone_carry_guide", "latent_guide", "tapered_guide"))
+MASKED_CONTINUATION_MODES = frozenset((
+ "masked_av", "tapered_av", "feathered_av", "audio_feathered_av",
+ "drift_control_av", "color_stable_drift_av"))
+DISPOSABLE_PREFIX_CONTINUATION_MODES = frozenset((
+ "tapered_av", "drift_control_av", "color_stable_drift_av"))
+DRIFT_CONTROL_CONTINUATION_MODES = frozenset((
+ "drift_control_av", "color_stable_drift_av"))
+LATENT_COLOR_CARRY_CONTINUATION_MODES = frozenset((
+ "color_stable_drift_av",))
+RGB_PROXY_GUIDE_MODES = frozenset((
+ "guide", "tone_carry_guide", "tapered_guide"))
+REFERENCE_AUDIO_TIMELINE_MODES = ("standalone", "source_timeline")
+MOTION_REFERENCE_SHORT_EDGES = ("384", "512", "768", "source")
+_LAZY_INPUT_MISSING = object()
+
+# These Plan-wide controls describe how the next scene consumes an immutable
+# predecessor. Their effective values are still recorded on generated scenes,
+# but changing them must not by itself rewrite the predecessor's identity.
+_NEXT_SCENE_COMPATIBILITY_KEYS = frozenset((
+ "continuation_mode",
+ "context_length",
+ "context_storage_length",
+))
+
+PLAN_TYPE = "H3_CHAIN_PLAN"
+STATE_TYPE = "H3_CHAIN_STATE"
+FLOW_TYPE = "H3_CHAIN_FLOW"
+SEGMENT_TYPE = "H3_CHAIN_SEGMENT"
+MANIFEST_TYPE = "H3_CHAIN_MANIFEST"
+EXTERNAL_CONTEXT_TYPE = "H3_CHAIN_EXTERNAL_CONTEXT"
+REFERENCE_SCHEDULE_TYPE = "H3_REFERENCE_SCHEDULE"
+TAGGED_REFERENCE_TYPE = "H3_TAGGED_REFERENCES"
+REFMOD_SOURCE_LIST_TYPE = "H3_REF_LIST"
+TAGGED_SCENE_OPTIONS_TYPE = "H3_TAGGED_SCENE_OPTIONS"
+SCENE_DATA_TYPE = "H3_SCENE_DATA"
+SEMANTIC_ANCHOR_DRAFT_TYPE = "H3_SEMANTIC_ANCHOR_DRAFT"
+SEMANTIC_PRESENTATION_TYPE = "H3_SEMANTIC_PRESENTATION"
+LAZY_MOTION_SOURCE_TYPE = "H3_LAZY_MOTION_SOURCE"
+SOURCE_TIMELINE_TYPE = "H3_SOURCE_TIMELINE"
+PROJECT_ASSETS_TYPE = "H3_PROJECT_ASSETS"
+AUDIO_POLICY_TYPE = "H3_AUDIO_POLICY"
+TRANSITION_POLICY_TYPE = "H3_TRANSITION_POLICY"
+CHAIN_POLICY_TYPE = "H3_CHAIN_POLICY"
+LIP_SYNC_OPTIONS_TYPE = "H3_LIP_SYNC_OPTIONS"
+PREFLIGHT_TYPE = "H3_PREFLIGHT_REPORT"
+REFERENCE_SCHEDULE_VERSION = 1
+REFERENCE_FINGERPRINT_LINEAGE_VERSION = 1
+TAGGED_SCENE_OPTIONS_VERSION = 1
+SCENE_DATA_VERSION = 1
+SEMANTIC_ANCHOR_REGISTRY_VERSION = 1
+LAZY_MOTION_SOURCE_VERSION = 3
+SEMANTIC_PRESENTATION_VERSION = 1
+EXTERNAL_REFMOD_CONTRACT_VERSION = 2
+
+_PENDING_REVIEWS: dict[str, dict[str, Any]] = {}
+_ACTIVE_CANDIDATE_BATCHES: dict[str, dict[str, Any]] = {}
+_PENDING_FINAL_REVIEW_PREVIEWS: dict[
+ tuple[str, str], dict[str, Any]
+] = {}
+_FFMPEG_PROBE_CACHE: dict[str, bool] = {}
+
+
+def _canonical_json(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True,
+ separators=(",", ":"))
+
+
+def _fingerprint(value: Any) -> str:
+ return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
+
+
+def _validate_audio_policy(value: Any) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ValueError(
+ "H3 Audio Policy must come from MiniMax H3 Audio Policy.")
+ if value.get("version") != AUDIO_POLICY_VERSION:
+ raise ValueError(
+ "H3 Audio Policy version is missing or obsolete.")
+ return _contract_audio_policy(
+ value.get("final_audio"), value.get("source_reference"),
+ value.get("generated_continuity"),
+ value.get("source_audio_target", "off"),
+ value.get("lip_sync_options"))
+
+
+def _resolved_audio_policy(value: Any) -> dict[str, Any]:
+ """Resolve an explicit 0.5 policy or faithfully migrate a 0.4 mode."""
+ compatibility = (
+ value.get("compatibility")
+ if isinstance(value, dict) and isinstance(
+ value.get("compatibility"), dict)
+ else value)
+ if not isinstance(compatibility, dict):
+ raise ValueError("H3 audio policy requires Plan compatibility data.")
+ explicit = compatibility.get("audio_policy")
+ if explicit is not None:
+ return _validate_audio_policy(explicit)
+ return migrate_legacy_audio_mode(compatibility.get("audio_mode"))
+
+
+def _legacy_audio_mode_for_policy(policy: Any) -> str:
+ resolved = _validate_audio_policy(policy)
+ for mode in AUDIO_MODES:
+ if migrate_legacy_audio_mode(mode) == resolved:
+ return mode
+ return "custom"
+
+
+def _audio_policy_summary(value: Any) -> str:
+ policy = _resolved_audio_policy(value)
+ summary = "final=%s/ref=%s/carry=%s" % (
+ policy["final_audio"], policy["source_reference"],
+ policy["generated_continuity"])
+ if policy.get("source_audio_target") == "locked":
+ summary += "/target=locked"
+ return summary
+
+
+def _optional_scene_audio_axis(
+ shot: Any, key: str, allowed: tuple[str, ...]) -> str | None:
+ """Return one validated scene override, or None for inherited behavior."""
+ if not isinstance(shot, dict) or key not in shot:
+ return None
+ raw = shot.get(key)
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
+ return None
+ value = str(raw).strip().lower()
+ if value == "inherit":
+ return None
+ if value not in allowed:
+ raise ValueError(
+ "Unknown H3 scene %s override %r; expected inherit or one of %s."
+ % (key.replace("_", " "), raw, allowed))
+ return value
+
+
+def _resolved_scene_audio_policy(
+ value: Any, shot: Any = None) -> dict[str, Any]:
+ """Overlay one scene's generation axes on the Plan-wide audio policy.
+
+ Final mux choice deliberately remains Plan-wide. Locking the source target
+ keeps the existing contract rule: it disables loose source reference and
+ predecessor generated-audio carry for that scene.
+ """
+ policy = _resolved_audio_policy(value)
+ source_reference = _optional_scene_audio_axis(
+ shot, "source_reference", SOURCE_REFERENCE_POLICIES)
+ generated_continuity = _optional_scene_audio_axis(
+ shot, "generated_continuity", GENERATED_CONTINUITY_POLICIES)
+ source_audio_target = _optional_scene_audio_axis(
+ shot, "source_audio_target", ("off", "locked"))
+ return _contract_audio_policy(
+ policy["final_audio"],
+ source_reference or policy["source_reference"],
+ generated_continuity or policy["generated_continuity"],
+ source_audio_target or policy.get("source_audio_target", "off"),
+ policy.get("lip_sync_options"),
+ )
+
+
+def _resolved_lip_sync_options(
+ value: Any, shot: Any = None) -> dict[str, Any] | None:
+ """Return the contextual song recipe only for an active locked target."""
+ policy = _resolved_scene_audio_policy(value, shot)
+ if policy.get("source_audio_target", "off") != "locked":
+ return None
+ options = policy.get("lip_sync_options")
+ return (_contract_masked_song_options(options)
+ if options is not None else None)
+
+
+def _audio_policy_locks_source_audio(value: Any, shot: Any = None) -> bool:
+ return _resolved_scene_audio_policy(value, shot).get(
+ "source_audio_target", "off") == "locked"
+
+
+def _audio_source_requirements(value: Any) -> list[dict[str, Any]]:
+ """Describe every effective setting that makes source audio mandatory."""
+ policy = _resolved_audio_policy(value)
+ requirements: list[dict[str, Any]] = []
+ if policy["final_audio"] == "source":
+ requirements.append({
+ "scope": "final output",
+ "setting": "final_audio",
+ "value": "source",
+ "origin": "Chain Policy",
+ })
+
+ shots = value.get("shots") if isinstance(value, dict) else None
+ if isinstance(shots, list):
+ for fallback_index, shot in enumerate(shots, 1):
+ resolved = _resolved_scene_audio_policy(value, shot)
+ scene = int(shot.get("index", fallback_index))
+ scene_id = str(shot.get("id") or "scene_%d" % scene)
+ for setting, active_value, allowed in (
+ ("source_reference", "on", SOURCE_REFERENCE_POLICIES),
+ ("source_audio_target", "locked", ("off", "locked"))):
+ if resolved.get(setting, "off") != active_value:
+ continue
+ override = _optional_scene_audio_axis(
+ shot, setting, allowed)
+ requirements.append({
+ "scope": "scene",
+ "scene": scene,
+ "scene_id": scene_id,
+ "setting": setting,
+ "value": active_value,
+ "origin": (
+ "scene override" if override is not None
+ else "Chain Policy"),
+ })
+ else:
+ for setting, active_value in (
+ ("source_reference", "on"),
+ ("source_audio_target", "locked")):
+ if policy.get(setting, "off") == active_value:
+ requirements.append({
+ "scope": "Plan",
+ "setting": setting,
+ "value": active_value,
+ "origin": "Chain Policy",
+ })
+ return requirements
+
+
+def _format_audio_source_requirement(requirement: dict[str, Any]) -> str:
+ if requirement.get("scope") == "scene":
+ location = 'Scene %d "%s"' % (
+ int(requirement["scene"]), str(requirement["scene_id"]))
+ else:
+ location = str(requirement.get("scope") or "Plan")
+ return "%s: %s=%s (%s)" % (
+ location, str(requirement.get("setting") or "audio_policy"),
+ str(requirement.get("value") or "required"),
+ str(requirement.get("origin") or "effective policy"))
+
+
+def _audio_policy_requires_source(value: Any) -> bool:
+ policy = _resolved_audio_policy(value)
+ if policy["final_audio"] == "source":
+ return True
+ shots = value.get("shots") if isinstance(value, dict) else None
+ if isinstance(shots, list):
+ return any(
+ _audio_policy_uses_source_reference(value, shot)
+ or _audio_policy_locks_source_audio(value, shot)
+ for shot in shots)
+ return (policy["source_reference"] == "on" or
+ policy.get("source_audio_target") == "locked")
+
+
+def _audio_policy_uses_source_reference(value: Any, shot: Any = None) -> bool:
+ return _resolved_scene_audio_policy(
+ value, shot)["source_reference"] == "on"
+
+
+def _audio_policy_uses_generated_continuity(
+ value: Any, shot: Any = None) -> bool:
+ return _resolved_scene_audio_policy(
+ value, shot)["generated_continuity"] == "on"
+
+
+def _audio_policy_final(value: Any) -> str:
+ return _resolved_audio_policy(value)["final_audio"]
+
+
+def _paired_audio_policy(value: Any) -> str:
+ return _contract_paired_audio_policy(value)
+
+
+def _validate_chain_policy(value: Any) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ValueError(
+ "H3 Chain Policy must come from MiniMax H3 Generation Profile, "
+ "Manual Chain Policy, Advanced Policy, or the Legacy 0.4 "
+ "Policy Adapter.")
+ if value.get("version") != CHAIN_POLICY_VERSION:
+ raise ValueError("H3 Chain Policy version is missing or obsolete.")
+ return _contract_compose_chain_policy(
+ value.get("audio_policy"), value.get("transition_policy"),
+ audio_context_length=value.get("audio_context_length", 22))
+
+
+def _validate_transition_policy(value: Any) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ValueError(
+ "H3 Transition Policy must come from MiniMax H3 Transition Policy.")
+ if value.get("version") != TRANSITION_POLICY_VERSION:
+ raise ValueError(
+ "H3 Transition Policy version is missing or obsolete.")
+ return _contract_transition_policy(
+ value.get("preset"),
+ expert_override=bool(value.get("expert_override", False)),
+ continuation_mode=value.get("continuation_mode"),
+ context_length=value.get("context_length"))
+
+
+def _resolved_transition_policy(value: Any) -> dict[str, Any]:
+ compatibility = (
+ value.get("compatibility")
+ if isinstance(value, dict) and isinstance(
+ value.get("compatibility"), dict)
+ else value)
+ if not isinstance(compatibility, dict):
+ raise ValueError(
+ "H3 transition policy requires Plan compatibility data.")
+ explicit = compatibility.get("transition_policy")
+ if explicit is not None:
+ return _validate_transition_policy(explicit)
+ mode = migrate_continuation_mode(
+ compatibility.get("continuation_mode", "guide"))
+ context = int(compatibility.get("context_length", 0))
+ preset = "legacy"
+ for candidate in (
+ "cut", "guide", "tone_guide", "latent_guide", "detail_guide",
+ "detail_av", "drift_av", "color_drift_av", "hard_av", "soft_av",
+ "audio_feather_av"):
+ resolved = _contract_transition_policy(candidate)
+ if (resolved["continuation_mode"] == mode and
+ int(resolved["context_length"]) == context):
+ preset = candidate
+ break
+ return {
+ "version": TRANSITION_POLICY_VERSION,
+ "preset": preset,
+ "continuation_mode": mode,
+ "context_length": context,
+ "expert_override": False,
+ }
+
+
+def _transition_policy_summary(value: Any) -> str:
+ policy = _resolved_transition_policy(value)
+ return "%s/%s/%df" % (
+ policy["preset"], policy["continuation_mode"],
+ int(policy["context_length"]))
+
+
+def _transition_policy_display(value: Any) -> str:
+ """Return the human-facing preset -> implementation mapping."""
+ policy = _resolved_transition_policy(value)
+ preset_labels = {
+ "cut": "Cut",
+ "guide": "Guide",
+ "tone_guide": "Tone Carry Guide",
+ "latent_guide": "Latent Guide",
+ "detail_guide": "Detail Guide",
+ "detail_av": "Detail AV",
+ "drift_av": "Drift-Control AV",
+ "color_drift_av": "Color-Stable Drift AV",
+ "hard_av": "Hard AV",
+ "soft_av": "Soft AV",
+ "audio_feather_av": "Audio Feather AV (legacy alias)",
+ "legacy": "Legacy",
+ }
+ implementation_labels = {
+ "guide": "Guide",
+ "tone_carry_guide": "Tone Carry Guide",
+ "latent_guide": "Latent Guide",
+ "tapered_guide": "Tapered Guide",
+ "masked_av": "Masked AV",
+ "tapered_av": "Tapered AV",
+ "feathered_av": "Feathered AV",
+ "audio_feathered_av": "Audio-Feathered AV",
+ "drift_control_av": "Drift-Control AV",
+ "color_stable_drift_av": "Color-Stable Drift AV",
+ }
+ preset = str(policy["preset"])
+ implementation = str(policy["continuation_mode"])
+ return "%s -> %s + %d frames" % (
+ preset_labels.get(preset, preset),
+ implementation_labels.get(implementation, implementation),
+ int(policy["context_length"]))
+
+
+def _safe_name(value: str, fallback: str = "chain") -> str:
+ text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(value or "").strip())
+ text = text.strip("._-")
+ return (text or fallback)[:96]
+
+
+def _strict_run_name(value: Any) -> str:
+ """Return an exact Run identity; never redirect a selected chapter."""
+ requested = str(value or "").strip()
+ normalized = _safe_name(requested, "")
+ if not normalized:
+ raise ValueError("A non-empty H3 chain run_name is required.")
+ if requested != normalized:
+ raise ValueError(
+ "H3 run_name must be 1-96 characters, start and end with a "
+ "letter or number, and contain only letters, numbers, '.', '_' "
+ "or '-'. Use %r for this Run." % normalized)
+ return normalized
+
+
+def _expand_filename_date(value: str, now: datetime | None = None) -> str:
+ """Expand ComfyUI-style date tokens before filename sanitization."""
+ current = now or datetime.now()
+ replacements = {
+ "yyyy": "%Y", "yy": "%y", "MM": "%m", "dd": "%d",
+ "HH": "%H", "hh": "%I", "mm": "%M", "ss": "%S",
+ }
+
+ def replace_date(match: re.Match[str]) -> str:
+ pattern = match.group(1)
+ strftime_pattern = re.sub(
+ r"yyyy|yy|MM|dd|HH|hh|mm|ss",
+ lambda token: replacements[token.group(0)],
+ pattern,
+ )
+ return current.strftime(strftime_pattern)
+
+ text = re.sub(r"%date:([^%]+)%", replace_date, str(value or ""))
+ simple_tokens = {
+ "%year%": "%Y", "%month%": "%m", "%day%": "%d",
+ "%hour%": "%H", "%minute%": "%M", "%second%": "%S",
+ }
+ for token, pattern in simple_tokens.items():
+ text = text.replace(token, current.strftime(pattern))
+ return text
+
+
+def _available_versioned_path(path: str) -> str:
+ """Return path unchanged when free, otherwise add a numeric version."""
+ if not os.path.exists(path):
+ return path
+ root, extension = os.path.splitext(path)
+ version = 1
+ while True:
+ candidate = "%s_%03d%s" % (root, version, extension)
+ if not os.path.exists(candidate):
+ return candidate
+ version += 1
+
+
+def _safe_output_subfolder(value: str) -> str:
+ """Return a safe, date-expanded subfolder relative to ComfyUI output."""
+ text = _expand_filename_date(value).strip().replace("\\", "/")
+ if not text or text == ".":
+ return ""
+ if text.startswith("/") or re.match(r"^[A-Za-z]:", text):
+ raise ValueError(
+ "H3 Chain Assemble output_subfolder must be relative to the "
+ "ComfyUI output folder.")
+ parts = []
+ for part in text.split("/"):
+ if not part or part == ".":
+ continue
+ if part == "..":
+ raise ValueError(
+ "H3 Chain Assemble output_subfolder cannot contain '..'.")
+ safe = _safe_name(part, "")
+ if not safe:
+ raise ValueError(
+ "H3 Chain Assemble output_subfolder contains an invalid "
+ "folder name.")
+ parts.append(safe)
+ return os.path.join(*parts) if parts else ""
+
+
+def _copy_final_to_output(final_path: str, output_subfolder: str) -> str:
+ """Copy a completed final beside regular ComfyUI outputs without overwrite."""
+ root = os.path.abspath(_output_root())
+ relative_folder = _safe_output_subfolder(output_subfolder)
+ destination_dir = os.path.abspath(os.path.join(root, relative_folder))
+ if os.path.commonpath([root, destination_dir]) != root:
+ raise ValueError(
+ "H3 Chain Assemble output_subfolder escapes the output folder.")
+ os.makedirs(destination_dir, exist_ok=True)
+ destination = _available_versioned_path(os.path.join(
+ destination_dir, os.path.basename(final_path)))
+ temporary = "%s.%s.tmp" % (destination, uuid.uuid4().hex)
+ try:
+ shutil.copy2(final_path, temporary)
+ os.replace(temporary, destination)
+ finally:
+ _safe_unlink(temporary)
+ return destination
+
+
+def _prompt_text(value: Any, label: str) -> str:
+ """Normalize a prompt string or a human-editable JSON array of lines."""
+ if isinstance(value, list):
+ if not all(isinstance(line, str) for line in value):
+ raise ValueError("%s line arrays may contain only strings." % label)
+ return "\n".join(value).strip()
+ return str(value or "").strip()
+
+
+def _resolve_dynamic_prompt(
+ value: str, seed: int, scope: str = ""
+) -> tuple[str, bool]:
+ """Resolve Comfy-style ``{one|two}`` groups deterministically.
+
+ Resolution is deliberately backend- and scene-local. Expanding the
+ complete Plan JSON through ComfyUI's frontend dynamic-prompt serializer
+ would rerandomize unrelated scenes and make checkpoint recovery depend on
+ queue timing. The same dedicated prompt seed, scope, and template always
+ produce the same effective prompt, independently of the sampler seed.
+
+ Nested groups and the escapes ``\\{``, ``\\}``, ``\\|``, and ``\\\\`` are
+ supported. Braces without an unescaped top-level pipe remain literal.
+ """
+ text = str(value or "")
+ seed = int(seed)
+ if seed < 0 or seed > MAX_SEED:
+ raise ValueError("Dynamic prompt seed is outside the uint64 range.")
+
+ group_number = 0
+ max_groups = 256
+ max_depth = 32
+
+ def escaped_character(source: str, position: int) -> tuple[str, int] | None:
+ if (source[position] == "\\" and position + 1 < len(source)
+ and source[position + 1] in "{}|\\"):
+ return source[position + 1], position + 2
+ return None
+
+ def matching_brace(source: str, opening: int) -> int | None:
+ depth = 0
+ position = opening
+ while position < len(source):
+ escaped = escaped_character(source, position)
+ if escaped is not None:
+ position = escaped[1]
+ continue
+ character = source[position]
+ if character == "{":
+ depth += 1
+ elif character == "}":
+ depth -= 1
+ if depth == 0:
+ return position
+ position += 1
+ return None
+
+ def alternatives(source: str) -> list[str] | None:
+ parts = []
+ start = 0
+ depth = 0
+ position = 0
+ while position < len(source):
+ escaped = escaped_character(source, position)
+ if escaped is not None:
+ position = escaped[1]
+ continue
+ character = source[position]
+ if character == "{":
+ depth += 1
+ elif character == "}" and depth:
+ depth -= 1
+ elif character == "|" and depth == 0:
+ parts.append(source[start:position])
+ start = position + 1
+ position += 1
+ if not parts:
+ return None
+ parts.append(source[start:])
+ return parts
+
+ def render(source: str, depth: int = 0) -> tuple[str, bool]:
+ nonlocal group_number
+ if depth > max_depth:
+ raise ValueError(
+ "Dynamic prompt nesting exceeds the maximum depth of %d." %
+ max_depth)
+ output = []
+ used = False
+ position = 0
+ while position < len(source):
+ escaped = escaped_character(source, position)
+ if escaped is not None:
+ output.append(escaped[0])
+ position = escaped[1]
+ continue
+ if source[position] != "{":
+ output.append(source[position])
+ position += 1
+ continue
+ closing = matching_brace(source, position)
+ if closing is None:
+ output.append(source[position])
+ position += 1
+ continue
+ body = source[position + 1:closing]
+ choices = alternatives(body)
+ if choices is None:
+ inner, inner_used = render(body, depth + 1)
+ output.extend(("{", inner, "}"))
+ used = used or inner_used
+ position = closing + 1
+ continue
+ if group_number >= max_groups:
+ raise ValueError(
+ "Dynamic prompt contains more than %d alternative groups."
+ % max_groups)
+ selector = hashlib.sha256(
+ ("%d\0%s\0%d\0%s" %
+ (seed, scope, group_number, body)).encode("utf-8")
+ ).digest()
+ group_number += 1
+ selected = choices[int.from_bytes(selector[:8], "big") % len(choices)]
+ rendered, _nested_used = render(selected, depth + 1)
+ output.append(rendered)
+ used = True
+ position = closing + 1
+ return "".join(output), used
+
+ return render(text)
+
+
+def _h3_frame_length(seconds: float) -> int:
+ """Round a duration up to H3's valid 17k+5 frame grid."""
+ seconds = float(seconds)
+ if not math.isfinite(seconds) or seconds <= 0:
+ raise ValueError("H3 shot duration must be a finite positive number.")
+ # Subtract a tiny tolerance so an exactly frame-aligned decimal does not
+ # jump a frame because of binary floating-point representation.
+ requested = max(5, int(math.ceil(seconds * FPS - 1e-9)))
+ length = requested + (5 - requested % 17) % 17
+ if length > MAX_H3_FRAMES:
+ raise ValueError(
+ "H3 shot duration %.6fs rounds to %d frames; the largest valid "
+ "17k+5 length is %d frames (%.6fs)." %
+ (seconds, length, MAX_H3_FRAMES, MAX_H3_FRAMES / float(FPS)))
+ return length
+
+
+def _validate_h3_length(length: Any, label: str) -> int:
+ length = int(length)
+ if length < 5 or length > MAX_H3_FRAMES or length % 17 != 5:
+ raise ValueError(
+ "%s must be an H3-valid frame length between 5 and %d "
+ "with length %% 17 == 5; got %d." %
+ (label, MAX_H3_FRAMES, length))
+ return length
+
+
+def _parse_scene_range(value: Any, total: int,
+ fallback_start: int) -> tuple[int, int]:
+ """Parse one inclusive, contiguous scene selection.
+
+ Disjoint selections are deliberately rejected: every H3 scene depends on
+ its immediate predecessor, so skipping a scene inside a render selection
+ would either break continuity or silently reuse an invalid checkpoint.
+ """
+ total = int(total)
+ fallback_start = int(fallback_start)
+ text = str(value or "").strip()
+ if not text:
+ start, end = fallback_start, total
+ else:
+ compact = re.sub(r"\s+", "", text)
+ if "," in compact:
+ raise ValueError(
+ "scene_range supports one contiguous inclusive range only, "
+ "such as '3' or '3:8'. Comma selections are not safe for a "
+ "seamless chain.")
+ match = re.fullmatch(r"(\d+)(?::(\d+))?", compact)
+ if match is None:
+ raise ValueError(
+ "scene_range must be blank, one scene like '3', or one "
+ "inclusive range like '3:8'.")
+ start = int(match.group(1))
+ end = int(match.group(2) or start)
+ if start < 1 or start > total:
+ raise ValueError("scene_range start must be between 1 and %d." % total)
+ if end < start:
+ raise ValueError("scene_range end must be greater than or equal to start.")
+ if end > total:
+ raise ValueError("scene_range end must be between %d and %d." %
+ (start, total))
+ return start, end
+
+
+_REFERENCE_TAG_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}")
+_REFERENCE_ALIAS_RE = re.compile(
+ r"(? list[dict[str, Any]]:
+ return [
+ {
+ "tag": match.group(1),
+ "timestamp_seconds": (
+ float(Fraction(match.group(2)))
+ if match.group(2) is not None else None),
+ }
+ for match in _SEMANTIC_ANCHOR_RE.finditer(str(text or ""))
+ ]
+
+
+def _semantic_anchor_mode(value: Any) -> str:
+ mode = str(value or "timestamped_video").strip().lower()
+ if mode not in SEMANTIC_ANCHOR_MODES:
+ raise ValueError(
+ "Semantic anchor mode must be timestamped_video or "
+ "picture_storyboard; got %r." % value)
+ return mode
+
+
+def _tagged_conditioning_backend(value: Any) -> str:
+ backend = str(value or "native_ref2va").strip().lower()
+ if backend not in TAGGED_CONDITIONING_BACKENDS:
+ raise ValueError(
+ "Tagged conditioning backend must be native_ref2va or "
+ "external_refmod; got %r." % value)
+ return backend
+
+
+def _prompt_reference_tags(text: Any) -> set[str]:
+ source = str(text or "")
+ return (
+ set(_REFERENCE_ALIAS_RE.findall(source)) |
+ {item["tag"] for item in _semantic_anchor_specs(source)}
+ )
+
+
+def _reference_compliance_mode(value: Any) -> str:
+ # Compatibility with the short-lived BOOLEAN form: True was strict and
+ # False was the warning-only behavior now named soft.
+ if isinstance(value, bool):
+ return "strict" if value else "soft"
+ mode = str(value or "strict").strip().lower()
+ if mode not in REFERENCE_COMPLIANCE_MODES:
+ raise ValueError(
+ "Reference prompt compliance must be strict, soft, or disabled; "
+ "got %r." % value)
+ return mode
+
+
+def _downstream_reference_compliance(
+ dynprompt: Any, unique_id: Any) -> str:
+ """Find the strictest Scheduled Ref2VA policy consuming this schedule.
+
+ Schedule-builder nodes execute before the wrapper. Looking downstream is
+ therefore the only way an upstream missing source_audio_slice can honor a
+ disabled policy without changing ComfyUI's execution semantics.
+ """
+ if dynprompt is None or unique_id is None:
+ return "strict"
+ try:
+ node_ids = list(dynprompt.all_node_ids())
+ except (AttributeError, TypeError):
+ return "strict"
+ queue = [unique_id]
+ visited = {str(unique_id)}
+ modes = []
+ while queue:
+ parent = queue.pop(0)
+ for node_id in node_ids:
+ if str(node_id) in visited:
+ continue
+ try:
+ node = dynprompt.get_node(node_id)
+ except Exception:
+ continue
+ inputs = node.get("inputs") if isinstance(node, dict) else None
+ if not isinstance(inputs, dict) or not any(
+ isinstance(value, list) and len(value) == 2
+ and str(value[0]) == str(parent)
+ for value in inputs.values()):
+ continue
+ visited.add(str(node_id))
+ if node.get("class_type") in (
+ "MiniMaxH3ScheduledReferenceToVideo",
+ "MiniMaxH3TaggedReferenceToVideo"):
+ try:
+ modes.append(_reference_compliance_mode(
+ inputs.get(
+ "prompt_compliance",
+ inputs.get("reference_policy", "strict"))))
+ except ValueError:
+ modes.append("strict")
+ else:
+ queue.append(node_id)
+ if not modes or "strict" in modes:
+ return "strict"
+ return "soft" if "soft" in modes else "disabled"
+
+
+def _has_downstream_review_gate(dynprompt: Any, unique_id: Any) -> bool:
+ """Return whether this Segment Save directly feeds an H3 Review Gate.
+
+ Segment Save persists the same video that Review Gate presents. Sending
+ an additional native video-preview payload from the saver duplicates that
+ media in the canvas, has no audio, and can force the saver node to expand
+ to the preview's dimensions. Dynamic prompt IDs are intentionally
+ inspected here so recursive chain iterations behave like the visible
+ graph as well as the initial execution.
+ """
+ if dynprompt is None or unique_id is None:
+ return False
+ try:
+ node_ids = list(dynprompt.all_node_ids())
+ except (AttributeError, TypeError):
+ try:
+ original = dynprompt.get_original_prompt()
+ node_ids = list(original) if isinstance(original, dict) else []
+ except (AttributeError, TypeError):
+ return False
+ for node_id in node_ids:
+ try:
+ node = dynprompt.get_node(node_id)
+ except Exception:
+ continue
+ if (not isinstance(node, dict)
+ or node.get("class_type") != "MiniMaxH3ChainReview"):
+ continue
+ inputs = node.get("inputs")
+ link = inputs.get("segment") if isinstance(inputs, dict) else None
+ if (is_link(link) and str(link[0]) == str(unique_id)
+ and str(link[1]) == "0"):
+ return True
+ return False
+
+
+def _skipped_reference_result(
+ previous: Any, label: str, reason: Any) -> tuple[Any, str, str]:
+ try:
+ schedule = _make_reference_schedule(
+ _reference_schedule_entries(previous))
+ except (TypeError, ValueError):
+ schedule = _make_reference_schedule([])
+ message = "%s skipped because compliance is disabled: %s" % (
+ label, str(reason))
+ _LOG.warning("H3 scheduled-reference warning: %s", message)
+ return schedule, _reference_fingerprint_output(schedule), message
+
+
+def _skipped_tagged_reference_result(
+ previous: Any, label: str, reason: Any) -> tuple[Any, str, str]:
+ try:
+ references = _make_tagged_references(
+ _tagged_reference_entries(previous))
+ except (TypeError, ValueError):
+ references = _make_tagged_references([])
+ message = "%s skipped because reference policy is disabled: %s" % (
+ label, str(reason))
+ _LOG.warning("H3 tagged-reference warning: %s", message)
+ return references, _reference_fingerprint_output(references), message
+
+
+def _normalize_reference_tag(value: Any, label: str) -> str:
+ tag = str(value or "").strip()
+ if tag.startswith("@"):
+ tag = tag[1:]
+ if _REFERENCE_TAG_RE.fullmatch(tag) is None:
+ raise ValueError(
+ "%s must be a stable tag such as 'hero_face' or '@hero-face'." %
+ label)
+ return tag
+
+
+def _resolved_media_path(value: Any, label: str) -> str:
+ path = os.path.realpath(os.path.abspath(os.path.expanduser(
+ str(value or "").strip())))
+ if not str(value or "").strip():
+ raise ValueError("%s cannot be blank." % label)
+ if not os.path.isfile(path):
+ raise ValueError("%s does not exist or is not a file: %s" %
+ (label, path))
+ return path
+
+
+def _lazy_motion_media_path(
+ video_path: Any, source_video: Any = None) -> tuple[str, str]:
+ if source_video is not None:
+ get_stream_source = getattr(source_video, "get_stream_source", None)
+ if not callable(get_stream_source):
+ raise ValueError(
+ "Lazy motion source_video must be a native ComfyUI VIDEO "
+ "from a file-backed loader.")
+ try:
+ stream_source = get_stream_source()
+ except Exception as exc:
+ raise ValueError(
+ "Lazy motion source_video could not expose its file source: "
+ "%s" % exc) from exc
+ if not isinstance(stream_source, (str, os.PathLike)):
+ raise ValueError(
+ "Lazy motion source_video is memory-backed. Connect core "
+ "Load Video or provide video_path so scene decoding can "
+ "remain disk-backed.")
+ get_trim = getattr(source_video, "get_active_trim_window", None)
+ if callable(get_trim):
+ try:
+ trim_start, trim_duration = get_trim()
+ except Exception as exc:
+ raise ValueError(
+ "Lazy motion source_video trim could not be inspected: "
+ "%s" % exc) from exc
+ if (abs(float(trim_start)) > 1e-9 or
+ abs(float(trim_duration)) > 1e-9):
+ raise ValueError(
+ "Lazy motion source_video must be untrimmed. Use "
+ "skip_first_frames on Tagged Motion Ref for an exact, "
+ "fingerprinted timeline offset.")
+ return (_resolved_media_path(
+ os.fspath(stream_source), "Lazy motion native VIDEO source"),
+ "native VIDEO loader")
+ return (_resolved_media_path(
+ video_path, "Motion-reference video path"), "direct path")
+
+
+def _stream_start_seconds(stream: Any) -> float:
+ if stream.start_time is None or stream.time_base is None:
+ return 0.0
+ return float(stream.start_time * stream.time_base)
+
+
+def _stream_duration_seconds(
+ stream: Any, container: Any, fallback_frame_count: int,
+ fallback_fps: float) -> float:
+ """Prefer media timestamps over a container's advisory frame count."""
+ if stream.duration is not None and stream.time_base is not None:
+ duration = float(stream.duration * stream.time_base)
+ if math.isfinite(duration) and duration > 0.0:
+ return duration
+ if container.duration is not None:
+ duration = float(container.duration) / float(av.time_base)
+ if math.isfinite(duration) and duration > 0.0:
+ return duration
+ if int(fallback_frame_count) > 0 and float(fallback_fps) > 0.0:
+ return int(fallback_frame_count) / float(fallback_fps)
+ return 0.0
+
+
+def _probe_lazy_motion_path(
+ path: str, use_embedded_audio: bool) -> dict[str, Any]:
+ if av is None:
+ raise RuntimeError("Lazy H3 motion references require PyAV.")
+ resolved = _resolved_media_path(path, "Motion-reference video path")
+ try:
+ with av.open(resolved, mode="r") as container:
+ videos = list(container.streams.video)
+ if len(videos) != 1:
+ raise ValueError(
+ "Lazy motion reference %s contains %d video streams; "
+ "exactly one is required." % (resolved, len(videos)))
+ video = videos[0]
+ rate = video.average_rate or video.base_rate or video.guessed_rate
+ if rate is None or not math.isfinite(float(rate)):
+ raise ValueError(
+ "Lazy motion reference has no usable frame rate.")
+ source_rate = Fraction(rate)
+ source_fps = float(source_rate)
+ if source_fps <= 0.0:
+ raise ValueError(
+ "Lazy motion reference has an invalid frame rate %.6g."
+ % source_fps)
+ width = int(video.codec_context.width or 0)
+ height = int(video.codec_context.height or 0)
+ if width < 1 or height < 1:
+ raise ValueError(
+ "Lazy motion reference has invalid video dimensions.")
+ source_frame_count = int(video.frames or 0)
+ duration_seconds = _stream_duration_seconds(
+ video, container, source_frame_count, source_fps)
+ frame_count = (int(math.ceil(
+ duration_seconds * FPS - 1e-9))
+ if duration_seconds > 0.0 else 0)
+
+ audios = list(container.streams.audio)
+ audio_info = None
+ if bool(use_embedded_audio):
+ if len(audios) != 1:
+ raise ValueError(
+ "Lazy motion reference requested embedded audio, but "
+ "%s contains %d audio streams; exactly one is "
+ "required." % (resolved, len(audios)))
+ audio = audios[0]
+ sample_rate = int(
+ audio.codec_context.sample_rate or audio.rate or 0)
+ channels = len(audio.codec_context.layout.channels)
+ if sample_rate < 1 or channels not in (1, 2):
+ raise ValueError(
+ "Lazy motion-reference audio must be mono or stereo "
+ "with a valid sample rate.")
+ audio_info = {
+ "sample_rate": sample_rate,
+ "channels": channels,
+ "start_seconds": _stream_start_seconds(audio),
+ }
+ return {
+ "version": LAZY_MOTION_SOURCE_VERSION,
+ "kind": "lazy_motion_path",
+ "path": resolved,
+ "fps": FPS,
+ "source_fps": source_fps,
+ "source_rate_numerator": int(source_rate.numerator),
+ "source_rate_denominator": int(source_rate.denominator),
+ "width": width,
+ "height": height,
+ "frame_count": frame_count,
+ "source_frame_count": source_frame_count,
+ "duration_seconds": duration_seconds,
+ "video_start_seconds": _stream_start_seconds(video),
+ "audio": audio_info,
+ }
+ except ValueError:
+ raise
+ except Exception as exc:
+ raise ValueError(
+ "Could not inspect lazy motion-reference video %s: %s" %
+ (resolved, exc)) from exc
+
+
+def _probe_audio_path(
+ path: str, label: str = "Source Timeline audio path",
+ required: bool = True) -> dict[str, Any] | None:
+ """Inspect one path-backed audio stream without decoding its samples."""
+ if av is None:
+ raise RuntimeError("H3 Source Timeline audio paths require PyAV.")
+ resolved = _resolved_media_path(path, label)
+ try:
+ with av.open(resolved, mode="r") as container:
+ audios = list(container.streams.audio)
+ if not audios and not bool(required):
+ return None
+ if len(audios) != 1:
+ raise ValueError(
+ "%s contains %d audio streams; exactly one is required."
+ % (label, len(audios)))
+ audio = audios[0]
+ sample_rate = int(
+ audio.codec_context.sample_rate or audio.rate or 0)
+ channels = len(audio.codec_context.layout.channels)
+ if sample_rate < 1 or channels not in (1, 2):
+ raise ValueError(
+ "%s must be mono or stereo with a valid sample rate."
+ % label)
+ duration_seconds = _stream_duration_seconds(
+ audio, container, 0, float(sample_rate))
+ return {
+ "path": resolved,
+ "stream_index": int(audio.index),
+ "sample_rate": sample_rate,
+ "channels": channels,
+ "start_seconds": _stream_start_seconds(audio),
+ "duration_seconds": duration_seconds,
+ }
+ except ValueError:
+ raise
+ except Exception as exc:
+ raise ValueError(
+ "Could not inspect %s %s: %s" % (label, resolved, exc)) from exc
+
+
+def _lazy_motion_descriptor_with_skip(
+ descriptor: dict[str, Any], skip_first_frames: Any) -> dict[str, Any]:
+ prepared = dict(descriptor)
+ skip_first_frames = int(skip_first_frames)
+ if skip_first_frames < 0:
+ raise ValueError(
+ "Lazy motion skip_first_frames cannot be negative.")
+ source_frames = int(prepared.get("source_frame_count", 0))
+ if source_frames > 0 and skip_first_frames >= source_frames:
+ raise ValueError(
+ "Lazy motion skip_first_frames %d consumes all %d source "
+ "frames." % (skip_first_frames, source_frames))
+ source_fps = float(prepared["source_fps"])
+ duration_seconds = float(prepared.get("duration_seconds", 0.0))
+ skip_seconds = skip_first_frames / source_fps
+ if duration_seconds > 0.0 and skip_seconds >= duration_seconds:
+ raise ValueError(
+ "Lazy motion skip_first_frames %d starts at or beyond the "
+ "%.3fs source duration." %
+ (skip_first_frames, duration_seconds))
+ prepared["skip_first_frames"] = skip_first_frames
+ prepared["skip_seconds"] = skip_seconds
+ if duration_seconds > 0.0:
+ prepared["frame_count"] = int(math.ceil(
+ (duration_seconds - skip_seconds) * FPS - 1e-9))
+ return prepared
+
+
+def _is_lazy_motion_descriptor(value: Any) -> bool:
+ if not isinstance(value, dict):
+ return False
+ try:
+ version = int(value.get("version", -1))
+ except (TypeError, ValueError):
+ return False
+ return (version == LAZY_MOTION_SOURCE_VERSION
+ and value.get("kind") == "lazy_motion_path"
+ and isinstance(value.get("path"), str))
+
+
+PROJECT_ASSET_DESCRIPTOR_VERSION = 1
+
+
+def _project_asset_descriptor(entry: dict[str, Any], path: str) -> dict[str, Any]:
+ """Small runtime handle; media bytes never enter workflow serialization."""
+ return {
+ "version": PROJECT_ASSET_DESCRIPTOR_VERSION,
+ "kind": "project_asset_path",
+ "path": os.path.realpath(path),
+ "asset_id": str(entry.get("id") or ""),
+ "media_kind": str(entry.get("kind") or ""),
+ "content_hash": str(entry.get("sha256") or ""),
+ }
+
+
+def _is_project_asset_descriptor(value: Any, media_kind: str = "") -> bool:
+ if not isinstance(value, dict):
+ return False
+ try:
+ version = int(value.get("version", -1))
+ except (TypeError, ValueError):
+ return False
+ return (version == PROJECT_ASSET_DESCRIPTOR_VERSION
+ and value.get("kind") == "project_asset_path"
+ and isinstance(value.get("path"), str)
+ and (not media_kind or value.get("media_kind") == media_kind))
+
+
+def _project_asset_media_path(value: Any, media_kind: str) -> str:
+ if not _is_project_asset_descriptor(value, media_kind):
+ raise ValueError("Invalid H3 project %s asset descriptor." % media_kind)
+ return _resolved_media_path(
+ value["path"], "H3 project %s asset" % media_kind)
+
+
+def _project_asset_image(value: Any) -> Any:
+ if not _is_project_asset_descriptor(value, "image"):
+ return value
+ if Image is None or ImageOps is None or np is None or torch is None:
+ raise RuntimeError(
+ "H3 project picture references require Pillow, NumPy, and torch.")
+ path = _project_asset_media_path(value, "image")
+ with Image.open(path) as source:
+ image = ImageOps.exif_transpose(source).convert("RGB")
+ array = np.asarray(image, dtype=np.float32) / 255.0
+ return torch.from_numpy(array.copy()).unsqueeze(0)
+
+
+def _project_asset_audio(value: Any) -> Any:
+ if not _is_project_asset_descriptor(value, "audio"):
+ return value
+ path = _project_asset_media_path(value, "audio")
+ timeline = _make_source_timeline(
+ audio_path=path, embedded_audio="ignore",
+ source_route="H3 project asset")
+ return _source_timeline_source_audio(timeline)
+
+
+def _project_asset_video_descriptor(
+ entry: dict[str, Any], path: str, *, embedded_audio: bool,
+ short_edge: str) -> dict[str, Any]:
+ metadata = entry.get("metadata")
+ metadata = metadata if isinstance(metadata, dict) else {}
+ source_fps = float(metadata.get("fps") or FPS)
+ numerator = int(metadata.get("source_rate_numerator") or round(source_fps))
+ denominator = int(metadata.get("source_rate_denominator") or 1)
+ duration = float(metadata.get("duration_seconds") or 0.0)
+ descriptor = {
+ "version": LAZY_MOTION_SOURCE_VERSION,
+ "kind": "lazy_motion_path",
+ "path": os.path.realpath(path),
+ "fps": FPS,
+ "source_fps": source_fps,
+ "source_rate_numerator": numerator,
+ "source_rate_denominator": denominator,
+ "width": int(metadata.get("width") or 0),
+ "height": int(metadata.get("height") or 0),
+ "frame_count": int(metadata.get("frame_count") or (
+ math.ceil(duration * FPS - 1e-9) if duration > 0 else 0)),
+ "source_frame_count": int(metadata.get("frame_count") or 0),
+ "duration_seconds": duration,
+ "video_start_seconds": float(metadata.get("video_start_seconds") or 0),
+ "skip_first_frames": 0,
+ "skip_seconds": 0.0,
+ "motion_short_edge": str(short_edge),
+ "file_sha256": str(entry.get("sha256") or ""),
+ "source_route": "H3 project asset",
+ "audio": None,
+ }
+ if (descriptor["width"] < 1 or descriptor["height"] < 1
+ or descriptor["source_fps"] <= 0):
+ descriptor = _probe_lazy_motion_path(path, bool(embedded_audio))
+ descriptor["motion_short_edge"] = str(short_edge)
+ descriptor["file_sha256"] = str(entry.get("sha256") or "")
+ descriptor["source_route"] = "H3 project asset"
+ return descriptor
+ if embedded_audio:
+ sample_rate = int(metadata.get("audio_sample_rate") or 0)
+ channels = int(metadata.get("audio_channels") or 0)
+ if sample_rate < 1 or channels not in (1, 2):
+ descriptor = _probe_lazy_motion_path(path, True)
+ descriptor["motion_short_edge"] = str(short_edge)
+ descriptor["file_sha256"] = str(entry.get("sha256") or "")
+ descriptor["source_route"] = "H3 project asset"
+ return descriptor
+ descriptor["audio"] = {
+ "sample_rate": sample_rate,
+ "channels": channels,
+ "start_seconds": float(metadata.get("audio_start_seconds") or 0),
+ }
+ return descriptor
+
+
+def _parse_reference_selector(
+ value: Any, total: int | None = None) -> tuple[tuple[int, int], ...]:
+ """Parse a disjoint, one-based scene selector and merge overlaps."""
+ text = re.sub(r"\s+", "", str(value or "")).lower()
+ if text in ("", "*", "all"):
+ return ()
+ ranges = []
+ for token in text.split(","):
+ match = re.fullmatch(r"(\d+)(?::(\d+))?", token)
+ if match is None:
+ raise ValueError(
+ "Reference scenes must be blank/all, one scene like '3', "
+ "or comma-separated inclusive ranges like '1,3,5:8'.")
+ start = int(match.group(1))
+ end = int(match.group(2) or start)
+ if start < 1:
+ raise ValueError("Reference scene numbers start at 1.")
+ if end < start:
+ raise ValueError(
+ "Reference range %s ends before it starts." % token)
+ if total is not None and end > int(total):
+ raise ValueError(
+ "Reference range %s exceeds this plan's %d scenes." %
+ (token, int(total)))
+ ranges.append((start, end))
+ merged = []
+ for start, end in sorted(ranges):
+ if merged and start <= merged[-1][1] + 1:
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
+ else:
+ merged.append((start, end))
+ return tuple(merged)
+
+
+def _reference_selector_text(ranges: Any) -> str:
+ if not ranges:
+ return "all"
+ return ",".join(
+ str(start) if int(start) == int(end) else "%d:%d" % (start, end)
+ for start, end in ranges)
+
+
+def _reference_is_active(entry: dict[str, Any], scene: int) -> bool:
+ ranges = entry.get("ranges") or ()
+ return not ranges or any(
+ int(start) <= int(scene) <= int(end) for start, end in ranges)
+
+
+def _reference_entry_contract(entry: dict[str, Any]) -> dict[str, Any]:
+ keys = (
+ "kind", "tag", "scenes", "content_hash", "audio_tag",
+ "audio_hash", "paired_audio_policy", "semantic_role", "motion_target",
+ "motion_description", "motion_short_edge", "semantic_anchor_size",
+ "semantic_anchor_mode",
+ )
+ contract = {key: entry[key] for key in keys if key in entry}
+ # Keep old schedules bit-identical. Timeline metadata enters the resume
+ # contract only when a reference opts into non-default playback.
+ default_timeline = (
+ "standalone" if entry.get("kind") == "audio"
+ else "restart_each_scene")
+ timeline_mode = str(entry.get("timeline_mode") or default_timeline)
+ if timeline_mode != default_timeline:
+ contract["timeline_mode"] = timeline_mode
+ if bool(entry.get("align_audio_reference")):
+ contract["align_audio_reference"] = True
+ if str(entry.get("activation") or "schedule") != "schedule":
+ contract["activation"] = str(entry["activation"])
+ return contract
+
+
+def _reference_schedule_entries(value: Any) -> list[dict[str, Any]]:
+ if value is None:
+ return []
+ if (not isinstance(value, dict) or
+ int(value.get("version", -1)) != REFERENCE_SCHEDULE_VERSION or
+ not isinstance(value.get("entries"), list)):
+ raise ValueError(
+ "Scheduled references must come from this pack's Picture, Video, "
+ "or Audio Schedule nodes.")
+ return list(value["entries"])
+
+
+def _reference_entry_tags(entry: dict[str, Any]) -> tuple[str, ...]:
+ tags = [str(entry["tag"])]
+ if entry.get("audio_tag"):
+ tags.append(str(entry["audio_tag"]))
+ return tuple(tags)
+
+
+def _make_reference_schedule(
+ entries: list[dict[str, Any]]) -> dict[str, Any]:
+ contracts = [_reference_entry_contract(entry) for entry in entries]
+ return {
+ "version": REFERENCE_SCHEDULE_VERSION,
+ "entries": entries,
+ "fingerprint": _fingerprint({
+ "version": REFERENCE_SCHEDULE_VERSION,
+ "entries": contracts,
+ }),
+ }
+
+
+def _reference_fingerprint_lineage(value: Any) -> dict[str, Any]:
+ """Return JSON-safe history and contracts for one reference registry."""
+ entries = _reference_schedule_entries(value)
+ contracts = [_reference_entry_contract(entry) for entry in entries]
+ registry_mode = (
+ "tagged" if isinstance(value, dict)
+ and value.get("activation") == "prompt" else "ordered")
+ prefixes = [{
+ "fingerprint": _reference_contracts_fingerprint(
+ [], registry_mode=registry_mode),
+ "entry": None,
+ }]
+ for index, entry in enumerate(contracts, start=1):
+ prefixes.append({
+ "fingerprint": _reference_contracts_fingerprint(
+ contracts[:index], registry_mode=registry_mode),
+ "entry": entry,
+ })
+ current = str(value.get("fingerprint") or "")
+ if not current or prefixes[-1]["fingerprint"] != current:
+ raise ValueError("H3 reference registry fingerprint is inconsistent.")
+ return {
+ "version": REFERENCE_FINGERPRINT_LINEAGE_VERSION,
+ "current": current,
+ "prefixes": prefixes,
+ "registry_mode": registry_mode,
+ # Full contracts let resume recognize a previously saved ordered
+ # registry when newly added, inactive nodes were inserted anywhere in
+ # the ComfyUI chain rather than only attached at its output end.
+ "entries": contracts,
+ }
+
+
+def _reference_fingerprint_output(
+ value: Any, *, wrapper_key: str = "",
+ wrapper_contract: dict[str, Any] | None = None) -> str:
+ """Encode the fingerprint plus safe append ancestry in a STRING socket."""
+ lineage = _reference_fingerprint_lineage(value)
+ if wrapper_key:
+ extras = dict(wrapper_contract or {})
+ prefixes = []
+ for item in lineage["prefixes"]:
+ fingerprint = _fingerprint({
+ wrapper_key: item["fingerprint"], **extras,
+ })
+ prefixes.append({
+ "fingerprint": fingerprint,
+ "entry": item["entry"],
+ })
+ lineage = {
+ "version": REFERENCE_FINGERPRINT_LINEAGE_VERSION,
+ "current": prefixes[-1]["fingerprint"],
+ "prefixes": prefixes,
+ "registry_mode": lineage["registry_mode"],
+ "entries": lineage["entries"],
+ "wrapper": {
+ "key": wrapper_key,
+ "contract": extras,
+ },
+ }
+ return _canonical_json({
+ "h3_reference_fingerprint_lineage": lineage,
+ })
+
+
+def _reference_contracts_fingerprint(
+ entries: list[dict[str, Any]],
+ wrapper: dict[str, Any] | None = None,
+ registry_mode: str = "ordered") -> str:
+ contracts = list(entries)
+ if str(registry_mode) == "tagged":
+ contracts = sorted(contracts, key=lambda entry: (
+ str(entry.get("kind") or ""),
+ str(entry.get("tag") or ""),
+ str(entry.get("audio_tag") or ""),
+ _canonical_json(entry),
+ ))
+ fingerprint = _fingerprint({
+ "version": REFERENCE_SCHEDULE_VERSION,
+ "entries": contracts,
+ })
+ if isinstance(wrapper, dict):
+ fingerprint = _fingerprint({
+ str(wrapper["key"]): fingerprint,
+ **dict(wrapper.get("contract") or {}),
+ })
+ return fingerprint
+
+
+def _generation_fingerprint_value(
+ value: Any) -> tuple[str, dict[str, Any] | None]:
+ """Decode a reference-lineage token or preserve a manual fingerprint."""
+ text = str(value or "").strip()
+ if not text.startswith("{"):
+ return text, None
+ try:
+ document = json.loads(text)
+ except json.JSONDecodeError:
+ return text, None
+ lineage = (document.get("h3_reference_fingerprint_lineage")
+ if isinstance(document, dict) else None)
+ if not isinstance(lineage, dict):
+ return text, None
+ if int(lineage.get("version", -1)) != (
+ REFERENCE_FINGERPRINT_LINEAGE_VERSION):
+ raise ValueError("Unsupported H3 reference fingerprint lineage version.")
+ current = str(lineage.get("current") or "")
+ prefixes = lineage.get("prefixes")
+ if (not re.fullmatch(r"[0-9a-f]{64}", current)
+ or not isinstance(prefixes, list) or not prefixes
+ or not isinstance(prefixes[-1], dict)
+ or str(prefixes[-1].get("fingerprint") or "") != current):
+ raise ValueError("H3 reference fingerprint lineage is malformed.")
+ normalized = []
+ for index, item in enumerate(prefixes):
+ if not isinstance(item, dict):
+ raise ValueError("H3 reference fingerprint lineage is malformed.")
+ fingerprint = str(item.get("fingerprint") or "")
+ entry = item.get("entry")
+ if not re.fullmatch(r"[0-9a-f]{64}", fingerprint):
+ raise ValueError("H3 reference fingerprint lineage is malformed.")
+ if index == 0:
+ if entry is not None:
+ raise ValueError(
+ "H3 reference fingerprint lineage base must be empty.")
+ elif not isinstance(entry, dict):
+ raise ValueError("H3 reference fingerprint lineage is malformed.")
+ normalized.append({"fingerprint": fingerprint, "entry": entry})
+ entries = lineage.get("entries")
+ if entries is not None and (
+ not isinstance(entries, list)
+ or any(not isinstance(entry, dict) for entry in entries)):
+ raise ValueError("H3 reference fingerprint lineage is malformed.")
+ normalized_lineage = {
+ "version": REFERENCE_FINGERPRINT_LINEAGE_VERSION,
+ "current": current,
+ "prefixes": normalized,
+ }
+ registry_mode = str(lineage.get("registry_mode") or "ordered")
+ if registry_mode not in ("ordered", "tagged"):
+ raise ValueError("H3 reference fingerprint lineage is malformed.")
+ normalized_lineage["registry_mode"] = registry_mode
+ if entries is not None:
+ normalized_lineage["entries"] = _json_document(entries)
+ wrapper = lineage.get("wrapper")
+ if wrapper is not None:
+ if (not isinstance(wrapper, dict)
+ or not isinstance(wrapper.get("key"), str)
+ or not wrapper["key"]
+ or not isinstance(wrapper.get("contract"), dict)):
+ raise ValueError("H3 reference fingerprint lineage is malformed.")
+ normalized_lineage["wrapper"] = _json_document(wrapper)
+ if entries is not None:
+ if (_reference_contracts_fingerprint(
+ normalized_lineage["entries"],
+ normalized_lineage.get("wrapper"), registry_mode) != current):
+ raise ValueError(
+ "H3 reference fingerprint lineage does not match its entries.")
+ for index, item in enumerate(normalized):
+ if (_reference_contracts_fingerprint(
+ normalized_lineage["entries"][:index],
+ normalized_lineage.get("wrapper"), registry_mode) !=
+ item["fingerprint"]):
+ raise ValueError(
+ "H3 reference fingerprint lineage has invalid history.")
+ return current, normalized_lineage
+
+
+def _tagged_reference_entries(value: Any) -> list[dict[str, Any]]:
+ entries = _reference_schedule_entries(value)
+ if not isinstance(value, dict) or value.get("activation") != "prompt":
+ raise ValueError(
+ "Tagged references must come from this pack's Tagged Picture, "
+ "Video, or Audio Ref nodes.")
+ if any(entry.get("activation") != "prompt" for entry in entries):
+ raise ValueError(
+ "A tagged-reference chain cannot contain legacy scheduled "
+ "entries. Use one node family or the other.")
+ return entries
+
+
+def _make_tagged_references(
+ entries: list[dict[str, Any]]) -> dict[str, Any]:
+ result = _make_reference_schedule(entries)
+ result["activation"] = "prompt"
+ result["fingerprint"] = _reference_contracts_fingerprint(
+ [_reference_entry_contract(entry) for entry in entries],
+ registry_mode="tagged")
+ return result
+
+
+def _semantic_anchor_draft_entries(value: Any) -> list[dict[str, Any]]:
+ if (not isinstance(value, dict)
+ or int(value.get("version", -1)) !=
+ SEMANTIC_ANCHOR_REGISTRY_VERSION
+ or value.get("kind") not in ("draft", "bundle")
+ or not isinstance(value.get("entries"), list)):
+ raise ValueError(
+ "Semantic anchors must come from this pack's Semantic Picture "
+ "Anchor nodes.")
+ entries = list(value["entries"])
+ for entry in entries:
+ if (not isinstance(entry, dict)
+ or entry.get("kind") != "semantic_anchor"
+ or not isinstance(entry.get("tag"), str)
+ or not entry.get("tag")
+ or "value" not in entry
+ or not isinstance(entry.get("content_hash"), str)):
+ raise ValueError("Semantic anchor registry is malformed.")
+ return entries
+
+
+def _make_semantic_anchor_draft(
+ entries: list[dict[str, Any]]) -> dict[str, Any]:
+ contracts = [_reference_entry_contract(entry) for entry in entries]
+ return {
+ "version": SEMANTIC_ANCHOR_REGISTRY_VERSION,
+ "kind": "draft",
+ "entries": entries,
+ "fingerprint": _reference_contracts_fingerprint(
+ contracts, registry_mode="tagged"),
+ }
+
+
+def _semantic_anchor_bundle(value: Any) -> dict[str, Any]:
+ entries = _semantic_anchor_draft_entries(value)
+ if value.get("kind") != "bundle":
+ raise ValueError(
+ "Connect Semantic Picture Anchors through Semantic Anchor Bundle "
+ "before using them in Ref2VA, Plan Studio, or Run Manager.")
+ size = str(value.get("semantic_anchor_size") or "512")
+ mode = str(value.get("semantic_anchor_mode") or
+ "timestamped_video").strip().lower()
+ if size not in SEMANTIC_ANCHOR_BUNDLE_SIZES:
+ raise ValueError(
+ "Semantic anchor size must be one of %s." %
+ (SEMANTIC_ANCHOR_BUNDLE_SIZES,))
+ if mode not in SEMANTIC_ANCHOR_BUNDLE_MODES:
+ raise ValueError(
+ "Semantic anchor mode must be one of %s." %
+ (SEMANTIC_ANCHOR_BUNDLE_MODES,))
+ expected = _make_semantic_anchor_bundle(entries, size, mode)
+ if str(value.get("fingerprint") or "") != expected["fingerprint"]:
+ raise ValueError("Semantic anchor bundle fingerprint is inconsistent.")
+ return value
+
+
+def _make_semantic_anchor_bundle(
+ entries: list[dict[str, Any]], size: Any,
+ mode: Any) -> dict[str, Any]:
+ size = str(size or "512")
+ mode = str(mode or "timestamped_video").strip().lower()
+ if size not in SEMANTIC_ANCHOR_BUNDLE_SIZES:
+ raise ValueError(
+ "Semantic anchor size must be one of %s." %
+ (SEMANTIC_ANCHOR_BUNDLE_SIZES,))
+ if mode not in SEMANTIC_ANCHOR_BUNDLE_MODES:
+ raise ValueError(
+ "Semantic anchor mode must be one of %s." %
+ (SEMANTIC_ANCHOR_BUNDLE_MODES,))
+ bundled_entries = []
+ for source in entries:
+ entry = dict(source)
+ entry["semantic_anchor_size"] = size
+ entry["semantic_anchor_mode"] = mode
+ bundled_entries.append(entry)
+ contracts = [_reference_entry_contract(entry) for entry in bundled_entries]
+ return {
+ "version": SEMANTIC_ANCHOR_REGISTRY_VERSION,
+ "kind": "bundle",
+ "entries": bundled_entries,
+ "semantic_anchor_size": size,
+ "semantic_anchor_mode": mode,
+ "fingerprint": _reference_contracts_fingerprint(
+ contracts, registry_mode="tagged"),
+ }
+
+
+def _reference_semantic_anchor_bundle(
+ references: Any) -> dict[str, Any] | None:
+ """Resolve Qwen anchors embedded in the tagged reference carrier."""
+ carried = (
+ references.get("semantic_anchors")
+ if isinstance(references, dict) else None)
+ return _semantic_anchor_bundle(carried) if carried is not None else None
+
+
+def _combined_reference_registry(
+ references: Any, semantic_anchors: Any = None) -> dict[str, Any]:
+ """Build one lineage token while keeping native and Qwen media separate."""
+ entries = _tagged_reference_entries(references)
+ bundle = (
+ _semantic_anchor_bundle(semantic_anchors)
+ if semantic_anchors is not None
+ else _reference_semantic_anchor_bundle(references))
+ if bundle is not None:
+ entries = entries + _semantic_anchor_draft_entries(bundle)
+ return _make_tagged_references(entries)
+
+
+def _plan_studio_generation_fingerprint(
+ generation_fingerprint: Any, tagged_references: Any) -> str:
+ """Compose Studio's external contract with its connected references."""
+ external = str(generation_fingerprint or "").strip()
+ if tagged_references is None:
+ return external
+
+ combined = _combined_reference_registry(tagged_references)
+ if not combined["entries"]:
+ # Merely connecting an empty reference line must not change an older
+ # standalone Plan's compatibility contract.
+ return external
+
+ reference_token = _reference_fingerprint_output(combined)
+ external_current, external_lineage = _generation_fingerprint_value(
+ external)
+ if external_lineage is not None and (
+ external_current == combined["fingerprint"]):
+ # Existing workflows may still wire the reference STRING output into
+ # the legacy field. Avoid wrapping the same lineage a second time.
+ return reference_token
+ if not external:
+ return reference_token
+ return _reference_fingerprint_output(
+ combined,
+ wrapper_key="tagged_reference_fingerprint",
+ wrapper_contract={
+ "external_generation_fingerprint": external,
+ })
+
+
+def _append_scheduled_reference(
+ previous: Any, *, kind: str, tag: Any, scenes: Any,
+ value: Any, content_hash: str, audio: Any = None,
+ audio_tag: Any = "", audio_hash: str = "",
+ compliance_mode: str = "strict",
+ timeline_mode: Any = None,
+ align_audio_reference: Any = False,
+ paired_audio_policy: Any = None) -> dict[str, Any]:
+ compliance = _reference_compliance_mode(compliance_mode)
+ entries = _reference_schedule_entries(previous)
+ try:
+ normalized_tag = _normalize_reference_tag(tag, "Reference tag")
+ except ValueError as exc:
+ if compliance != "disabled":
+ raise
+ normalized_tag = "reference_%s" % str(content_hash)[:12]
+ _LOG.warning(
+ "H3 scheduled-reference warning: %s Using internal tag @%s.",
+ exc, normalized_tag)
+ try:
+ ranges = _parse_reference_selector(scenes)
+ except ValueError as exc:
+ if compliance != "disabled":
+ raise
+ ranges = ()
+ _LOG.warning(
+ "H3 scheduled-reference warning: %s Treating the reference as "
+ "active in every scene.", exc)
+ entry = {
+ "kind": str(kind),
+ "tag": normalized_tag,
+ "scenes": _reference_selector_text(ranges),
+ "ranges": ranges,
+ "value": value,
+ "content_hash": str(content_hash),
+ }
+ if kind == "video":
+ normalized_timeline = str(
+ timeline_mode or "restart_each_scene").strip().lower()
+ if normalized_timeline not in REFERENCE_VIDEO_TIMELINE_MODES:
+ raise ValueError(
+ "Scheduled video timeline_mode must be one of %s." %
+ (REFERENCE_VIDEO_TIMELINE_MODES,))
+ entry["timeline_mode"] = normalized_timeline
+ elif kind == "audio":
+ normalized_timeline = str(
+ timeline_mode or "standalone").strip().lower()
+ if normalized_timeline not in REFERENCE_AUDIO_TIMELINE_MODES:
+ raise ValueError(
+ "Scheduled audio timeline_mode must be one of %s." %
+ (REFERENCE_AUDIO_TIMELINE_MODES,))
+ entry["timeline_mode"] = normalized_timeline
+ entry["align_audio_reference"] = bool(align_audio_reference)
+ if audio is not None:
+ try:
+ normalized_audio_tag = _normalize_reference_tag(
+ audio_tag or (normalized_tag + "_audio"),
+ "Paired video-audio tag")
+ except ValueError as exc:
+ if compliance != "disabled":
+ raise
+ normalized_audio_tag = "%s_audio" % normalized_tag
+ _LOG.warning(
+ "H3 scheduled-reference warning: %s Using internal tag @%s.",
+ exc, normalized_audio_tag)
+ entry.update({
+ "audio": audio,
+ "audio_tag": normalized_audio_tag,
+ "audio_hash": str(audio_hash),
+ })
+ if kind == "video" and paired_audio_policy is not None:
+ paired_policy = _paired_audio_policy(paired_audio_policy)
+ if paired_policy == "embedded" and audio is None:
+ raise ValueError(
+ "paired_audio_policy=embedded requires paired audio on the "
+ "video reference.")
+ if paired_policy == "off" and audio is not None:
+ raise ValueError(
+ "paired_audio_policy=off cannot carry paired audio.")
+ entry["paired_audio_policy"] = paired_policy
+
+ existing_tags = {
+ alias for existing in entries
+ for alias in _reference_entry_tags(existing)
+ }
+ new_tags = _reference_entry_tags(entry)
+ if compliance != "disabled" and len(set(new_tags)) != len(new_tags):
+ raise ValueError(
+ "A scheduled video's @tag and paired @audio_tag must be "
+ "different.")
+ duplicates = existing_tags.intersection(new_tags)
+ if compliance != "disabled" and duplicates:
+ duplicate = sorted(duplicates)[0]
+ raise ValueError(
+ "Scheduled reference tag @%s is already in this chain." %
+ duplicate)
+ return _make_reference_schedule(entries + [entry])
+
+
+def _append_tagged_reference(
+ previous: Any, *, kind: str, tag: Any, value: Any,
+ content_hash: str, audio: Any = None, audio_tag: Any = "",
+ audio_hash: str = "", compliance_mode: str = "strict",
+ timeline_mode: Any = None,
+ align_audio_reference: Any = False,
+ paired_audio_policy: Any = None) -> dict[str, Any]:
+ if previous is None:
+ seed = None
+ else:
+ seed = _make_reference_schedule(_tagged_reference_entries(previous))
+ collection = _append_scheduled_reference(
+ seed, kind=kind, tag=tag, scenes="all", value=value,
+ content_hash=content_hash, audio=audio, audio_tag=audio_tag,
+ audio_hash=audio_hash, compliance_mode=compliance_mode,
+ timeline_mode=timeline_mode,
+ align_audio_reference=align_audio_reference,
+ paired_audio_policy=paired_audio_policy)
+ entries = collection["entries"]
+ entries[-1]["activation"] = "prompt"
+ return _make_tagged_references(entries)
+
+
+def _lazy_motion_target_size(
+ descriptor: dict[str, Any]) -> tuple[int, int]:
+ source_w = int(descriptor["width"])
+ source_h = int(descriptor["height"])
+ short_edge = str(descriptor.get("motion_short_edge") or "384")
+ if short_edge == "source" or min(source_w, source_h) <= int(short_edge):
+ return source_w, source_h
+ scale = int(short_edge) / float(min(source_w, source_h))
+ target_h = max(32, round(source_h * scale / 32) * 32)
+ target_w = max(32, round(source_w * scale / 32) * 32)
+ return target_w, target_h
+
+
+def _decode_lazy_motion_video(
+ descriptor: dict[str, Any], source_start: int,
+ source_end: int) -> Any:
+ if av is None or np is None or torch is None:
+ raise RuntimeError(
+ "Lazy H3 motion video decoding requires PyAV, NumPy, and torch.")
+ path = _resolved_media_path(
+ descriptor.get("path"), "Lazy motion-reference video path")
+ source_start = int(source_start)
+ source_end = int(source_end)
+ requested = source_end - source_start
+ if source_start < 0 or requested < 5:
+ raise ValueError(
+ "Lazy motion-reference 24 fps target window must contain at "
+ "least five frames; got %d:%d." % (source_start, source_end))
+ source_fps = float(descriptor.get("source_fps", FPS))
+ if not math.isfinite(source_fps) or source_fps <= 0.0:
+ raise ValueError(
+ "Lazy motion reference has invalid source_fps %.6g." %
+ source_fps)
+ skip_first_frames = int(descriptor.get("skip_first_frames", 0))
+ if skip_first_frames < 0:
+ raise ValueError(
+ "Lazy motion reference skip_first_frames cannot be negative.")
+ skip_seconds = skip_first_frames / source_fps
+ target_w, target_h = _lazy_motion_target_size(descriptor)
+ buffer = np.empty(
+ (requested, target_h, target_w, 3), dtype=np.uint8)
+ video_start = float(descriptor.get("video_start_seconds", 0.0))
+
+ def decode_window(allow_seek: bool) -> int | None:
+ decoded = 0
+ with av.open(path, mode="r") as container:
+ videos = list(container.streams.video)
+ if len(videos) != 1:
+ raise ValueError(
+ "Lazy motion reference changed and now contains %d "
+ "video streams; exactly one is required." % len(videos))
+ video = videos[0]
+ actual_rate = (
+ video.average_rate or video.base_rate or video.guessed_rate)
+ if (actual_rate is None or
+ abs(float(actual_rate) - source_fps) > 1e-3):
+ raise ValueError(
+ "Lazy motion reference changed frame rate after it was "
+ "registered (expected %.6g fps, got %s)." %
+ (source_fps, actual_rate))
+ target_start_seconds = (
+ skip_seconds + source_start / float(FPS))
+ seeking = bool(
+ allow_seek and video.time_base is not None and
+ target_start_seconds > 2.0 / source_fps)
+ if seeking:
+ seek_seconds = video_start + max(
+ 0.0, target_start_seconds - 2.0 / source_fps)
+ seek_offset = int(math.floor(
+ seek_seconds / float(video.time_base)))
+ container.seek(
+ seek_offset, stream=video, backward=True, any_frame=False)
+
+ previous_frame = None
+ previous_time = None
+ previous_array = None
+ target_index = source_start
+ target_tolerance = 1e-7
+
+ def frame_array(frame):
+ if (int(frame.width) != target_w or
+ int(frame.height) != target_h):
+ converted = frame.reformat(
+ width=target_w, height=target_h, format="rgb24")
+ return converted.to_ndarray()
+ return frame.to_ndarray(format="rgb24")
+
+ def emit_previous():
+ nonlocal decoded, previous_array
+ if previous_frame is None:
+ raise ValueError(
+ "Lazy motion reference starts after its first 24 fps "
+ "target timestamp.")
+ if previous_array is None:
+ previous_array = frame_array(previous_frame)
+ buffer[decoded] = previous_array
+ decoded += 1
+
+ for local_index, frame in enumerate(container.decode(video)):
+ if frame.pts is not None and frame.time_base is not None:
+ timestamp = float(frame.pts * frame.time_base)
+ timestamp_relative = timestamp - video_start
+ source_position = timestamp_relative * source_fps
+ frame_index = int(round(source_position))
+ pts_tolerance = max(
+ 0.05,
+ abs(float(frame.time_base)) * source_fps * 0.6)
+ if abs(source_position - frame_index) > pts_tolerance:
+ raise ValueError(
+ "Lazy motion reference is not constant %.6g fps "
+ "near decoded timestamp %.6fs (source position "
+ "%.4f)." %
+ (source_fps, timestamp, source_position))
+ elif seeking:
+ # Exact global indexing is unavailable after a keyframe
+ # seek. Reopen and decode from frame zero as a safe
+ # fallback; RAM remains bounded even for this rare case.
+ return None
+ else:
+ frame_index = local_index
+ # Once CFR has been verified, use its rational global frame
+ # index rather than coarse container PTS for target selection.
+ relative_time = frame_index / source_fps
+ target_time = (
+ skip_seconds + target_index / float(FPS))
+ while (target_index < source_end and
+ target_time < relative_time - target_tolerance):
+ if (previous_time is None or
+ target_time < previous_time - target_tolerance):
+ raise ValueError(
+ "Lazy motion reference cannot cover 24 fps target "
+ "frame %d after skipping %d source frames." %
+ (target_index, skip_first_frames))
+ emit_previous()
+ target_index += 1
+ target_time = (
+ skip_seconds + target_index / float(FPS))
+ if target_index >= source_end:
+ break
+ previous_frame = frame
+ previous_time = relative_time
+ previous_array = None
+ if target_index < source_end and previous_frame is not None:
+ display_end = previous_time + 1.0 / source_fps
+ target_time = (
+ skip_seconds + target_index / float(FPS))
+ while (target_index < source_end and
+ target_time < display_end - target_tolerance):
+ emit_previous()
+ target_index += 1
+ target_time = (
+ skip_seconds + target_index / float(FPS))
+ return decoded
+
+ decoded = decode_window(allow_seek=True)
+ if decoded is None:
+ decoded = decode_window(allow_seek=False)
+ if decoded != requested:
+ raise ValueError(
+ "Lazy motion reference %s yielded only %d of %d requested "
+ "frames for 24 fps target window %d:%d after skipping %d source "
+ "frames." %
+ (path, decoded, requested, source_start, source_end,
+ skip_first_frames))
+ return torch.from_numpy(buffer).to(dtype=torch.float32).div_(255.0)
+
+
+def _resampled_audio_frames(resampler: Any, frame: Any):
+ values = resampler.resample(frame)
+ if values is None:
+ return ()
+ if isinstance(values, (list, tuple)):
+ return values
+ return (values,)
+
+
+def _decode_lazy_motion_audio(
+ descriptor: dict[str, Any], source_start: int,
+ source_end: int) -> dict[str, Any] | None:
+ audio_info = descriptor.get("audio")
+ if audio_info is None:
+ return None
+ if av is None or np is None or torch is None:
+ raise RuntimeError(
+ "Lazy H3 motion audio decoding requires PyAV, NumPy, and torch.")
+ path = _resolved_media_path(
+ descriptor.get("path"), "Lazy motion-reference video path")
+ sample_rate = int(audio_info["sample_rate"])
+ channels = int(audio_info["channels"])
+ layout = "mono" if channels == 1 else "stereo"
+ video_start = float(descriptor.get("video_start_seconds", 0.0))
+ audio_start = float(audio_info.get("start_seconds", 0.0))
+ source_fps = float(descriptor.get("source_fps", FPS))
+ skip_first_frames = int(descriptor.get("skip_first_frames", 0))
+ skip_seconds = skip_first_frames / source_fps
+ start_seconds = (
+ video_start + skip_seconds + int(source_start) / float(FPS))
+ end_seconds = (
+ video_start + skip_seconds + int(source_end) / float(FPS))
+ sample_start = int(round((start_seconds - audio_start) * sample_rate))
+ sample_end = int(round((end_seconds - audio_start) * sample_rate))
+ if sample_start < 0 or sample_end <= sample_start:
+ raise ValueError(
+ "Lazy motion-reference audio cannot cover video window %d:%d "
+ "because its stream starts at %.6fs." %
+ (int(source_start), int(source_end), audio_start))
+ requested = sample_end - sample_start
+ target = np.zeros((channels, requested), dtype=np.float32)
+ coverage = np.zeros((requested,), dtype=np.bool_)
+ cursor = 0
+ cursor_initialized = False
+ with av.open(path, mode="r") as container:
+ audios = list(container.streams.audio)
+ if len(audios) != 1:
+ raise ValueError(
+ "Lazy motion reference changed and now contains %d audio "
+ "streams; exactly one is required." % len(audios))
+ audio_stream = audios[0]
+ seek_preroll = max(0.25, 2.0 / source_fps)
+ audio_seek = bool(
+ audio_stream.time_base is not None and
+ start_seconds - audio_start > seek_preroll)
+ if audio_seek:
+ seek_seconds = max(
+ audio_start, start_seconds - seek_preroll)
+ seek_offset = int(math.floor(
+ seek_seconds / float(audio_stream.time_base)))
+ container.seek(
+ seek_offset, stream=audio_stream,
+ backward=True, any_frame=False)
+ resampler = av.AudioResampler(
+ format="fltp", layout=layout, rate=sample_rate)
+
+ def consume(output_frame):
+ nonlocal cursor, cursor_initialized
+ array = output_frame.to_ndarray()
+ if array.ndim == 1:
+ array = array.reshape(1, -1)
+ if tuple(array.shape[:1]) != (channels,):
+ raise ValueError(
+ "Lazy motion-reference audio decoded to unexpected shape "
+ "%s." % (tuple(array.shape),))
+ count = int(array.shape[-1])
+ if not cursor_initialized:
+ if (output_frame.pts is not None and
+ output_frame.time_base is not None):
+ frame_seconds = float(
+ output_frame.pts * output_frame.time_base)
+ cursor = int(round(
+ (frame_seconds - audio_start) * sample_rate))
+ elif audio_seek:
+ raise ValueError(
+ "Lazy motion-reference audio has no timestamps after "
+ "seeking; exact skip alignment is unavailable.")
+ cursor_initialized = True
+ copy_start = max(cursor, sample_start)
+ copy_end = min(cursor + count, sample_end)
+ if copy_end > copy_start:
+ source_offset = copy_start - cursor
+ target_offset = copy_start - sample_start
+ span = copy_end - copy_start
+ target[:, target_offset:target_offset + span] = (
+ array[:, source_offset:source_offset + span])
+ coverage[target_offset:target_offset + span] = True
+ cursor += count
+
+ stop = False
+ for frame in container.decode(audio_stream):
+ for output_frame in _resampled_audio_frames(resampler, frame):
+ consume(output_frame)
+ if cursor >= sample_end:
+ stop = True
+ break
+ if stop:
+ break
+ if not stop:
+ for output_frame in _resampled_audio_frames(resampler, None):
+ consume(output_frame)
+ if cursor >= sample_end:
+ break
+ copied = int(np.count_nonzero(coverage))
+ if copied != requested:
+ first_missing = int(np.flatnonzero(~coverage)[0])
+ internal_gap = bool(np.any(coverage[first_missing:]))
+ tail_missing = requested - first_missing
+ maximum_rounding_pad = int(math.ceil(sample_rate / float(FPS)))
+ if internal_gap or tail_missing > maximum_rounding_pad:
+ raise ValueError(
+ "Lazy motion reference %s yielded only %d of %d requested "
+ "audio samples for video window %d:%d." %
+ (path, copied, requested,
+ int(source_start), int(source_end)))
+ _LOG.info(
+ "h3_lazy_motion: tail padded %d zero audio samples while "
+ "converting %.6g fps video to a 24 fps target window",
+ tail_missing, float(descriptor.get("source_fps", FPS)))
+ return {
+ "waveform": torch.from_numpy(target).unsqueeze(0),
+ "sample_rate": sample_rate,
+ }
+
+
+def _decode_lazy_motion_source_audio(
+ descriptor: dict[str, Any]) -> dict[str, Any]:
+ """Decode the complete post-skip audio track on the 24 fps video clock."""
+ frame_count = int(descriptor.get("frame_count", 0))
+ if frame_count < 1:
+ raise ValueError(
+ "Lazy motion source audio requires a known post-skip video "
+ "duration.")
+ audio = _decode_lazy_motion_audio(descriptor, 0, frame_count)
+ if audio is None:
+ raise ValueError(
+ "Lazy motion source video contains no embedded audio stream.")
+ return audio
+
+
+def _native_video_from_path(path: str) -> Any:
+ try:
+ from comfy_api.latest import InputImpl
+ except ImportError as exc:
+ raise RuntimeError(
+ "MiniMax H3 Lazy Motion AV Loader requires ComfyUI's native "
+ "VIDEO API.") from exc
+ return InputImpl.VideoFromFile(path)
+
+
+def _is_source_timeline(value: Any) -> bool:
+ return (
+ isinstance(value, dict)
+ and value.get("version") == SOURCE_TIMELINE_VERSION
+ and value.get("kind") == "source_timeline"
+ and isinstance(value.get("extent"), dict)
+ and isinstance(value.get("audio"), dict)
+ and isinstance(value.get("fingerprints"), dict)
+ )
+
+
+def _validate_source_timeline(
+ timeline: Any, *, require_runtime: bool = False
+ ) -> dict[str, Any]:
+ if not _is_source_timeline(timeline):
+ raise ValueError(
+ "H3 Source Timeline must come from the MiniMax H3 Source "
+ "Timeline node.")
+ video = timeline.get("video")
+ if video is not None and not _is_lazy_motion_descriptor(video):
+ raise ValueError(
+ "H3 Source Timeline video descriptor is invalid or obsolete.")
+ extent = timeline["extent"]
+ frame_count = int(extent.get("frame_count", 0))
+ duration = float(extent.get("duration_seconds", 0.0))
+ if frame_count < 1 or not math.isfinite(duration) or duration <= 0.0:
+ raise ValueError("H3 Source Timeline has no usable 24 fps extent.")
+ audio = timeline["audio"]
+ audio_kind = str(audio.get("kind") or "")
+ if audio_kind not in ("none", "embedded", "external_path",
+ "deferred_tensor"):
+ raise ValueError(
+ "H3 Source Timeline has unknown audio kind %r." % audio_kind)
+ if audio_kind in ("embedded", "external_path"):
+ if not isinstance(audio.get("path"), str):
+ raise ValueError(
+ "H3 Source Timeline path-backed audio has no path.")
+ if require_runtime:
+ _resolved_media_path(
+ audio["path"], "H3 Source Timeline audio recovery path")
+ elif audio_kind == "deferred_tensor" and require_runtime:
+ _validate_audio(
+ audio.get("value"), "H3 Source Timeline deferred audio")
+ if video is not None and require_runtime:
+ _resolved_media_path(
+ video["path"], "H3 Source Timeline video recovery path")
+ fingerprints = timeline["fingerprints"]
+ for key in ("video", "audio", "timeline"):
+ value = str(fingerprints.get(key) or "")
+ if key == "video" and video is None:
+ if value:
+ raise ValueError(
+ "Audio-only H3 Source Timeline has a video fingerprint.")
+ continue
+ if key == "audio" and audio_kind == "none":
+ if value:
+ raise ValueError(
+ "Silent H3 Source Timeline has an audio fingerprint.")
+ continue
+ if len(value) != 64:
+ raise ValueError(
+ "H3 Source Timeline %s fingerprint is invalid." % key)
+ tracks = timeline.get("audio_tracks")
+ if tracks is not None:
+ if (not isinstance(tracks, dict)
+ or tracks.get("version") != "h3_audio_tracks_v1"
+ or tracks.get("mix_mode") not in ("full_mix", "stems")
+ or not any(tracks.get(key) for key in ("vocals", "instrumental"))):
+ raise ValueError("H3 Audio Tracks has an invalid track group.")
+ for role in ("vocals", "instrumental"):
+ track = tracks.get(role)
+ if track is None:
+ continue
+ if not isinstance(track, dict) or "audio_tracks" in track:
+ raise ValueError("H3 Audio Tracks cannot contain nested groups.")
+ _validate_source_timeline(track, require_runtime=require_runtime)
+ if track.get("video") is not None or track["audio"]["kind"] == "none":
+ raise ValueError("H3 Audio Tracks %s must be audio-only." % role)
+ if int(track["extent"]["frame_count"]) != frame_count:
+ raise ValueError(
+ "H3 Audio Tracks %s must have the same duration as the "
+ "full mix, including leading silence and instrumental "
+ "gaps. Tracks must share one timeline; they are not "
+ "stretched or concatenated." % role)
+ return timeline
+
+
+def _make_audio_tracks(full_mix=None, vocals=None, instrumental=None):
+ """Group aligned audio-only timelines; only vocals drive a locked target."""
+ supplied = {key: value for key, value in (
+ ("full_mix", full_mix), ("vocals", vocals),
+ ("instrumental", instrumental)) if value is not None}
+ if not supplied:
+ raise ValueError("H3 Audio Tracks needs a full mix, vocals, or instrumental.")
+ for role, track in supplied.items():
+ _validate_source_timeline(track, require_runtime=True)
+ if (track.get("video") is not None or "audio_tracks" in track
+ or track["audio"]["kind"] == "none"):
+ raise ValueError("H3 Audio Tracks %s must be a single audio track." % role)
+ frames = {int(track["extent"]["frame_count"]) for track in supplied.values()}
+ if len(frames) != 1:
+ raise ValueError(
+ "H3 Audio Tracks must have the same duration, including silence. "
+ "Export aligned full-length stems; do not trim vocal gaps.")
+ if full_mix is not None and len(supplied) == 1:
+ return full_mix # Preserve the exact legacy descriptor and identity.
+ gain = 1.0
+ if full_mix is None:
+ # Decode once only when a mix must be synthesized. Cache a seekable mix
+ # so Carousel -> Plan remains recoverable without another source wire.
+ # Run Manager can archive it alongside the original stems.
+ values = [_source_timeline_source_audio(track)
+ for track in supplied.values()]
+ sample_rate = max(int(value["sample_rate"]) for value in values)
+ channels = max(int(_audio_waveform_3d(value, "Audio stem")[0].shape[1])
+ for value in values)
+ samples = sample_boundary_from_frames(next(iter(frames)), sample_rate)
+ parts = [_resample_audio_exact(value, sample_rate, samples, channels,
+ "H3 Audio Tracks stem")["waveform"]
+ for value in values]
+ waveform = sum(parts[1:], parts[0].clone())
+ if not bool(torch.isfinite(waveform).all()):
+ raise ValueError("H3 Audio Tracks contains non-finite audio samples.")
+ peak = float(waveform.abs().max())
+ if peak > 1.0:
+ gain = 1.0 / peak
+ waveform *= gain
+ mixed_audio = {"waveform": waveform, "sample_rate": sample_rate}
+ mixed_path = os.path.join(
+ folder_paths.get_temp_directory(), "h3_audio_track_mixes",
+ "%s.wav" % _audio_fingerprint(mixed_audio))
+ if not os.path.isfile(mixed_path):
+ _atomic_float_wav(mixed_audio, mixed_path)
+ full_mix = _make_source_timeline(audio_path=mixed_path)
+ mix_mode = "stems"
+ else:
+ mix_mode = "full_mix"
+ result = dict(full_mix)
+ result["audio_tracks"] = {
+ "version": "h3_audio_tracks_v1", "mix_mode": mix_mode,
+ "mix_gain": gain, "vocals": vocals, "instrumental": instrumental,
+ }
+ result["fingerprints"] = dict(full_mix["fingerprints"])
+ result["fingerprints"]["timeline"] = _fingerprint({
+ "version": "h3_audio_tracks_v1",
+ "delivery": full_mix["fingerprints"]["timeline"],
+ "vocals": vocals["fingerprints"]["timeline"] if vocals else "",
+ "instrumental": (instrumental["fingerprints"]["timeline"]
+ if instrumental else ""),
+ "mix_mode": mix_mode, "mix_gain": gain,
+ })
+ return _validate_source_timeline(result, require_runtime=True)
+
+
+def _source_timeline_generation_track(timeline, *, lip_sync=False):
+ source = _validate_source_timeline(timeline)
+ tracks = source.get("audio_tracks") or {}
+ if lip_sync and tracks:
+ if tracks.get("vocals") is None:
+ raise ValueError(
+ "Lip-sync is enabled, but this audio group has no vocal track. "
+ "Attach aligned vocals or turn Lip-sync off for this scene. "
+ "Instrumental audio is never substituted for missing vocals.")
+ return tracks["vocals"]
+ return source
+
+
+def _source_timeline_path_hash(
+ path: str, cache: dict[str, str]) -> str:
+ resolved = os.path.realpath(path)
+ if resolved not in cache:
+ cache[resolved] = _file_sha256(resolved)
+ return cache[resolved]
+
+
+def _make_source_timeline(
+ video_path: Any = "", *, source_video: Any = None,
+ audio_path: Any = "", source_audio: Any = None,
+ embedded_audio: str = "auto", skip_first_frames: Any = 0,
+ source_route: str = "") -> dict[str, Any]:
+ """Build one intent-free, file-backed source timeline descriptor."""
+ embedded_mode = str(embedded_audio or "auto").strip().lower()
+ if embedded_mode not in ("auto", "required", "ignore"):
+ raise ValueError(
+ "Source Timeline embedded_audio must be auto, required, or "
+ "ignore.")
+ has_audio_path = bool(str(audio_path or "").strip())
+ if has_audio_path and source_audio is not None:
+ raise ValueError(
+ "Source Timeline accepts one external audio source: connect "
+ "source_audio or provide audio_path, not both.")
+ wants_video = source_video is not None or bool(str(video_path or "").strip())
+ video = None
+ route = str(source_route or "").strip()
+ if wants_video:
+ resolved_video, detected_route = _lazy_motion_media_path(
+ video_path, source_video)
+ route = route or detected_route
+ video = _lazy_motion_descriptor_with_skip(
+ _probe_lazy_motion_path(resolved_video, False),
+ skip_first_frames)
+ video["source_route"] = route
+ elif int(skip_first_frames) != 0:
+ raise ValueError(
+ "Source Timeline skip_first_frames needs a video clock.")
+
+ hashes: dict[str, str] = {}
+ if video is not None:
+ video_file_hash = _source_timeline_path_hash(video["path"], hashes)
+ video["file_sha256"] = video_file_hash
+ video_hash = _fingerprint({
+ "source_timeline": SOURCE_TIMELINE_VERSION,
+ "file_sha256": video_file_hash,
+ "video_stream": 0,
+ "source_rate": [
+ int(video["source_rate_numerator"]),
+ int(video["source_rate_denominator"]),
+ ],
+ "skip_first_frames": int(video["skip_first_frames"]),
+ })
+ origin_fps = float(video["source_fps"])
+ origin_skip = int(video["skip_first_frames"])
+ origin_seconds = float(video["skip_seconds"])
+ else:
+ video_hash = ""
+ origin_fps = float(FPS)
+ origin_skip = 0
+ origin_seconds = 0.0
+
+ audio: dict[str, Any]
+ if source_audio is not None:
+ waveform, sample_rate = _validate_audio(
+ source_audio, "H3 Source Timeline external audio")
+ channels = 1 if waveform.ndim == 1 else int(waveform.shape[-2])
+ if channels not in (1, 2):
+ raise ValueError(
+ "H3 Source Timeline external audio must be mono or stereo.")
+ content_hash = _audio_fingerprint(source_audio)
+ audio = {
+ "kind": "deferred_tensor",
+ "value": source_audio,
+ "sample_rate": sample_rate,
+ "channels": channels,
+ "duration_seconds": (
+ int(waveform.shape[-1]) / float(sample_rate)),
+ "timeline_offset_seconds": 0.0,
+ "content_sha256": content_hash,
+ "aligned_to_timeline_origin": True,
+ }
+ audio_hash = _fingerprint({
+ "source_timeline": SOURCE_TIMELINE_VERSION,
+ "kind": "deferred_tensor",
+ "content_sha256": content_hash,
+ "timeline_offset_seconds": 0.0,
+ })
+ elif has_audio_path:
+ inspected = _probe_audio_path(
+ str(audio_path), "Source Timeline external audio path", True)
+ assert inspected is not None
+ audio_file_hash = _source_timeline_path_hash(
+ inspected["path"], hashes)
+ audio = {
+ "kind": "external_path",
+ **inspected,
+ "file_sha256": audio_file_hash,
+ "timeline_offset_seconds": origin_seconds,
+ "origin_source_fps": origin_fps,
+ "origin_skip_frames": origin_skip,
+ }
+ audio_hash = _fingerprint({
+ "source_timeline": SOURCE_TIMELINE_VERSION,
+ "kind": "external_path",
+ "file_sha256": audio_file_hash,
+ "audio_stream": int(inspected["stream_index"]),
+ "timeline_offset_seconds": origin_seconds,
+ })
+ elif video is not None and embedded_mode != "ignore":
+ inspected = _probe_audio_path(
+ video["path"], "Source Timeline embedded audio",
+ embedded_mode == "required")
+ if inspected is None:
+ audio = {"kind": "none"}
+ audio_hash = ""
+ else:
+ audio_info = {
+ "sample_rate": int(inspected["sample_rate"]),
+ "channels": int(inspected["channels"]),
+ "start_seconds": float(inspected["start_seconds"]),
+ }
+ video["audio"] = audio_info
+ audio = {
+ "kind": "embedded",
+ **inspected,
+ "file_sha256": video["file_sha256"],
+ "timeline_offset_seconds": origin_seconds,
+ "origin_source_fps": origin_fps,
+ "origin_skip_frames": origin_skip,
+ }
+ audio_hash = _fingerprint({
+ "source_timeline": SOURCE_TIMELINE_VERSION,
+ "kind": "embedded",
+ "file_sha256": video["file_sha256"],
+ "audio_stream": int(inspected["stream_index"]),
+ "skip_first_frames": origin_skip,
+ })
+ else:
+ audio = {"kind": "none"}
+ audio_hash = ""
+
+ if video is not None:
+ frame_count = int(video.get("frame_count", 0))
+ duration_seconds = frame_count / float(FPS)
+ elif audio["kind"] != "none":
+ available_duration = max(
+ 0.0, float(audio.get("duration_seconds", 0.0)) -
+ float(audio.get("timeline_offset_seconds", 0.0)))
+ frame_count = int(math.floor(available_duration * FPS + 1e-9))
+ duration_seconds = frame_count / float(FPS)
+ else:
+ raise ValueError(
+ "Source Timeline needs a video, an audio path, or source_audio.")
+ if frame_count < 1:
+ raise ValueError(
+ "Source Timeline media has no usable duration after its origin.")
+
+ timeline_hash = _fingerprint({
+ "version": SOURCE_TIMELINE_VERSION,
+ "video": video_hash,
+ "audio": audio_hash,
+ "origin": {
+ "source_fps": origin_fps,
+ "skip_first_frames": origin_skip,
+ },
+ "extent_frames": frame_count,
+ })
+ timeline = {
+ "version": SOURCE_TIMELINE_VERSION,
+ "kind": "source_timeline",
+ "fps": FPS,
+ "origin": {
+ "source_fps": origin_fps,
+ "skip_first_frames": origin_skip,
+ "skip_seconds": origin_seconds,
+ },
+ "extent": {
+ "frame_count": frame_count,
+ "duration_seconds": duration_seconds,
+ },
+ "video": video,
+ "audio": audio,
+ "fingerprints": {
+ "video": video_hash,
+ "audio": audio_hash,
+ "timeline": timeline_hash,
+ },
+ "recovery": {
+ "video_path": video["path"] if video is not None else "",
+ "audio_path": (
+ str(audio.get("path") or "")
+ if audio["kind"] in ("embedded", "external_path") else ""),
+ "deferred_audio_requires_materialization": (
+ audio["kind"] == "deferred_tensor"),
+ },
+ }
+ return _validate_source_timeline(timeline, require_runtime=True)
+
+
+def _source_timeline_recovery_record(timeline: Any) -> dict[str, Any]:
+ """Return a JSON-safe descriptor suitable for run/checkpoint metadata."""
+ source = _validate_source_timeline(timeline)
+ record = {
+ "version": source["version"],
+ "kind": source["kind"],
+ "fps": int(source["fps"]),
+ "origin": dict(source["origin"]),
+ "extent": dict(source["extent"]),
+ "video": dict(source["video"]) if source.get("video") else None,
+ "audio": {
+ key: value for key, value in source["audio"].items()
+ if key != "value"
+ },
+ "fingerprints": dict(source["fingerprints"]),
+ "recovery": dict(source.get("recovery") or {}),
+ }
+ if record["audio"].get("kind") == "deferred_tensor":
+ record["audio"]["requires_materialization"] = True
+ if source.get("audio_tracks") is not None:
+ record["audio_tracks"] = {
+ **source["audio_tracks"],
+ **{role: _source_timeline_recovery_record(track)
+ for role in ("vocals", "instrumental")
+ if (track := source["audio_tracks"].get(role)) is not None},
+ }
+ # Fail here, close to persistence, rather than writing an unusable run.
+ _canonical_json(record)
+ return record
+
+
+def _source_timeline_verify_recovery_file(
+ descriptor: dict[str, Any], label: str) -> None:
+ path = str(descriptor.get("path") or "")
+ if not path or not os.path.isfile(path):
+ return
+ expected = str(descriptor.get("file_sha256") or "")
+ if expected and _file_sha256(path) != expected:
+ raise ValueError(
+ "%s exists but its contents do not match the saved Source "
+ "Timeline fingerprint: %s" % (label, path))
+
+
+def _source_timeline_from_recovery(
+ record: Any, *, video_path: Any = "", audio_path: Any = "",
+ deferred_audio: Any = None, track_paths=None, deferred_tracks=None
+ ) -> dict[str, Any]:
+ """Restore or relink a persisted timeline without changing its identity."""
+ try:
+ restored = json.loads(_canonical_json(record))
+ except (TypeError, ValueError) as exc:
+ raise ValueError(
+ "Saved H3 Source Timeline descriptor is not JSON-safe.") from exc
+ _validate_source_timeline(restored)
+ video = restored.get("video")
+ audio = restored["audio"]
+ if str(video_path or "").strip():
+ if video is None:
+ raise ValueError("Cannot relink video on an audio-only timeline.")
+ resolved = _resolved_media_path(
+ video_path, "H3 Source Timeline replacement video")
+ if _file_sha256(resolved) != str(video.get("file_sha256") or ""):
+ raise ValueError(
+ "Replacement video does not match the saved Source Timeline.")
+ video["path"] = resolved
+ restored["recovery"]["video_path"] = resolved
+ if audio.get("kind") == "embedded":
+ audio["path"] = resolved
+ restored["recovery"]["audio_path"] = resolved
+ if str(audio_path or "").strip():
+ if audio.get("kind") != "external_path":
+ raise ValueError(
+ "audio_path relinking is only valid for external-path audio.")
+ resolved = _resolved_media_path(
+ audio_path, "H3 Source Timeline replacement audio")
+ if _file_sha256(resolved) != str(audio.get("file_sha256") or ""):
+ raise ValueError(
+ "Replacement audio does not match the saved Source Timeline.")
+ audio["path"] = resolved
+ restored["recovery"]["audio_path"] = resolved
+ if audio.get("kind") == "deferred_tensor" and deferred_audio is not None:
+ _validate_audio(deferred_audio, "Recovered H3 Source Timeline audio")
+ expected = str(audio.get("content_sha256") or "")
+ if _audio_fingerprint(deferred_audio) != expected:
+ raise ValueError(
+ "Recovered audio tensor does not match the saved Source "
+ "Timeline fingerprint.")
+ audio["value"] = deferred_audio
+ if video is not None:
+ _source_timeline_verify_recovery_file(
+ video, "H3 Source Timeline video recovery path")
+ if audio.get("kind") == "external_path":
+ _source_timeline_verify_recovery_file(
+ audio, "H3 Source Timeline audio recovery path")
+ if restored.get("audio_tracks") is not None:
+ tracks = restored["audio_tracks"]
+ for role in ("vocals", "instrumental"):
+ if tracks.get(role) is not None:
+ tracks[role] = _source_timeline_from_recovery(
+ tracks[role], audio_path=(track_paths or {}).get(role, ""),
+ deferred_audio=(deferred_tracks or {}).get(role))
+ return _validate_source_timeline(restored)
+
+
+def _source_timeline_audio_descriptor(
+ timeline: dict[str, Any]) -> dict[str, Any] | None:
+ source = _validate_source_timeline(timeline)
+ audio = source["audio"]
+ kind = audio["kind"]
+ if kind == "none" or kind == "deferred_tensor":
+ return None
+ if kind == "embedded":
+ descriptor = dict(source["video"])
+ descriptor["audio"] = {
+ "sample_rate": int(audio["sample_rate"]),
+ "channels": int(audio["channels"]),
+ "start_seconds": float(audio["start_seconds"]),
+ }
+ return descriptor
+ return {
+ "version": LAZY_MOTION_SOURCE_VERSION,
+ "kind": "lazy_motion_path",
+ "path": audio["path"],
+ "fps": FPS,
+ "source_fps": float(audio.get("origin_source_fps", FPS)),
+ "skip_first_frames": int(audio.get("origin_skip_frames", 0)),
+ "video_start_seconds": float(audio.get("start_seconds", 0.0)),
+ "frame_count": int(source["extent"]["frame_count"]),
+ "audio": {
+ "sample_rate": int(audio["sample_rate"]),
+ "channels": int(audio["channels"]),
+ "start_seconds": float(audio.get("start_seconds", 0.0)),
+ },
+ }
+
+
+def _source_timeline_scene_video(
+ timeline: Any, source_start: int, source_end: int) -> Any:
+ source = _validate_source_timeline(timeline, require_runtime=True)
+ if source.get("video") is None:
+ return None
+ start = int(source_start)
+ end = int(source_end)
+ if start < 0 or end > int(source["extent"]["frame_count"]):
+ raise ValueError(
+ "H3 Source Timeline video window %d:%d exceeds 0:%d." %
+ (start, end, int(source["extent"]["frame_count"])))
+ return _decode_lazy_motion_video(source["video"], start, end)
+
+
+def _source_timeline_scene_audio(
+ timeline: Any, source_start: int, source_end: int) -> Any:
+ source = _validate_source_timeline(timeline, require_runtime=True)
+ start = int(source_start)
+ end = int(source_end)
+ if start < 0 or end <= start or end > int(
+ source["extent"]["frame_count"]):
+ raise ValueError(
+ "H3 Source Timeline audio window %d:%d exceeds 0:%d." %
+ (start, end, int(source["extent"]["frame_count"])))
+ audio = source["audio"]
+ if audio["kind"] == "none":
+ return None
+ if audio["kind"] == "deferred_tensor":
+ return _slice_audio(
+ audio["value"], start / float(FPS),
+ (end - start) / float(FPS))
+ descriptor = _source_timeline_audio_descriptor(source)
+ return _decode_lazy_motion_audio(descriptor, start, end)
+
+
+def _source_timeline_source_audio(timeline: Any) -> Any:
+ source = _validate_source_timeline(timeline, require_runtime=True)
+ if source["audio"]["kind"] == "none":
+ return None
+ return _source_timeline_scene_audio(
+ source, 0, int(source["extent"]["frame_count"]))
+
+
+def _source_timeline_to_lazy_motion_descriptor(
+ timeline: Any) -> dict[str, Any] | None:
+ source = _validate_source_timeline(timeline)
+ if source.get("video") is None:
+ return None
+ descriptor = dict(source["video"])
+ if source["audio"]["kind"] != "embedded":
+ descriptor["audio"] = None
+ return descriptor
+
+
+def _source_timeline_from_lazy_motion_descriptor(
+ descriptor: Any, source_audio: Any = None) -> dict[str, Any]:
+ if not _is_lazy_motion_descriptor(descriptor):
+ raise ValueError(
+ "Legacy motion source is not a valid lazy descriptor.")
+ return _make_source_timeline(
+ descriptor["path"], source_audio=source_audio,
+ embedded_audio=(
+ "required" if descriptor.get("audio") is not None else "ignore"),
+ skip_first_frames=int(descriptor.get("skip_first_frames", 0)),
+ source_route=str(descriptor.get("source_route") or "legacy adapter"))
+
+
+def _legacy_media_to_source_timeline(
+ video_path: Any = "", *, source_video: Any = None,
+ source_audio: Any = None, skip_first_frames: Any = 0,
+ use_embedded_audio: bool = True) -> dict[str, Any]:
+ return _make_source_timeline(
+ video_path, source_video=source_video, source_audio=source_audio,
+ embedded_audio="required" if use_embedded_audio else "ignore",
+ skip_first_frames=skip_first_frames, source_route="legacy adapter")
+
+
+def _source_timeline_with_motion_short_edge(
+ timeline: Any, short_edge: Any) -> dict[str, Any]:
+ """Attach a decode-only resize choice without changing media identity."""
+ source = _validate_source_timeline(timeline)
+ if source.get("video") is None:
+ raise ValueError(
+ "Tagged motion reference requires video on H3 Source Timeline.")
+ resolved = str(short_edge or "384")
+ if resolved not in MOTION_REFERENCE_SHORT_EDGES:
+ raise ValueError("Unknown motion-reference short edge %r." % short_edge)
+ prepared = dict(source)
+ prepared["video"] = dict(source["video"])
+ prepared["video"]["motion_short_edge"] = resolved
+ prepared["audio"] = dict(source["audio"])
+ prepared["fingerprints"] = dict(source["fingerprints"])
+ prepared["recovery"] = dict(source.get("recovery") or {})
+ return _validate_source_timeline(prepared)
+
+
+def _source_timeline_available_audio_frames(timeline: Any) -> int:
+ source = _validate_source_timeline(timeline)
+ audio = source["audio"]
+ if audio["kind"] == "none":
+ return 0
+ available_seconds = max(
+ 0.0, float(audio.get("duration_seconds", 0.0)) -
+ float(audio.get("timeline_offset_seconds", 0.0)))
+ return int(math.floor(available_seconds * FPS + 1e-9))
+
+
+def _materialize_source_timeline_audio(
+ timeline: Any, plan: dict[str, Any]) -> dict[str, Any]:
+ timeline = _validate_source_timeline(timeline, require_runtime=True)
+ if (timeline.get("audio_tracks") and _audio_policy_requires_source(plan)
+ and _source_timeline_available_audio_frames(timeline) <
+ int(plan["total_delivered_frames"])):
+ raise ValueError(
+ "H3 Audio Tracks is too short for this Plan. Supply aligned "
+ "full-length tracks; grouped stems are never padded separately.")
+ source = _materialize_source_timeline_primary_audio(timeline, plan)
+ if source.get("audio_tracks") is not None:
+ source = dict(source)
+ source["audio_tracks"] = {
+ **source["audio_tracks"],
+ **{role: _materialize_source_timeline_primary_audio(track, plan)
+ for role in ("vocals", "instrumental")
+ if (track := source["audio_tracks"].get(role)) is not None},
+ }
+ return _validate_source_timeline(source, require_runtime=True)
+
+
+def _materialize_source_timeline_primary_audio(
+ timeline: Any, plan: dict[str, Any]) -> dict[str, Any]:
+ """Persist a deferred AUDIO once so recursive state remains path-backed."""
+ source = _validate_source_timeline(timeline, require_runtime=True)
+ audio = source["audio"]
+ if audio["kind"] != "deferred_tensor":
+ return source
+ value = audio["value"]
+ waveform, sample_rate = _validate_audio(
+ value, "H3 Source Timeline deferred audio")
+ required_samples = int(round(
+ int(plan["total_delivered_frames"]) / float(FPS) * sample_rate))
+ materialized = value
+ silent_padding = False
+ if (_audio_policy_requires_source(plan)
+ and int(waveform.shape[-1]) < required_samples):
+ if not _audio_is_silent(waveform):
+ raise ValueError(
+ "H3 Source Timeline audio is too short: it contains %d "
+ "samples at %d Hz, but this plan requires at least %d "
+ "samples for %d delivered frames. Only silent placeholder "
+ "audio is automatically padded." %
+ (int(waveform.shape[-1]), sample_rate, required_samples,
+ int(plan["total_delivered_frames"])))
+ materialized = _pad_audio_to_samples(
+ value, required_samples,
+ "H3 Source Timeline silent placeholder audio")
+ silent_padding = True
+ content_hash = str(audio.get("content_sha256") or
+ _audio_fingerprint(value))
+ directory = os.path.join(_run_dir(plan), "source_timeline")
+ path = os.path.join(directory, "source_audio.%s.wav" % content_hash[:16])
+ if not os.path.isfile(path):
+ _atomic_float_wav(materialized, path)
+ inspected = _probe_audio_path(
+ path, "Materialized H3 Source Timeline audio", True)
+ assert inspected is not None
+ prepared = dict(source)
+ prepared["video"] = (dict(source["video"])
+ if source.get("video") is not None else None)
+ prepared["audio"] = {
+ "kind": "external_path",
+ **inspected,
+ "file_sha256": _file_sha256(path),
+ "timeline_offset_seconds": 0.0,
+ "origin_source_fps": float(FPS),
+ "origin_skip_frames": 0,
+ "content_sha256": content_hash,
+ "materialized_from_tensor": True,
+ }
+ prepared["fingerprints"] = dict(source["fingerprints"])
+ prepared["recovery"] = dict(source.get("recovery") or {})
+ prepared["recovery"].update({
+ "audio_path": path,
+ "deferred_audio_requires_materialization": False,
+ "materialized_audio": True,
+ })
+ if silent_padding:
+ prepared["recovery"]["silent_padding_frames"] = int(
+ plan["total_delivered_frames"])
+ if prepared.get("video") is None:
+ prepared["extent"] = {
+ "frame_count": int(plan["total_delivered_frames"]),
+ "duration_seconds": (
+ int(plan["total_delivered_frames"]) / float(FPS)),
+ }
+ return _validate_source_timeline(prepared, require_runtime=True)
+
+
+def _path_is_inside(directory: str, path: str) -> bool:
+ try:
+ return os.path.commonpath((
+ os.path.realpath(directory), os.path.realpath(path))) == (
+ os.path.realpath(directory))
+ except ValueError:
+ return False
+
+
+def _archive_source_timeline_path(
+ source_path: str, expected_hash: str, destination_dir: str) -> str:
+ source_path = _resolved_media_path(
+ source_path, "H3 Source Timeline archive source")
+ expected_hash = str(expected_hash or "")
+ actual_hash = _file_sha256(source_path)
+ if expected_hash and actual_hash != expected_hash:
+ raise ValueError(
+ "H3 Source Timeline media changed before Run Manager archived it.")
+ os.makedirs(destination_dir, exist_ok=True)
+ basename = _safe_name(
+ os.path.splitext(os.path.basename(source_path))[0], "source")
+ suffix = os.path.splitext(source_path)[1][:24]
+ destination = os.path.join(
+ destination_dir, "%s_%s%s" % (actual_hash, basename, suffix))
+ if os.path.isfile(destination):
+ if _file_sha256(destination) != actual_hash:
+ raise ValueError(
+ "H3 Source Timeline archive contains a corrupt existing file: "
+ "%s" % destination)
+ return destination
+ temporary = "%s.%s.tmp" % (destination, uuid.uuid4().hex)
+ try:
+ with open(source_path, "rb") as source_handle, open(
+ temporary, "xb") as target_handle:
+ shutil.copyfileobj(source_handle, target_handle, 1024 * 1024)
+ if _file_sha256(temporary) != actual_hash:
+ raise ValueError(
+ "H3 Source Timeline archive copy failed verification.")
+ os.replace(temporary, destination)
+ finally:
+ _safe_unlink(temporary)
+ return destination
+
+
+def _archive_source_timeline_media(
+ timeline: Any, plan: dict[str, Any], *, archive_audio: bool,
+ archive_video: bool) -> dict[str, Any]:
+ """Apply Run Manager archive policy without decoding lazy media."""
+ source = _validate_source_timeline(timeline, require_runtime=True)
+ prepared = dict(source)
+ prepared["video"] = (dict(source["video"])
+ if source.get("video") is not None else None)
+ prepared["audio"] = dict(source["audio"])
+ prepared["fingerprints"] = dict(source["fingerprints"])
+ prepared["recovery"] = dict(source.get("recovery") or {})
+ run_dir = _run_dir(plan)
+ video = prepared.get("video")
+ audio = prepared["audio"]
+ if video is not None and bool(archive_video):
+ original = video["path"]
+ archived = _archive_source_timeline_path(
+ original, str(video.get("file_sha256") or ""),
+ os.path.join(run_dir, "references", "video"))
+ prepared["recovery"]["original_video_path"] = original
+ video["path"] = archived
+ prepared["recovery"]["video_path"] = archived
+ prepared["recovery"]["video_archived"] = True
+ if audio["kind"] == "embedded":
+ prepared["recovery"]["original_audio_path"] = audio["path"]
+ audio["path"] = archived
+ prepared["recovery"]["audio_path"] = archived
+ prepared["recovery"]["audio_archived_with_video"] = True
+ if audio["kind"] == "external_path" and bool(archive_audio):
+ original = audio["path"]
+ if not _path_is_inside(run_dir, original):
+ archived = _archive_source_timeline_path(
+ original, str(audio.get("file_sha256") or ""),
+ os.path.join(run_dir, "references", "audio"))
+ prepared["recovery"]["original_audio_path"] = original
+ audio["path"] = archived
+ prepared["recovery"]["audio_path"] = archived
+ prepared["recovery"]["audio_archived"] = True
+ if (audio["kind"] == "embedded" and bool(archive_audio)
+ and not bool(archive_video)):
+ prepared["recovery"]["audio_archive_requires_video"] = True
+ if source.get("audio_tracks") is not None:
+ prepared["audio_tracks"] = {
+ **source["audio_tracks"],
+ **{role: _archive_source_timeline_media(
+ track, plan, archive_audio=archive_audio, archive_video=False)
+ for role in ("vocals", "instrumental")
+ if (track := source["audio_tracks"].get(role)) is not None},
+ }
+ return _validate_source_timeline(prepared, require_runtime=True)
+
+
+def _validate_source_timeline_hash(
+ compatibility: dict[str, Any], timeline: Any, usage: str) -> None:
+ source = _validate_source_timeline(timeline, require_runtime=True)
+ expected_timeline = str(
+ compatibility.get("source_timeline_fingerprint") or "")
+ actual_timeline = str(source["fingerprints"]["timeline"])
+ expected_audio = str(compatibility.get("source_audio_hash") or "")
+ legacy_audio = str(
+ (source.get("recovery") or {}).get(
+ "legacy_loop_start_source_audio_hash") or "")
+ legacy_recovery = bool(
+ source.get("video") is None
+ and expected_audio
+ and expected_audio != "none"
+ and legacy_audio == expected_audio
+ and str(source["audio"].get("content_sha256") or "") ==
+ expected_audio)
+ if (expected_timeline and expected_timeline != actual_timeline) or (
+ not expected_timeline and not legacy_recovery):
+ raise ValueError(
+ "%s received a different H3 Source Timeline than Loop Start." %
+ usage)
+ if expected_audio and expected_audio != "none":
+ actual_audio = str(source["fingerprints"].get("audio") or "none")
+ content_audio = str(source["audio"].get("content_sha256") or "")
+ if (not expected_audio or
+ expected_audio not in (actual_audio, content_audio)):
+ raise ValueError(
+ "%s received different Source Timeline audio than Loop "
+ "Start." % usage)
+
+
+def _source_timeline_recovers_legacy_audio(
+ compatibility: dict[str, Any], timeline: Any) -> bool:
+ """Whether a timeline is the saved form of Loop Start's legacy AUDIO."""
+ try:
+ source = _validate_source_timeline(timeline, require_runtime=True)
+ except (OSError, TypeError, ValueError):
+ return False
+ expected = str(compatibility.get("source_audio_hash") or "")
+ return bool(
+ source.get("video") is None
+ and expected
+ and expected != "none"
+ and str((source.get("recovery") or {}).get(
+ "legacy_loop_start_source_audio_hash") or "") == expected
+ and str(source["audio"].get("content_sha256") or "") == expected)
+
+
+def _source_timeline_from_metadata(value: Any) -> dict[str, Any] | None:
+ if not isinstance(value, dict):
+ return None
+ record = value.get("source_timeline")
+ if record is None and isinstance(value.get("plan"), dict):
+ record = value["plan"].get("source_timeline")
+ if record is None:
+ return None
+ return _source_timeline_from_recovery(record)
+
+
+def _scheduled_video_reference_slice(
+ entry: dict[str, Any], state: Any, scene: int, scene_count: int,
+ length: int) -> tuple[Any, Any, str]:
+ """Resolve one active video ref without silently restarting its timeline."""
+ timeline_mode = str(
+ entry.get("timeline_mode") or "restart_each_scene")
+ video = entry["value"]
+ audio = entry.get("audio")
+ if timeline_mode == "restart_each_scene":
+ if _is_source_timeline(video):
+ source_start = 0
+ source_end = int(video["extent"]["frame_count"])
+ decoded_video = _source_timeline_scene_video(
+ video, source_start, source_end)
+ decoded_audio = (
+ _source_timeline_scene_audio(
+ video, source_start, source_end)
+ if audio is not None else None)
+ return decoded_video, decoded_audio, (
+ "@%s Source Timeline restart frames %d:%d" %
+ (entry.get("tag", "video"), source_start, source_end))
+ if _is_lazy_motion_descriptor(video):
+ source_start = 0
+ source_end = int(video.get("frame_count", 0))
+ if source_end < 1:
+ raise ValueError(
+ "Scheduled video @%s has no usable reference frames." %
+ entry.get("tag", "video"))
+ decoded_video = _decode_lazy_motion_video(
+ video, source_start, source_end)
+ decoded_audio = _decode_lazy_motion_audio(
+ video, source_start, source_end)
+ return decoded_video, decoded_audio, (
+ "@%s lazy restart frames %d:%d" %
+ (entry.get("tag", "video"), source_start, source_end))
+ return video, audio, ""
+ if timeline_mode != "sequential":
+ raise ValueError(
+ "Scheduled video @%s has unknown timeline mode %r." %
+ (entry.get("tag", "video"), timeline_mode))
+ if not isinstance(state, dict) or not isinstance(state.get("plan"), dict):
+ raise ValueError(
+ "Sequential scheduled video @%s requires Current Shot state on "
+ "Scheduled Ref2VA's state input." % entry.get("tag", "video"))
+ if int(state.get("index", -1)) != int(scene):
+ raise ValueError(
+ "Scheduled Ref2VA received scene %d but Current Shot state is at "
+ "scene %s." % (int(scene), state.get("index")))
+ shots = state["plan"].get("shots")
+ if not isinstance(shots, list) or len(shots) != int(scene_count):
+ raise ValueError(
+ "Sequential scheduled video requires state from the same %d-scene "
+ "Plan connected to Scheduled Ref2VA." % int(scene_count))
+ current = shots[int(scene) - 1]
+ if int(current.get("raw_frames", -1)) != int(length):
+ raise ValueError(
+ "Scheduled Ref2VA length %d does not match scene %d's %s raw "
+ "frames in Current Shot state." %
+ (int(length), int(scene), current.get("raw_frames")))
+
+ ranges = entry.get("ranges") or ()
+ if entry.get("activation") == "prompt":
+ origin_scene = next((
+ index for index, shot in enumerate(shots, 1)
+ if set(_REFERENCE_ALIAS_RE.findall(_prompt_text(
+ shot.get("prompt", ""), "Scene %d prompt" % index
+ ))).intersection(_reference_entry_tags(entry))
+ ), int(scene))
+ else:
+ origin_scene = int(ranges[0][0]) if ranges else 1
+ if origin_scene < 1 or origin_scene > len(shots):
+ raise ValueError(
+ "Sequential scheduled video @%s has an invalid first active "
+ "scene %d." % (entry.get("tag", "video"), origin_scene))
+ origin_start = int(shots[origin_scene - 1]["generation_start_frame"])
+ current_start = int(current["generation_start_frame"])
+ source_start = current_start - origin_start
+ source_length = int(length)
+ audio_source_start = source_start
+ audio_source_length = source_length
+ detail_mode = ""
+ compatibility = state["plan"].get("compatibility")
+ if not isinstance(compatibility, dict):
+ compatibility = {}
+ continuation_mode = current.get(
+ "continuation_mode", compatibility.get("continuation_mode", "guide"))
+ if (entry.get("semantic_role") == "motion"
+ and continuation_mode in MASKED_CONTINUATION_MODES):
+ # Native Ref2VA video banks carry no target-frame coordinates. A
+ # masked continuation already owns the repeated prefix, so including
+ # those reference frames makes H3 replay them once the denoisable tail
+ # begins (one context span late). Advance only the motion video on
+ # the delivered timeline. Its paired soundtrack still describes the
+ # complete raw target, including the repeated prefix, and therefore
+ # must remain on the raw generation window. Applying the delivered
+ # video window to audio shifts source-driven performance by exactly
+ # one context span on every continuation scene.
+ delivered = current.get("delivered_frames")
+ if delivered is None:
+ raise ValueError(
+ "Sequential motion reference @%s requires delivered-frame "
+ "timing from Current Shot state." % entry.get("tag", "video"))
+ preceding = shots[origin_scene - 1:int(scene) - 1]
+ if any(shot.get("delivered_frames") is None for shot in preceding):
+ raise ValueError(
+ "Sequential motion reference @%s requires delivered-frame "
+ "timing for every scene since its first activation." %
+ entry.get("tag", "video"))
+ source_start = sum(int(shot["delivered_frames"])
+ for shot in preceding)
+ source_length = int(delivered)
+ if source_length < 1:
+ raise ValueError(
+ "Sequential motion reference @%s resolved an empty delivered "
+ "window for scene %d." %
+ (entry.get("tag", "video"), int(scene)))
+ detail_mode = " delivered"
+ source_end = source_start + source_length
+ audio_source_end = audio_source_start + audio_source_length
+ if source_start < 0 or audio_source_start < 0:
+ raise ValueError(
+ "Sequential scheduled video @%s resolved a negative source "
+ "window for scene %d." %
+ (entry.get("tag", "video"), int(scene)))
+ if _is_source_timeline(video):
+ available = int(video["extent"]["frame_count"])
+ elif _is_lazy_motion_descriptor(video):
+ available = int(video.get("frame_count", 0))
+ else:
+ available = int(video.shape[0])
+ if available > 0 and source_end > available:
+ raise ValueError(
+ "Sequential scheduled video @%s needs source frames %d:%d for "
+ "scene %d, but the 24 fps reference contains only %d frames. "
+ "Supply a longer reference, shorten the Plan, or use "
+ "restart_each_scene." %
+ (entry.get("tag", "video"), source_start, source_end,
+ int(scene), available))
+ if _is_source_timeline(video):
+ sliced_video = _source_timeline_scene_video(
+ video, source_start, source_end)
+ sliced_audio = (
+ _source_timeline_scene_audio(
+ video, audio_source_start, audio_source_end)
+ if audio is not None else None)
+ elif _is_lazy_motion_descriptor(video):
+ sliced_video = _decode_lazy_motion_video(
+ video, source_start, source_end)
+ sliced_audio = _decode_lazy_motion_audio(
+ video, audio_source_start, audio_source_end)
+ else:
+ sliced_video = video[source_start:source_end]
+ sliced_audio = None
+ if (audio is not None and not _is_lazy_motion_descriptor(video)
+ and not _is_source_timeline(video)):
+ waveform, sample_rate = _validate_audio(
+ audio, "Sequential scheduled video @%s audio" % entry.get(
+ "tag", "video"))
+ sample_start = int(round(
+ audio_source_start / float(FPS) * sample_rate))
+ sample_end = int(round(
+ audio_source_end / float(FPS) * sample_rate))
+ available_samples = int(waveform.shape[-1])
+ if sample_end > available_samples:
+ raise ValueError(
+ "Sequential scheduled video @%s needs paired-audio samples "
+ "%d:%d for scene %d, but the soundtrack contains only %d "
+ "samples at %d Hz." %
+ (entry.get("tag", "video"), sample_start, sample_end,
+ int(scene), available_samples, sample_rate))
+ sliced_audio = {
+ "waveform": waveform[..., sample_start:sample_end],
+ "sample_rate": sample_rate,
+ }
+ if (sliced_audio is not None
+ and (audio_source_start != source_start
+ or audio_source_end != source_end)):
+ detail = (
+ "@%s sequential%s video frames %d:%d; paired audio raw frames "
+ "%d:%d (origin scene %d)" % (
+ entry.get("tag", "video"), detail_mode, source_start,
+ source_end, audio_source_start, audio_source_end,
+ origin_scene))
+ else:
+ detail = "@%s sequential%s frames %d:%d (origin scene %d)" % (
+ entry.get("tag", "video"), detail_mode, source_start, source_end,
+ origin_scene)
+ return sliced_video, sliced_audio, detail
+
+
+def _tagged_audio_reference_value(
+ entry: dict[str, Any], state: Any, scene: int, scene_count: int,
+ length: int) -> tuple[Any, str]:
+ """Resolve a tagged standalone clip or an exact Plan-timeline slice."""
+ timeline_mode = str(entry.get("timeline_mode") or "standalone")
+ audio = _project_asset_audio(entry["value"])
+ if timeline_mode == "standalone":
+ return audio, ""
+ if timeline_mode != "source_timeline":
+ raise ValueError(
+ "Tagged audio @%s has unknown timeline mode %r." %
+ (entry.get("tag", "audio"), timeline_mode))
+ if not isinstance(state, dict) or not isinstance(state.get("plan"), dict):
+ raise ValueError(
+ "Tagged audio @%s uses source_timeline and requires Current Shot "
+ "state on Tagged Ref2VA's state input. Keep the full source track "
+ "connected to Tagged Audio Ref; do not connect "
+ "source_audio_slice there because its fingerprint-to-Plan link "
+ "would create a cycle." % entry.get("tag", "audio"))
+ if int(state.get("index", -1)) != int(scene):
+ raise ValueError(
+ "Tagged Ref2VA received scene %d but Current Shot state is at "
+ "scene %s." % (int(scene), state.get("index")))
+ plan = state["plan"]
+ shots = plan.get("shots")
+ if not isinstance(shots, list) or len(shots) != int(scene_count):
+ raise ValueError(
+ "Tagged audio @%s source_timeline requires state from the same "
+ "%d-scene Plan connected to Tagged Ref2VA." %
+ (entry.get("tag", "audio"), int(scene_count)))
+ current = shots[int(scene) - 1]
+ if int(current.get("raw_frames", -1)) != int(length):
+ raise ValueError(
+ "Tagged Ref2VA length %d does not match scene %d's %s raw frames "
+ "in Current Shot state." %
+ (int(length), int(scene), current.get("raw_frames")))
+ compatibility = plan.get("compatibility")
+ if (not isinstance(compatibility, dict) or
+ not _audio_policy_uses_source_reference(plan, current)):
+ raise ValueError(
+ "Tagged audio @%s source_timeline requires Source reference=on "
+ "in this scene's effective audio policy." %
+ entry.get("tag", "audio"))
+ expected_hash = str(compatibility.get("source_audio_hash") or "")
+ entry_hash = str(entry.get("content_hash") or "")
+ if not expected_hash or expected_hash == "none":
+ raise ValueError(
+ "Tagged audio @%s source_timeline has no Loop Start source-audio "
+ "fingerprint to validate." % entry.get("tag", "audio"))
+ accepted_hashes = {expected_hash}
+ source_timeline = state.get("source_timeline")
+ if _is_source_timeline(source_timeline):
+ timeline_audio_hash = str(
+ source_timeline["fingerprints"].get("audio") or "")
+ timeline_content_hash = str(
+ source_timeline["audio"].get("content_sha256") or "")
+ # Source Timeline deliberately fingerprints the media route as well
+ # as its decoded content. A connected AUDIO tensor is registered by
+ # Tagged Audio with the content fingerprint, while Loop Start records
+ # the route fingerprint in the Plan. Accept the content identity only
+ # when this runtime timeline is itself the source recorded by Plan.
+ if expected_hash in (timeline_audio_hash, timeline_content_hash):
+ accepted_hashes.update(filter(None, (
+ timeline_audio_hash, timeline_content_hash)))
+ if not entry_hash or entry_hash not in accepted_hashes:
+ raise ValueError(
+ "Tagged audio @%s source_timeline received a different full "
+ "source track than H3 Chain Loop Start. Wire the same Load Audio "
+ "output to both nodes." % entry.get("tag", "audio"))
+ external_lead = int(current.get("external_context_frames", 0))
+ if int(scene) == 1 and external_lead > 0:
+ sliced = _slice_audio_after_external_context(
+ audio, state.get("previous_audio"), int(current["raw_frames"]),
+ external_lead, pad_silence=bool(compatibility.get(
+ "source_audio_silent_padding")))
+ else:
+ sliced = _slice_audio(
+ audio, current["audio_start_seconds"],
+ current["audio_duration_seconds"],
+ pad_silence=bool(compatibility.get(
+ "source_audio_silent_padding")))
+ if bool(entry.get("align_audio_reference")):
+ sliced, alignment = _align_audio_reference_to_h3_grid(
+ sliced, int(length))
+ else:
+ alignment = "frame-exact"
+ start = float(current["audio_start_seconds"])
+ end = start + float(current["audio_duration_seconds"])
+ detail = "@%s source timeline %.3f..%.3fs; %s" % (
+ entry.get("tag", "audio"), start, end, alignment)
+ return sliced, detail
+
+
+def _active_reference_bindings(
+ schedule: Any, scene: int, scene_count: int,
+ compliance_mode: str = "strict",
+ warnings: list[str] | None = None,
+ activation_tags: set[str] | None = None) -> dict[str, Any]:
+ scene, scene_count = int(scene), int(scene_count)
+ mode = _reference_compliance_mode(compliance_mode)
+ warnings = warnings if warnings is not None else []
+ if scene_count < 1 or scene < 1 or scene > scene_count:
+ raise ValueError(
+ "Scheduled Ref2VA scene index must be between 1 and %d; got %d." %
+ (scene_count, scene))
+ if activation_tags is None:
+ entries = _reference_schedule_entries(schedule)
+ for entry in entries:
+ try:
+ _parse_reference_selector(
+ entry.get("scenes", "all"), scene_count)
+ except ValueError as exc:
+ if mode != "disabled":
+ raise
+ warnings.append(str(exc))
+ _LOG.warning("H3 scheduled-reference warning: %s", exc)
+ active = [
+ entry for entry in entries if _reference_is_active(entry, scene)]
+ else:
+ entries = _tagged_reference_entries(schedule)
+ active = sorted(
+ [entry for entry in entries
+ if activation_tags.intersection(_reference_entry_tags(entry))],
+ key=lambda entry: (
+ str(entry.get("kind") or ""),
+ str(entry.get("tag") or ""),
+ str(entry.get("audio_tag") or ""),
+ ))
+ pictures = [entry for entry in active if entry.get("kind") == "picture"]
+ videos = [entry for entry in active if entry.get("kind") == "video"]
+ audios = [entry for entry in active if entry.get("kind") == "audio"]
+ unknown = [entry.get("kind") for entry in active
+ if entry.get("kind") not in ("picture", "video", "audio")]
+ if unknown:
+ message = "Unknown scheduled reference kind %r." % unknown[0]
+ if mode != "disabled":
+ raise ValueError(message)
+ warnings.append(message)
+ _LOG.warning("H3 scheduled-reference warning: %s", message)
+ if len(pictures) > 9:
+ message = (
+ "Scene %d activates %d pictures; only the first 9 were kept." %
+ (scene, len(pictures)))
+ if mode != "disabled":
+ raise ValueError(
+ "Scene %d activates %d pictures; stock H3 Ref2VA supports 9." %
+ (scene, len(pictures)))
+ warnings.append(message)
+ _LOG.warning("H3 scheduled-reference warning: %s", message)
+ pictures = pictures[:9]
+ if len(videos) > 3:
+ message = (
+ "Scene %d activates %d videos; only the first 3 were kept." %
+ (scene, len(videos)))
+ if mode != "disabled":
+ raise ValueError(
+ "Scene %d activates %d videos; stock H3 Ref2VA supports 3." %
+ (scene, len(videos)))
+ warnings.append(message)
+ _LOG.warning("H3 scheduled-reference warning: %s", message)
+ videos = videos[:3]
+ if len(audios) > 3:
+ message = (
+ "Scene %d activates %d standalone audios; only the first 3 "
+ "were kept." % (scene, len(audios)))
+ if mode != "disabled":
+ raise ValueError(
+ "Scene %d activates %d standalone audios; stock H3 Ref2VA "
+ "supports 3." % (scene, len(audios)))
+ warnings.append(message)
+ _LOG.warning("H3 scheduled-reference warning: %s", message)
+ audios = audios[:3]
+
+ aliases: dict[str, str] = {}
+ presentation: list[dict[str, Any]] = []
+ for ordinal, entry in enumerate(pictures, 1):
+ label = "" % ordinal
+ aliases[entry["tag"]] = label
+ presentation.append({
+ "entry": entry, "role": "picture", "tag": entry["tag"],
+ "label": label,
+ })
+
+ audio_ordinal = 0
+ for ordinal, entry in enumerate(videos, 1):
+ if entry.get("audio") is not None:
+ audio_ordinal += 1
+ audio_label = "