render_from, and a cache that stops re-rendering hops nothing touched
Browse filesThree things, all aimed at the same complaint: changing one shot in the middle
of a chain cost a whole chain.
`render_from` is the other end of the range `render_through` already had. It
truncates nothing -- the hops before it still run through the loop, they are
just required to come out of the hop cache instead of the sampler. Doing it
that way rather than seeding prev_imgs / prev_audio / prev_sampled / prev_key
from the store up front means there is still exactly ONE piece of code that
carries continuity forward, and it is the cache-hit branch that was already
carrying it. The sampler latent is the part that would have been got wrong: it
decides whether the next hop joins by Motion-Context or falls back to AddGuide.
Every misconfiguration is refused before the master tensor is allocated, which
on an 8x15s plan is ~31 GB. An inverted range is checked against the ORIGINAL
render_through, because `n` has already been truncated to it by then and
testing against `n` reported "past the end of the plan" and then rendered the
whole thing. A missing cache entry names the hop and says why it might be gone
rather than printing "cache miss" and leaving people to count files in temp.
The cache change is the one that matters most. `chain_salt` digested every
wired reference chain-wide, so swapping the file behind @outfit moved hop 1's
key even when @outfit rides only hop 5 -- and because the key is chained, that
re-rendered everything. References are now keyed per hop, from `base_images`,
which is the right set on both paths: this hop's scheduled stills with a ref
plan, every wired ref without one, and the pin frame it excludes is already
covered by prev_key.
Last: the 9-reference ceiling is per ENCODE, not per plan, but it was counted
over the whole rail. Twelve references at three per shot across four shots was
refused against a limit no shot came near. Counted per shot now, with
unscheduled refs counted everywhere as the conservative case -- under
hop_script=next they only ride hop 1, so that can refuse a plan that would
have been fine but never accept one that would overflow. A shot that fills all
nine slots now says so, because _attach_pin_to_qwen drops the incoming frame
rather than a still when there is no room, which turns a continuation into a
fresh generate.
The schema keeps maxItems at 9: a grammar cannot express "per hop", so the
writer is held to the safe subset and hand-authored plans get the real rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VB4Dw6F79643vySkf19KV
- h3_ref_chain.py +94 -3
- prompt_pack/SCHEMA.json +1 -1
- refs.py +44 -3
- tools/check_features.py +61 -0
- tools/gen_schema.py +11 -1
- workflows/HandTieClips_Showcase.json +1 -0
- workflows/HandTieClips_Starter.json +1 -0
|
@@ -1552,6 +1552,22 @@ class HandTieClips:
|
|
| 1552 |
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1553 |
"tooltip": "End of the soundtrack window. 0 = to the end.",
|
| 1554 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1555 |
},
|
| 1556 |
"hidden": {
|
| 1557 |
"unique_id": "UNIQUE_ID",
|
|
@@ -1615,7 +1631,7 @@ class HandTieClips:
|
|
| 1615 |
music_fit="loop", music_fade_s=1.0, soundtrack_file="",
|
| 1616 |
voice_start_s=0.0, voice_end_s=0.0,
|
| 1617 |
reference_video_start_s=0.0, reference_video_end_s=0.0,
|
| 1618 |
-
music_start_s=0.0, music_end_s=0.0,
|
| 1619 |
unique_id=None):
|
| 1620 |
# First thing, before a single model is touched: hand the writer's VRAM
|
| 1621 |
# back. The plan writer stays resident between plans now, which is the
|
|
@@ -1710,6 +1726,46 @@ class HandTieClips:
|
|
| 1710 |
print(f"[{TAG}] render_through={stop_at} is past the end of a "
|
| 1711 |
f"{n}-hop plan; rendering all of it", flush=True)
|
| 1712 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1713 |
# Per-shot duration overrides, validated up front so a bad value fails
|
| 1714 |
# before any sampling happens rather than three hops in.
|
| 1715 |
lengths = []
|
|
@@ -1877,6 +1933,14 @@ class HandTieClips:
|
|
| 1877 |
budget_gb=float(cache_budget_gb), fps=FPS)
|
| 1878 |
print(f"[{TAG}] hop cache: {hop_store.root} "
|
| 1879 |
f"(budget {float(cache_budget_gb):.0f} GB)", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1880 |
# Everything constant across the chain, mixed into every hop key so a
|
| 1881 |
# resolution or sampler change invalidates the whole cache.
|
| 1882 |
chain_salt = {
|
|
@@ -1895,8 +1959,14 @@ class HandTieClips:
|
|
| 1895 |
# in _pin_continue (Motion-Context when a sampler latent exists,
|
| 1896 |
# AddGuide pixels otherwise), so it belongs in the per-hop key
|
| 1897 |
# below, not in the chain-wide salt.
|
| 1898 |
-
"refs"
|
| 1899 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1900 |
"voice": _store.audio_digest(voice),
|
| 1901 |
"refvid": _store.tensor_digest(reference_video),
|
| 1902 |
"start": _store.tensor_digest(start_image),
|
|
@@ -2161,6 +2231,14 @@ class HandTieClips:
|
|
| 2161 |
"seed": (int(shot["seed"]) if shot.get("seed") is not None
|
| 2162 |
else ((int(seed) + i) if seed_per_shot else int(seed))),
|
| 2163 |
"tags": [r["tag"] for r in hop_active],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2164 |
# Per hop, not chain-wide: hop 2 after a hop-1 cache hit
|
| 2165 |
# has no sampler latent and falls back to AddGuide, which
|
| 2166 |
# is a different render of the same inputs.
|
|
@@ -2200,6 +2278,19 @@ class HandTieClips:
|
|
| 2200 |
f"render yet; rendering it once", flush=True)
|
| 2201 |
hop_keys.append(hop_key)
|
| 2202 |
cached = hop_store.get(hop_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2203 |
|
| 2204 |
this_sampled = None
|
| 2205 |
if cached is not None:
|
|
|
|
| 1552 |
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1553 |
"tooltip": "End of the soundtrack window. 0 = to the end.",
|
| 1554 |
}),
|
| 1555 |
+
# Appended 2026-09-03 -- LAST, per the note at the top of this
|
| 1556 |
+
# block. `render_through` has been here since 0.4 and stops the
|
| 1557 |
+
# chain; this is the other end of the same range.
|
| 1558 |
+
"render_from": ("INT", {
|
| 1559 |
+
"default": 0, "min": 0, "max": 64,
|
| 1560 |
+
"tooltip": (
|
| 1561 |
+
"Start at this hop instead of hop 1. 0 starts at the "
|
| 1562 |
+
"beginning. Everything before it is replayed from the "
|
| 1563 |
+
"hop cache rather than rendered, so re-running one shot "
|
| 1564 |
+
"in the middle of a long chain costs that shot. "
|
| 1565 |
+
"Needs cache_hops=on, and every earlier hop must "
|
| 1566 |
+
"already be in the cache -- it names the first one that "
|
| 1567 |
+
"is not rather than guessing at the join. Pair it with "
|
| 1568 |
+
"render_through to render a range."
|
| 1569 |
+
),
|
| 1570 |
+
}),
|
| 1571 |
},
|
| 1572 |
"hidden": {
|
| 1573 |
"unique_id": "UNIQUE_ID",
|
|
|
|
| 1631 |
music_fit="loop", music_fade_s=1.0, soundtrack_file="",
|
| 1632 |
voice_start_s=0.0, voice_end_s=0.0,
|
| 1633 |
reference_video_start_s=0.0, reference_video_end_s=0.0,
|
| 1634 |
+
music_start_s=0.0, music_end_s=0.0, render_from=0,
|
| 1635 |
unique_id=None):
|
| 1636 |
# First thing, before a single model is touched: hand the writer's VRAM
|
| 1637 |
# back. The plan writer stays resident between plans now, which is the
|
|
|
|
| 1726 |
print(f"[{TAG}] render_through={stop_at} is past the end of a "
|
| 1727 |
f"{n}-hop plan; rendering all of it", flush=True)
|
| 1728 |
|
| 1729 |
+
# render_from is the other end of the same range, and unlike
|
| 1730 |
+
# render_through it truncates nothing at all: the hops before it still
|
| 1731 |
+
# run through the loop, they just have to come out of the cache instead
|
| 1732 |
+
# of the sampler. Validated against the store further down, once there
|
| 1733 |
+
# is a store to validate against.
|
| 1734 |
+
start_at = int(render_from or 0)
|
| 1735 |
+
# Inverted range first, and against the ORIGINAL render_through: `n` has
|
| 1736 |
+
# already been truncated to it above, so testing start_at against `n`
|
| 1737 |
+
# here would report an inverted range as "past the end of the plan" and
|
| 1738 |
+
# then quietly render the whole thing.
|
| 1739 |
+
if start_at > 1 and 0 < stop_at < start_at:
|
| 1740 |
+
raise ValueError(
|
| 1741 |
+
f"{TAG}: render_from={start_at} is past "
|
| 1742 |
+
f"render_through={stop_at}, so the range is empty. "
|
| 1743 |
+
"render_through is the LAST hop to render, not a count.")
|
| 1744 |
+
if start_at > n:
|
| 1745 |
+
print(f"[{TAG}] render_from={start_at} is past the end of a "
|
| 1746 |
+
f"{n}-hop plan; starting at hop 1", flush=True)
|
| 1747 |
+
start_at = 0
|
| 1748 |
+
replay_before = max(0, start_at - 1)
|
| 1749 |
+
if replay_before:
|
| 1750 |
+
# Checked here rather than beside the hop store, which is built much
|
| 1751 |
+
# further down -- after the master tensor, which at 8 x 15 s and
|
| 1752 |
+
# 1280x736 is ~31 GB. A misconfigured range must not cost that
|
| 1753 |
+
# allocation before it is told it is misconfigured.
|
| 1754 |
+
if dry:
|
| 1755 |
+
raise ValueError(
|
| 1756 |
+
f"{TAG}: render_from={start_at} needs the hop cache, and a "
|
| 1757 |
+
"dry run never touches it. Use render_through to limit what "
|
| 1758 |
+
"a dry run compiles.")
|
| 1759 |
+
if str(cache_hops) != "on":
|
| 1760 |
+
raise ValueError(
|
| 1761 |
+
f"{TAG}: render_from={start_at} replays hops 1-"
|
| 1762 |
+
f"{replay_before} from the hop cache, so cache_hops must be "
|
| 1763 |
+
"on. With it off there is nothing to replay from, and the "
|
| 1764 |
+
f"join into hop {start_at} would be invented rather than "
|
| 1765 |
+
"continued.")
|
| 1766 |
+
print(f"[{TAG}] render_from={start_at}: hops 1-{replay_before} come "
|
| 1767 |
+
f"from the cache, {start_at}-{n} render", flush=True)
|
| 1768 |
+
|
| 1769 |
# Per-shot duration overrides, validated up front so a bad value fails
|
| 1770 |
# before any sampling happens rather than three hops in.
|
| 1771 |
lengths = []
|
|
|
|
| 1933 |
budget_gb=float(cache_budget_gb), fps=FPS)
|
| 1934 |
print(f"[{TAG}] hop cache: {hop_store.root} "
|
| 1935 |
f"(budget {float(cache_budget_gb):.0f} GB)", flush=True)
|
| 1936 |
+
# Note on how render_from works, since this is where the store appears:
|
| 1937 |
+
# the leading hops are NOT seeded into prev_imgs / prev_audio /
|
| 1938 |
+
# prev_sampled / prev_key from here. They run through the loop like any
|
| 1939 |
+
# other hop and are simply required to hit the cache. The hit branch
|
| 1940 |
+
# already carries all four forward exactly as a render does -- the
|
| 1941 |
+
# sampler latent especially, which decides whether the next hop joins by
|
| 1942 |
+
# Motion-Context or falls back to AddGuide -- and a second copy of that
|
| 1943 |
+
# logic is a second thing to get subtly and silently wrong.
|
| 1944 |
# Everything constant across the chain, mixed into every hop key so a
|
| 1945 |
# resolution or sampler change invalidates the whole cache.
|
| 1946 |
chain_salt = {
|
|
|
|
| 1959 |
# in _pin_continue (Motion-Context when a sampler latent exists,
|
| 1960 |
# AddGuide pixels otherwise), so it belongs in the per-hop key
|
| 1961 |
# below, not in the chain-wide salt.
|
| 1962 |
+
# No "refs" here any more -- they are keyed per hop below.
|
| 1963 |
+
#
|
| 1964 |
+
# Digesting every wired reference chain-wide meant swapping the file
|
| 1965 |
+
# behind @outfit moved hop 1's key even when @outfit rides only hop
|
| 1966 |
+
# 5, and because the key is chained that re-rendered the entire
|
| 1967 |
+
# chain. Changing one late reference cost a full run. A reference
|
| 1968 |
+
# can only change the pixels of a hop it is actually handed to, so
|
| 1969 |
+
# that is where it belongs.
|
| 1970 |
"voice": _store.audio_digest(voice),
|
| 1971 |
"refvid": _store.tensor_digest(reference_video),
|
| 1972 |
"start": _store.tensor_digest(start_image),
|
|
|
|
| 2231 |
"seed": (int(shot["seed"]) if shot.get("seed") is not None
|
| 2232 |
else ((int(seed) + i) if seed_per_shot else int(seed))),
|
| 2233 |
"tags": [r["tag"] for r in hop_active],
|
| 2234 |
+
# The reference PIXELS this hop is handed, not the whole
|
| 2235 |
+
# rail (see chain_salt). `base_images` is the pre-pin dict,
|
| 2236 |
+
# which is the right thing on both paths: with a ref plan it
|
| 2237 |
+
# is this hop's scheduled stills, without one it is every
|
| 2238 |
+
# wired ref, and the pin frame it excludes is already
|
| 2239 |
+
# accounted for by `prev_key`.
|
| 2240 |
+
"refs": {k: _store.tensor_digest(t)
|
| 2241 |
+
for k, t in sorted((base_images or {}).items())},
|
| 2242 |
# Per hop, not chain-wide: hop 2 after a hop-1 cache hit
|
| 2243 |
# has no sampler latent and falls back to AddGuide, which
|
| 2244 |
# is a different render of the same inputs.
|
|
|
|
| 2278 |
f"render yet; rendering it once", flush=True)
|
| 2279 |
hop_keys.append(hop_key)
|
| 2280 |
cached = hop_store.get(hop_key)
|
| 2281 |
+
if i < replay_before and cached is None:
|
| 2282 |
+
# Name the hop. "Cache miss" on its own sends people to the
|
| 2283 |
+
# temp folder to count files; what they need to know is
|
| 2284 |
+
# which shot moved and that the sweep may simply have
|
| 2285 |
+
# reclaimed it -- the store is under ComfyUI's temp
|
| 2286 |
+
# directory, which is deleted on startup and shutdown.
|
| 2287 |
+
raise ValueError(
|
| 2288 |
+
f"{TAG}: render_from={start_at} needs hop {i + 1} in "
|
| 2289 |
+
"the cache and it is not there. Either its inputs "
|
| 2290 |
+
"changed since it rendered -- editing an earlier shot "
|
| 2291 |
+
"moves every key after it -- or the cache was swept. "
|
| 2292 |
+
f"Render hops 1-{replay_before} first, or set "
|
| 2293 |
+
"render_from back to 0.")
|
| 2294 |
|
| 2295 |
this_sampled = None
|
| 2296 |
if cached is not None:
|
|
@@ -192,7 +192,7 @@
|
|
| 192 |
}
|
| 193 |
},
|
| 194 |
"maxItems": 9,
|
| 195 |
-
"description": "At most 9 pictures on any one hop."
|
| 196 |
},
|
| 197 |
"subjects": {
|
| 198 |
"type": "object",
|
|
|
|
| 192 |
}
|
| 193 |
},
|
| 194 |
"maxItems": 9,
|
| 195 |
+
"description": "At most 9 pictures on any one hop. Keep the whole list to 9 and that always holds."
|
| 196 |
},
|
| 197 |
"subjects": {
|
| 198 |
"type": "object",
|
|
@@ -246,9 +246,7 @@ def parse_ref_plan(text):
|
|
| 246 |
_fail("ref_plan 'refs' must be an array")
|
| 247 |
|
| 248 |
refs = [_norm_ref(r, i) for i, r in enumerate(raw_refs)]
|
| 249 |
-
|
| 250 |
-
_fail(f"ref_plan has {len(refs)} references; the encoder takes at most "
|
| 251 |
-
f"{MAX_REF_IMAGES} in one plan.")
|
| 252 |
# The ordinal is derived from list position, which is what the author sees
|
| 253 |
# in the rail. This keeps `active_refs`/`ordinals` unchanged while the
|
| 254 |
# authored identity moves from a socket number to a filename.
|
|
@@ -274,6 +272,49 @@ def parse_ref_plan(text):
|
|
| 274 |
return {"refs": refs, "subjects": subjects}
|
| 275 |
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
def active_refs(refs, hop_index, wired_slots):
|
| 278 |
"""Refs live on this hop, in rail order, restricted to those whose file loaded.
|
| 279 |
|
|
|
|
| 246 |
_fail("ref_plan 'refs' must be an array")
|
| 247 |
|
| 248 |
refs = [_norm_ref(r, i) for i, r in enumerate(raw_refs)]
|
| 249 |
+
_check_hop_load(refs)
|
|
|
|
|
|
|
| 250 |
# The ordinal is derived from list position, which is what the author sees
|
| 251 |
# in the rail. This keeps `active_refs`/`ordinals` unchanged while the
|
| 252 |
# authored identity moves from a socket number to a filename.
|
|
|
|
| 272 |
return {"refs": refs, "subjects": subjects}
|
| 273 |
|
| 274 |
|
| 275 |
+
def _check_hop_load(refs):
|
| 276 |
+
"""Fail if any single shot would carry more stills than one encode takes.
|
| 277 |
+
|
| 278 |
+
The limit is per ENCODE, not per plan -- `active_refs` is what decides who
|
| 279 |
+
turns up at each one. This counted the whole rail until 1.1, so twelve
|
| 280 |
+
references scheduled three to a shot across four shots were refused against
|
| 281 |
+
a ceiling no shot came near.
|
| 282 |
+
|
| 283 |
+
`shots: null` is counted on every shot, which is the worst case rather than
|
| 284 |
+
always the truth: under `hop_script=next` an unscheduled still is dropped
|
| 285 |
+
from continuation hops, so it really only rides hop 1. Counting it
|
| 286 |
+
everywhere can only refuse a plan that would have been fine in `next` mode,
|
| 287 |
+
never accept one that would overflow -- and the ceiling is a hard encoder
|
| 288 |
+
limit, so erring toward refusing is the right direction.
|
| 289 |
+
"""
|
| 290 |
+
always = [r for r in refs if r["shots"] is None]
|
| 291 |
+
by_shot = {}
|
| 292 |
+
for r in refs:
|
| 293 |
+
for h in (r["shots"] or []):
|
| 294 |
+
by_shot.setdefault(h, []).append(r)
|
| 295 |
+
|
| 296 |
+
worst_shot, worst = None, len(always)
|
| 297 |
+
for h in sorted(by_shot):
|
| 298 |
+
if len(always) + len(by_shot[h]) > worst:
|
| 299 |
+
worst_shot, worst = h, len(always) + len(by_shot[h])
|
| 300 |
+
if worst > MAX_REF_IMAGES:
|
| 301 |
+
where = f"shot {worst_shot}" if worst_shot is not None else "every shot"
|
| 302 |
+
rides = (f" {len(always)} of them have no shots set, so they are "
|
| 303 |
+
"counted on every shot." if always else "")
|
| 304 |
+
_fail(f"{where} would carry {worst} references; one encode takes at "
|
| 305 |
+
f"most {MAX_REF_IMAGES}.{rides}")
|
| 306 |
+
# A hop that is already full has no room for the incoming frame, and
|
| 307 |
+
# _attach_pin_to_qwen drops it rather than a still -- which turns a
|
| 308 |
+
# continuation into a fresh generate with only the invisible latent pin
|
| 309 |
+
# holding it. Better said here, once, than discovered as a hard cut.
|
| 310 |
+
if worst == MAX_REF_IMAGES:
|
| 311 |
+
where = f"shot {worst_shot}" if worst_shot is not None else "every shot"
|
| 312 |
+
print(f"[{TAG}] note: {where} carries the full {MAX_REF_IMAGES} "
|
| 313 |
+
"references, leaving no slot for the incoming frame. On a "
|
| 314 |
+
"continuation hop pin_to_qwen will be skipped there; drop one "
|
| 315 |
+
"reference if you want the pin shown to the encoder.", flush=True)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
def active_refs(refs, hop_index, wired_slots):
|
| 319 |
"""Refs live on this hop, in rail order, restricted to those whose file loaded.
|
| 320 |
|
|
@@ -267,12 +267,73 @@ def main():
|
|
| 267 |
ck("render_through=%d compiles %d hop(s)" % (rt, want),
|
| 268 |
o[2].count("===== hop") == want)
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
_, log = run(quality="draft", render_through=2)
|
| 271 |
ck("draft drops the canvas", "736x416" in log and "0.30 MP" in log)
|
| 272 |
ck("draft drops the steps", "6 steps" in log)
|
| 273 |
_, log = run(quality="final", render_through=2)
|
| 274 |
ck("final leaves the canvas alone", "1280x736" in log)
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
print()
|
| 277 |
if FAIL:
|
| 278 |
print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
|
|
|
|
| 267 |
ck("render_through=%d compiles %d hop(s)" % (rt, want),
|
| 268 |
o[2].count("===== hop") == want)
|
| 269 |
|
| 270 |
+
# render_from. The replay itself needs a populated cache and a real render,
|
| 271 |
+
# so what is asserted here is the half that decides whether a user loses an
|
| 272 |
+
# hour: every misconfiguration is refused BEFORE the master tensor is
|
| 273 |
+
# allocated (~31 GB on this plan) and before a single step is sampled.
|
| 274 |
+
def raises(**kw):
|
| 275 |
+
try:
|
| 276 |
+
run(**kw)
|
| 277 |
+
except ValueError as exc:
|
| 278 |
+
return str(exc)
|
| 279 |
+
return ""
|
| 280 |
+
|
| 281 |
+
ck("render_from past render_through is refused",
|
| 282 |
+
"range is empty" in raises(render_from=5, render_through=2))
|
| 283 |
+
ck("render_from on a dry run is refused",
|
| 284 |
+
"dry run never touches it" in raises(render_from=3))
|
| 285 |
+
ck("render_from without the hop cache is refused",
|
| 286 |
+
"cache_hops must be on" in raises(render_from=3, dry_run="off",
|
| 287 |
+
cache_hops="off"))
|
| 288 |
+
o, log = run(render_from=99)
|
| 289 |
+
ck("render_from past the end falls back to hop 1",
|
| 290 |
+
"past the end" in log and o[2].count("===== hop") == 8)
|
| 291 |
+
o, log = run(render_from=1)
|
| 292 |
+
ck("render_from=1 is not a replay at all",
|
| 293 |
+
"come from the cache" not in log and o[2].count("===== hop") == 8)
|
| 294 |
+
o, _ = run(render_from=0)
|
| 295 |
+
ck("render_from=0 leaves the chain alone", o[2].count("===== hop") == 8)
|
| 296 |
+
|
| 297 |
_, log = run(quality="draft", render_through=2)
|
| 298 |
ck("draft drops the canvas", "736x416" in log and "0.30 MP" in log)
|
| 299 |
ck("draft drops the steps", "6 steps" in log)
|
| 300 |
_, log = run(quality="final", render_through=2)
|
| 301 |
ck("final leaves the canvas alone", "1280x736" in log)
|
| 302 |
|
| 303 |
+
# The reference ceiling is per ENCODE, not per plan. This counted the whole
|
| 304 |
+
# rail until 1.1, so a plan that spread its references across shots was
|
| 305 |
+
# refused against a limit no shot came near.
|
| 306 |
+
R = sys.modules["htcpack.refs"]
|
| 307 |
+
|
| 308 |
+
def rail(*specs):
|
| 309 |
+
"""specs are (tag, shots) pairs; shots=None means 'rides every shot'."""
|
| 310 |
+
return json.dumps({"refs": [
|
| 311 |
+
{"tag": t, "file": f"{t}.jpg", "shots": s} for t, s in specs]})
|
| 312 |
+
|
| 313 |
+
def ref_err(*specs):
|
| 314 |
+
try:
|
| 315 |
+
R.parse_ref_plan(rail(*specs))
|
| 316 |
+
except Exception as exc:
|
| 317 |
+
return str(exc)
|
| 318 |
+
return ""
|
| 319 |
+
|
| 320 |
+
spread = [(f"r{i}", [1 + i // 3]) for i in range(12)]
|
| 321 |
+
ck("12 references at 3 per shot is allowed", not ref_err(*spread))
|
| 322 |
+
ck("10 references all riding every shot is refused",
|
| 323 |
+
"would carry 10" in ref_err(*[(f"r{i}", None) for i in range(10)]))
|
| 324 |
+
ck("10 references landing on ONE shot is refused",
|
| 325 |
+
"shot 2 would carry 10" in ref_err(*[(f"r{i}", [2]) for i in range(10)]))
|
| 326 |
+
# The mix is the case the old plan-wide count could not see either way:
|
| 327 |
+
# 6 unscheduled + 4 on shot 3 is 10 on that shot, and 6 everywhere else.
|
| 328 |
+
ck("unscheduled references count on every shot",
|
| 329 |
+
"shot 3 would carry 10" in ref_err(
|
| 330 |
+
*([(f"a{i}", None) for i in range(6)]
|
| 331 |
+
+ [(f"b{i}", [3]) for i in range(4)])))
|
| 332 |
+
ck("the refusal explains where the unscheduled ones went",
|
| 333 |
+
"counted on every shot" in ref_err(
|
| 334 |
+
*([(f"a{i}", None) for i in range(6)]
|
| 335 |
+
+ [(f"b{i}", [3]) for i in range(4)])))
|
| 336 |
+
|
| 337 |
print()
|
| 338 |
if FAIL:
|
| 339 |
print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
|
|
@@ -219,10 +219,20 @@ def build():
|
|
| 219 |
"required": ["refs"],
|
| 220 |
"additionalProperties": False,
|
| 221 |
"properties": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
"refs": {"type": "array", "items": ref,
|
| 223 |
"maxItems": r.MAX_REF_IMAGES,
|
| 224 |
"description": f"At most {r.MAX_REF_IMAGES} "
|
| 225 |
-
f"pictures on any one hop."
|
|
|
|
|
|
|
| 226 |
"subjects": {
|
| 227 |
"type": "object",
|
| 228 |
"patternProperties": {"^[0-9]+$": subject},
|
|
|
|
| 219 |
"required": ["refs"],
|
| 220 |
"additionalProperties": False,
|
| 221 |
"properties": {
|
| 222 |
+
# The real rule is per HOP: a plan may hold more than
|
| 223 |
+
# MAX_REF_IMAGES so long as no single hop carries that many,
|
| 224 |
+
# which is what refs._check_hop_load enforces. A JSON-schema
|
| 225 |
+
# grammar cannot express "per hop", and maxItems on the array
|
| 226 |
+
# is the only lever structured output gives us -- so the
|
| 227 |
+
# writer is held to the safe subset instead: a plan whose
|
| 228 |
+
# whole rail fits on one hop can never breach the real rule.
|
| 229 |
+
# Hand-authored plans are checked against the real rule.
|
| 230 |
"refs": {"type": "array", "items": ref,
|
| 231 |
"maxItems": r.MAX_REF_IMAGES,
|
| 232 |
"description": f"At most {r.MAX_REF_IMAGES} "
|
| 233 |
+
f"pictures on any one hop. Keep the "
|
| 234 |
+
f"whole list to {r.MAX_REF_IMAGES} "
|
| 235 |
+
f"and that always holds."},
|
| 236 |
"subjects": {
|
| 237 |
"type": "object",
|
| 238 |
"patternProperties": {"^[0-9]+$": subject},
|
|
@@ -608,6 +608,7 @@
|
|
| 608 |
0,
|
| 609 |
0,
|
| 610 |
0,
|
|
|
|
| 611 |
0
|
| 612 |
],
|
| 613 |
"title": "H3 Ref2VA Chain - showcase 6x7s"
|
|
|
|
| 608 |
0,
|
| 609 |
0,
|
| 610 |
0,
|
| 611 |
+
0,
|
| 612 |
0
|
| 613 |
],
|
| 614 |
"title": "H3 Ref2VA Chain - showcase 6x7s"
|
|
@@ -611,6 +611,7 @@
|
|
| 611 |
0,
|
| 612 |
0,
|
| 613 |
0,
|
|
|
|
| 614 |
0
|
| 615 |
],
|
| 616 |
"title": "H3 Ref2VA Chain - starter"
|
|
|
|
| 611 |
0,
|
| 612 |
0,
|
| 613 |
0,
|
| 614 |
+
0,
|
| 615 |
0
|
| 616 |
],
|
| 617 |
"title": "H3 Ref2VA Chain - starter"
|