Sandpies Claude Opus 5 commited on
Commit
8d66692
·
1 Parent(s): 99c0d4a

render_from, and a cache that stops re-rendering hops nothing touched

Browse files

Three 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 CHANGED
@@ -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": {s: _store.tensor_digest(t)
1899
- for s, t in sorted(slot_images.items())},
 
 
 
 
 
 
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:
prompt_pack/SCHEMA.json CHANGED
@@ -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",
refs.py CHANGED
@@ -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
- if len(refs) > MAX_REF_IMAGES:
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
 
tools/check_features.py CHANGED
@@ -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)))
tools/gen_schema.py CHANGED
@@ -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},
workflows/HandTieClips_Showcase.json CHANGED
@@ -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"
workflows/HandTieClips_Starter.json CHANGED
@@ -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"