"""Disk-backed hop store: resume, single-shot re-roll, and lower peak RAM. Hops are causally dependent -- hop N is rendered from hop N-1's tail -- so the cache key **chains**: each hop's key mixes in the previous hop's key. Editing shot 1 therefore invalidates 2..N automatically, which is correct and must be surfaced in the UI, because otherwise it reads as a bug. **Why 16-bit.** A cached hop's last frame becomes the next hop's Qwen pin and its AddGuide guide. Round-tripping float32 through 8-bit would make a resumed chain diverge from an uninterrupted one -- the cache would change the output, which defeats the point. FFV1 at `rgb48le` keeps ~16 bits per channel, which is far below the VAE's own noise floor, so a resumed hop is indistinguishable from a fresh one. The cost is roughly 2x the bytes of an 8-bit lossless encode, and FFV1 still compresses it well. **On the RAM claim.** The node's IMAGE output is the whole clip, so the final tensor is unavoidably full size. What the store removes is the *double and triple buffering* during the loop: today `master_imgs` grows by concatenation (which allocates a new full-size tensor every hop) while `prev_imgs` and `imgs` are also live. Streaming to disk keeps one hop plus the overlap tail resident and concatenates once at the end. """ import hashlib import json import os import shutil import subprocess import time import numpy as np import torch TAG = "HandTieClips" VIDEO_EXT = ".mkv" AUDIO_EXT = ".npy" META_EXT = ".json" LATENT_EXT = ".latent.pt" def _ffmpeg(): exe = shutil.which("ffmpeg") if not exe: raise RuntimeError( f"{TAG}: ffmpeg is not on PATH. The hop store needs it to write " f"lossless FFV1. Install ffmpeg or set cache to off." ) return exe def tensor_digest(t): """Cheap, order-sensitive digest of a tensor's actual bytes.""" if t is None: return "none" a = t.detach().cpu().contiguous().numpy() h = hashlib.sha256() h.update(str(a.shape).encode()) h.update(str(a.dtype).encode()) h.update(a.tobytes()) return h.hexdigest()[:16] def audio_digest(a): """Digest an AUDIO input -- ``{"waveform": Tensor, "sample_rate": int}``. AUDIO is a dict, not a tensor, so `tensor_digest` cannot take it: `.detach` on a dict raises AttributeError, which is what wiring `voice` with the hop cache on used to do before hop 1 ever started. Sample rate is part of the identity -- the same waveform at a different rate is different audio. """ if a is None: return "none" if not isinstance(a, dict): return tensor_digest(a) h = hashlib.sha256() h.update(tensor_digest(a.get("waveform")).encode()) h.update(str(a.get("sample_rate")).encode()) return h.hexdigest()[:16] def hop_key(prev_key, payload): """Chained content key. `payload` must contain everything that changes pixels. Anything omitted here is something the cache will fail to notice, so err toward including it. """ h = hashlib.sha256() h.update((prev_key or "root").encode()) h.update(json.dumps(payload, sort_keys=True, default=str).encode()) return h.hexdigest()[:24] class HopStore: def __init__(self, root, budget_gb=20.0, fps=24): self.root = str(root) self.budget = float(budget_gb) * (1024 ** 3) self.fps = int(fps) os.makedirs(self.root, exist_ok=True) # -- paths ------------------------------------------------------------ def _p(self, key, ext): return os.path.join(self.root, key + ext) def has(self, key): return all(os.path.exists(self._p(key, e)) for e in (VIDEO_EXT, AUDIO_EXT, META_EXT)) # -- write ------------------------------------------------------------ def put(self, key, imgs, wav, sr, meta=None, latent=None): """imgs: float [N,H,W,3] in 0..1 on cpu. wav: float [.., C, S]. `latent` is this hop's sampler output and is optional; see the comment at the write below for why storing it is what makes the cache useful past hop 1. """ n, hgt, wid = int(imgs.shape[0]), int(imgs.shape[1]), int(imgs.shape[2]) vid_tmp = self._p(key, VIDEO_EXT + ".part") cmd = [ _ffmpeg(), "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb48le", "-s", f"{wid}x{hgt}", "-r", str(self.fps), "-i", "-", "-c:v", "ffv1", "-level", "3", "-coder", "1", "-context", "1", "-pix_fmt", "rgb48le", # The .part suffix defeats extension-based format detection, so the # muxer is named explicitly. Writing to .part and renaming on success # keeps a killed render from leaving a half-file that `has()` trusts. "-f", "matroska", vid_tmp, ] proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) try: # Frame at a time: never materialise a second full-size copy. for i in range(n): f = (imgs[i].clamp(0, 1) * 65535.0).round().to(torch.int32) proc.stdin.write(f.numpy().astype(" (imgs float32 [N,H,W,3], wav, sample_rate, latent|None) or None.""" if not self.has(key): return None with open(self._p(key, META_EXT), encoding="utf-8") as fh: info = json.load(fh) n, hgt, wid = int(info["frames"]), int(info["height"]), int(info["width"]) cmd = [ _ffmpeg(), "-v", "error", "-i", self._p(key, VIDEO_EXT), "-f", "rawvideo", "-pix_fmt", "rgb48le", "-", ] want = n * hgt * wid * 3 * 2 # communicate(), not sequential reads: draining stdout to EOF while # stderr is an unread pipe deadlocks the moment ffmpeg emits more than # the pipe buffer on stderr -- which is exactly what a corrupt FFV1 # does, i.e. the one case where the error actually matters. proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) raw, err_raw = proc.communicate() err = err_raw.decode(errors="replace") if proc.returncode != 0: raise RuntimeError(f"{TAG}: FFV1 decode failed for hop {key}: {err.strip()}") if len(raw) != want: raise RuntimeError( f"{TAG}: cached hop {key} is {len(raw)} bytes, expected {want}. " f"Delete the cache entry and re-render.") arr = np.frombuffer(raw, dtype="