Sandpies Claude Opus 5 commited on
Commit
0730c62
·
1 Parent(s): 5298a45

Close two more bindings the fingerprint could not see

Browse files

Review of 5298a45 found the object fix covered one of four shapes a node can
use to hand a configured callable to the model. Both gaps were verified against
the real _model_fingerprint before fixing, and both produced identical
fingerprints for different settings -- the silent-wrong-frames class the batch
exists to close.

A BOUND METHOD loses everything. vars() on one proxies to the underlying
function's __dict__, which is empty, not to the instance, so only __qualname__
survived. A node registering self.forward rather than self is the object bug one
attribute away.

A functools.partial loses everything too: no __name__, no __qualname__, no
__closure__, empty __dict__. It collapsed to the same "fn()" constant a bare
instance did, with its settings sitting untouched in func/args/keywords. That is
the SLA bug one binding away.

_callable_scalars now unwraps all four shapes -- closure, instance, bound
method, partial -- depth-bounded because a partial can wrap a partial.

Corrects a claim in DEVLOG 46. It said mutable containers being excluded keeps a
sampler's step counter out of the key. That is wrong: a counter is normally a
plain int on the instance, not a container, so it IS hashed, and a node that
counts on itself between queues stops the cache hitting for as long as it is
installed. The behaviour stands -- render twice rather than serve the wrong
frames once -- but it was an invisible cost. run() now prints the model
fingerprint beside the hop-cache line, because a cache that has quietly stopped
hitting is otherwise indistinguishable from one that is merely cold.

check_cache_keys.py gains six cases: bound method, partial keywords, partial
positional args, their agreement cases, and the mutable-public-scalar tradeoff
asserted explicitly rather than left implicit. Without that last one the
regression above passed the checker clean.

tools/notes.py listed pin_to_qwen under "anything chain-wide re-renders
everything". overlap and reference pictures were already stale there from
earlier commits. All three corrected and the Starter board regenerated.

13/13 checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013orqT1xNDezvdwv4f51MdW

docs/DEVLOG.md CHANGED
@@ -2342,12 +2342,27 @@ numbers -- a reuse threshold, a window, a step cap -- carried on the
2342
  instance.
2343
 
2344
  `_object_scalars` reads an object's public scalar attributes before falling
2345
- back to its type name. It accepts a tradeoff worth writing down: a scalar
2346
- attribute that a node mutates during a run will move the fingerprint between
2347
- runs and stop the cache hitting while that node is installed. Wasteful, not
2348
- wrong, and the right direction for a pack that would rather render twice
2349
- than serve the wrong frames once. Mutable containers are still excluded,
2350
- which is what keeps a sampler's own step counter out of the key.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2351
 
2352
  The third is not a hole but a cost. `pin_to_qwen` sat in `chain_salt`, which
2353
  mixes into every hop -- while `_attach_pin_to_qwen` is called only under `if
 
2342
  instance.
2343
 
2344
  `_object_scalars` reads an object's public scalar attributes before falling
2345
+ back to its type name. Review then found two more bindings that hide an
2346
+ object behind a callable and lose it just as completely. A BOUND METHOD:
2347
+ `vars()` on one proxies to the underlying function's `__dict__`, which is
2348
+ empty, not to the instance, so a node registering `self.forward` instead of
2349
+ `self` is the same bug one attribute away. And a `functools.partial`: no
2350
+ name, no cells, no attributes of its own, everything it carries sitting in
2351
+ `func`, `args` and `keywords` -- the SLA bug one binding away. Both
2352
+ collapsed to the same four characters as a bare instance did.
2353
+ `_callable_scalars` now unwraps all four shapes.
2354
+
2355
+ The tradeoff is worth writing down plainly, because the first draft of this
2356
+ entry got it wrong and review caught that too. A public scalar the node
2357
+ mutates during a run IS hashed. Only mutable *containers* are skipped, and a
2358
+ step counter is normally a plain int on the instance rather than a dict, so
2359
+ it is hashed like any other setting. A node that counts on itself between
2360
+ queues therefore moves the fingerprint between runs and the cache stops
2361
+ hitting for as long as it is installed. That is wasteful rather than wrong,
2362
+ and it is the direction this pack should err in -- but it is a real cost,
2363
+ and it used to be an invisible one. The run now prints the fingerprint
2364
+ beside the hop-cache line, because a cache that has quietly stopped hitting
2365
+ is otherwise indistinguishable from one that is merely cold.
2366
 
2367
  The third is not a hole but a cost. `pin_to_qwen` sat in `chain_salt`, which
2368
  mixes into every hop -- while `_attach_pin_to_qwen` is called only under `if
h3_ref_chain.py CHANGED
@@ -424,6 +424,47 @@ def _model_fingerprint(model):
424
  and (isinstance(v, (str, int, float, bool)) or v is None)]
425
  return "{" + ",".join(parts) + "}" if parts else ""
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  def _scalars(obj, depth=0):
428
  """Only names and scalars -- tensors and mutable state are not stable."""
429
  if depth > 3:
@@ -440,7 +481,7 @@ def _model_fingerprint(model):
440
  if isinstance(obj, (str, int, float, bool)) or obj is None:
441
  return repr(obj)
442
  if callable(obj):
443
- return _closure_scalars(obj) + _object_scalars(obj)
444
  return type(obj).__name__ + _object_scalars(obj)
445
 
446
  h.update(_scalars(transformer).encode())
@@ -2084,8 +2125,15 @@ class HandTieClips:
2084
  hop_store = _store.HopStore(
2085
  os.path.join(folder_paths.get_temp_directory(), "h3_ref_chain_hops"),
2086
  budget_gb=float(cache_budget_gb), fps=FPS)
 
 
 
 
 
 
2087
  print(f"[{TAG}] hop cache: {hop_store.root} "
2088
- f"(budget {float(cache_budget_gb):.0f} GB)", flush=True)
 
2089
  # Note on how render_from works, since this is where the store appears:
2090
  # the leading hops are NOT seeded into prev_imgs / prev_audio /
2091
  # prev_sampled / prev_key from here. They run through the loop like any
 
424
  and (isinstance(v, (str, int, float, bool)) or v is None)]
425
  return "{" + ",".join(parts) + "}" if parts else ""
426
 
427
+ def _callable_scalars(fn, depth=0):
428
+ """Settings a callable carries, whichever way it carries them.
429
+
430
+ There are four ways a node hands a configured callable to the model and
431
+ all four have to reach the hash, because they are interchangeable from
432
+ the installing node's point of view and indistinguishable from here:
433
+
434
+ * a closure -- cells (`_closure_scalars`)
435
+ * a configured instance -- its attributes (`_object_scalars`)
436
+ * a BOUND METHOD of a configured instance -- neither. `vars()` on a
437
+ bound method proxies to the underlying *function's* `__dict__`,
438
+ which is empty, so the instance's settings were invisible; only
439
+ `__qualname__` survived. A node registering `self.forward` rather
440
+ than `self` is the object case one attribute away.
441
+ * a `functools.partial` -- neither either. It has no `__name__`, no
442
+ `__qualname__`, no `__closure__`, and an empty `__dict__`, so it
443
+ collapsed to the constant "fn()" exactly as a bare instance did.
444
+ Everything it carries is in `func`, `args` and `keywords`.
445
+
446
+ Depth-bounded because `partial` can wrap `partial`.
447
+ """
448
+ parts = [_closure_scalars(fn), _object_scalars(fn)]
449
+ if depth <= 3:
450
+ owner = getattr(fn, "__self__", None)
451
+ if owner is not None:
452
+ parts.append("@" + type(owner).__name__ + _object_scalars(owner))
453
+ inner = getattr(fn, "func", None)
454
+ if inner is not None and callable(inner):
455
+ bound = ["<" + _callable_scalars(inner, depth + 1)]
456
+ for a in (getattr(fn, "args", None) or ()):
457
+ bound.append(repr(a) if isinstance(a, (str, int, float, bool)) or a is None
458
+ else type(a).__name__)
459
+ kw = getattr(fn, "keywords", None) or {}
460
+ for k in sorted(kw, key=str):
461
+ v = kw[k]
462
+ bound.append(f"{k}=" + (repr(v)
463
+ if isinstance(v, (str, int, float, bool)) or v is None
464
+ else type(v).__name__))
465
+ parts.append(",".join(bound) + ">")
466
+ return "".join(parts)
467
+
468
  def _scalars(obj, depth=0):
469
  """Only names and scalars -- tensors and mutable state are not stable."""
470
  if depth > 3:
 
481
  if isinstance(obj, (str, int, float, bool)) or obj is None:
482
  return repr(obj)
483
  if callable(obj):
484
+ return _callable_scalars(obj)
485
  return type(obj).__name__ + _object_scalars(obj)
486
 
487
  h.update(_scalars(transformer).encode())
 
2125
  hop_store = _store.HopStore(
2126
  os.path.join(folder_paths.get_temp_directory(), "h3_ref_chain_hops"),
2127
  budget_gb=float(cache_budget_gb), fps=FPS)
2128
+ # The fingerprint is printed because it is the one cache input a
2129
+ # user cannot see and cannot derive. If a run that should have hit
2130
+ # re-rendered everything, this line moving between two runs says so
2131
+ # in one glance -- and a node that mutates a public scalar attribute
2132
+ # on itself between queues (see `_object_scalars`) is exactly the
2133
+ # case that would otherwise look like the cache is simply broken.
2134
  print(f"[{TAG}] hop cache: {hop_store.root} "
2135
+ f"(budget {float(cache_budget_gb):.0f} GB, model {model_fp})",
2136
+ flush=True)
2137
  # Note on how render_from works, since this is where the store appears:
2138
  # the leading hops are NOT seeded into prev_imgs / prev_audio /
2139
  # prev_sampled / prev_key from here. They run through the loop like any
tools/check_cache_keys.py CHANGED
@@ -30,6 +30,7 @@ to agree, because a fingerprint that always changes is a cache that never hits.
30
  """
31
  from __future__ import annotations
32
 
 
33
  import importlib.util
34
  import os
35
  import sys
@@ -147,7 +148,40 @@ def main():
147
  != fp(_Patcher(transformer=dit(_closure_configured(0.50)))),
148
  "the SLA sparsity regression")
149
 
150
- print("mutable run state is still excluded")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  shared = {"calls": 0}
152
 
153
  def _with_state():
@@ -156,9 +190,32 @@ def main():
156
  return _override
157
  before = fp(_Patcher(transformer=dit(_with_state())))
158
  shared["calls"] += 17
159
- ck("a counter the sampler advances does not move the key",
160
  before == fp(_Patcher(transformer=dit(_with_state()))),
161
- "hashing it would miss the cache on every queue")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  print()
164
  if FAIL:
 
30
  """
31
  from __future__ import annotations
32
 
33
+ import functools
34
  import importlib.util
35
  import os
36
  import sys
 
148
  != fp(_Patcher(transformer=dit(_closure_configured(0.50)))),
149
  "the SLA sparsity regression")
150
 
151
+ print("settings reached through a binding")
152
+ # A node hands the model `self.method` rather than `self`, or a
153
+ # functools.partial rather than a closure. Both are callable, both have an
154
+ # empty __dict__ of their own, and neither has cells -- so before they were
155
+ # unwrapped, both collapsed to a constant and every setting behind them was
156
+ # invisible. These are the object bug and the SLA bug in their third and
157
+ # fourth binding shapes.
158
+ class _Bound:
159
+ def __init__(self, reuse_threshold=0.05):
160
+ self.reuse_threshold = reuse_threshold
161
+
162
+ def patch(self, *a, **kw):
163
+ return None
164
+
165
+ ck("a bound method carries its instance's settings",
166
+ fp(_Patcher(transformer=dit(_Bound(0.05).patch)))
167
+ != fp(_Patcher(transformer=dit(_Bound(0.20).patch))),
168
+ "vars() on a bound method sees the function, not the instance")
169
+ ck("two identical bound methods agree",
170
+ fp(_Patcher(transformer=dit(_Bound(0.05).patch)))
171
+ == fp(_Patcher(transformer=dit(_Bound(0.05).patch))))
172
+
173
+ def _plain_override(*a, **kw):
174
+ return None
175
+
176
+ ck("a functools.partial carries its keywords",
177
+ fp(_Patcher(transformer=dit(functools.partial(_plain_override, sparsity=0.90))))
178
+ != fp(_Patcher(transformer=dit(functools.partial(_plain_override, sparsity=0.50)))),
179
+ "no __name__, no cells, empty __dict__")
180
+ ck("a functools.partial carries its positional args",
181
+ fp(_Patcher(transformer=dit(functools.partial(_plain_override, 0.90))))
182
+ != fp(_Patcher(transformer=dit(functools.partial(_plain_override, 0.50)))))
183
+
184
+ print("mutable run state")
185
  shared = {"calls": 0}
186
 
187
  def _with_state():
 
190
  return _override
191
  before = fp(_Patcher(transformer=dit(_with_state())))
192
  shared["calls"] += 17
193
+ ck("a counter in a closure cell does not move the key",
194
  before == fp(_Patcher(transformer=dit(_with_state()))),
195
+ "hashing a mutable container would miss the cache on every queue")
196
+
197
+ # The other half of that tradeoff, asserted rather than left implicit: a
198
+ # PUBLIC SCALAR attribute is hashed even when the node mutates it, so a
199
+ # node carrying a step counter on itself moves the fingerprint between runs
200
+ # and the cache stops hitting while it is installed. That is deliberate --
201
+ # this pack renders twice rather than serving the wrong frames once -- but
202
+ # it is a real cost, and the run log prints the fingerprint so it can be
203
+ # seen rather than guessed at. If this assertion ever flips, the tradeoff
204
+ # was changed and the docstring on `_object_scalars` needs to change with it.
205
+ class _Counting:
206
+ def __init__(self):
207
+ self.reuse_threshold = 0.05
208
+ self.cnt = 0
209
+
210
+ def __call__(self, *a, **kw):
211
+ return None
212
+
213
+ counting = _Counting()
214
+ seen = fp(_Patcher(transformer=dit(counting)))
215
+ counting.cnt += 7
216
+ ck("a public scalar counter DOES move the key (documented cost)",
217
+ seen != fp(_Patcher(transformer=dit(counting))),
218
+ "wasteful, not wrong -- see _object_scalars")
219
 
220
  print()
221
  if FAIL:
tools/notes.py CHANGED
@@ -357,10 +357,15 @@ to 4 off disk. Hop 6 was rendered *from* hop 5, so it has to. **Edit the
357
  earliest hop you dislike and work forward** -- that way each hop is paid for
358
  once.
359
 
360
- Anything chain-wide re-renders everything: resolution, aspect, overlap, sampler,
361
- scheduler, either shift, `ref_image_size`, `pin_to_qwen`, the LoRA stack, or
362
- **any reference picture** (keyed on pixels, so a re-crop counts even under the
363
- same filename). That is the usual reason the cache looks broken.
 
 
 
 
 
364
 
365
  Loved a hop? Put `"locked": true` and a stable `"id"` on that shot and it keeps
366
  that exact take even when its inputs move. Unrelated to `subjects.N.locked`,
 
357
  earliest hop you dislike and work forward** -- that way each hop is paid for
358
  once.
359
 
360
+ Anything chain-wide re-renders everything: resolution, aspect, sampler,
361
+ scheduler, either shift, `ref_image_size`, the checkpoint, or the LoRA stack.
362
+ That is the usual reason the cache looks broken.
363
+
364
+ Some levers are cheaper than they look. `overlap`, `pin_to_qwen` and the pin
365
+ settings only reach hop 2 onward, so flipping one re-renders hops 2+ and leaves
366
+ hop 1 on disk. A reference picture is keyed on the hops it actually rides -- so
367
+ a re-crop counts even under the same filename, but swapping the file behind a
368
+ ref that only rides hop 5 leaves hops 1-4 alone.
369
 
370
  Loved a hop? Put `"locked": true` and a stable `"id"` on that shot and it keeps
371
  that exact take even when its inputs move. Unrelated to `subjects.N.locked`,
workflows/HandTieClips_Starter.json CHANGED
@@ -1261,7 +1261,7 @@
1261
  "htc_card": "rules"
1262
  },
1263
  "widgets_values": [
1264
- "## The rules that decide whether it works\n\nNot style preferences. This is how this model fails.\n\n### 1. The prompt is additive\n\nSampling runs at **cfg 1.0 with no negative branch**. Every concept you name is\nadded, and nothing can be removed by mentioning it -- `no cut` puts the word\n*cut* in front of the encoder. **Never write a negation.**\n\n### 2. Never name the thing you want to end\n\n\"The cook stops talking\" keeps her talking. Write the state you want as **a pose\nplus a sound**:\n\n> leans back against the counter with her lips closed, and lets her eyes move\n> slowly across the room. The kitchen is quiet apart from the low hum of the\n> refrigerator.\n\nAudio is always generated. Silence written as an absence comes back as speech,\nso **silence has to be written as a sound** -- room tone, a fridge, a single\nclick. Keep it narrowband: \"faint street noise\" renders as a five-second hiss.\n\n**The ban is on the idea, not on a word list.** *Fades, passes, wanes, subsides,\ndies down, eases off* all name an ending as surely as *stops* does, and all of\nthem add the thing they describe. Ask of each sentence: is this happening, or\nhas it finished happening?\n\n### 3. A state change belongs at the END of the previous shot\n\nEvery hop opens holding the frames it was handed, and the audio pin carries the\nprevious hop's tail across the join. Nothing you write in shot 3 can make shot 3\nstart quiet. **Arrive there before the previous shot ends.**\n\n### 4. A hop that ends on dialogue keeps talking\n\nSpeech at the end of hop N opens hop N+1 and propagates down the whole chain.\nLand each line **mid-hop** and leave a non-verbal action running into the seam --\nslicing, walking, a hand on a doorframe. Give every hop with no dialogue a sound\nbed of its own.\n\n**A hop's own opening needs one too.** If anything happens before the first\nspoken line -- walking in, sitting down, turning to camera -- name the sound\nthose seconds carry. Frames with a picture and no audio assigned come back as\ndialogue nobody wrote, and ending the *previous* hop quiet does not cover it:\nthat buys you a quiet pin, not a quiet opening.\n\n### 5. A walk between two rooms is `match_cut`\n\n`join: continuous` across a real location change makes the model morph one room\ninto the other mid-movement.\n\n### 6. Set `tail` on your last shot\n\nLeft at `ongoing`, the model is told action is still underway at the final frame\nand will invent something to satisfy it. Use `settle` or `hold`."
1265
  ],
1266
  "color": "#432",
1267
  "bgcolor": "#653"
@@ -1339,7 +1339,7 @@
1339
  "htc_card": "trouble"
1340
  },
1341
  "widgets_values": [
1342
- "## When it goes wrong\n\n| symptom | cause | fix |\n|---|---|---|\n| The clip cuts to the reference photo in its last seconds | The beat finished before the frames did | Set `tail`, give the beat enough to do |\n| A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |\n| She keeps talking after you asked for quiet | You named the ending | Pose plus a sound |\n| Dialogue continues into hops that have none written | The hop before ended on speech | Land the line early; non-verbal action into the seam; a sound bed on every quiet hop |\n| A character walks between two rooms and one morphs into the other | `continuous` across a location change | `match_cut` |\n| A burst of invented dialogue over a hop's opening action | The beat speaks later, so the opening seconds have a picture and no sound assigned | Name what those seconds sound like -- footsteps, room tone -- before the line |\n| Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |\n| Ambience is a five-second hiss | Broadband wording | Narrowband, or one discrete event |\n| Two characters' faces merge | Both declared as the same `subject` | One subject number per person |\n| The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |\n| A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |\n| The film gets darker every hop | Each hop dims across its own frames and hands the darker tail on. Seam correction cannot see it | `tone_compensate=anchor`. `tone_anchor=0.35` removed ~60% of measured drift; 0.6 removes ~78% and costs ~0.5/255 more seam step. Also restate the light as a positive property in every beat |\n| A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |\n| A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |\n| A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |\n| A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |\n| The run stops, naming a reference | That row's picture is not in `h3_refs` | Drop the file on the row, or clear its picture to run without it |\n| A reference has no effect on some hop | Its `shots` list leaves that hop out | List every hop the picture should ride |\n| A deliberately dark scene keeps being brightened | `anchor` cannot tell intent from drift | `\"tone\": \"rebase\"` on that scene's first shot |\n| You cannot tell which hop broke | 114 s is a lot to scrub | `contact_sheet=on` -- one row per hop, first and last frame |\n| A pasted plan is rejected as invalid JSON | Escaped double quotes mangled in transit | Single quotes around dialogue |\n\nEvery error message names the shot or the reference it came from. Nothing\nguesses."
1343
  ],
1344
  "color": "#432",
1345
  "bgcolor": "#653"
@@ -1391,7 +1391,7 @@
1391
  "htc_card": "author"
1392
  },
1393
  "widgets_values": [
1394
- "## Let a model write your plan\n\n### From the node\n\nOpen **WRITE**. Point it at any OpenAI-compatible server -- LM Studio,\nllama-server, anything serving `/v1/chat/completions` -- pick a loaded model,\nsay what you want in plain language, and press **Write plan**. It fills SCRIPT\nand REFERENCES for you, and the pictures already on your REFERENCES rows go\nwith the request, so the model describes what it is actually looking at.\n\n- **Context 32768.** The system prompt alone is ~6,000 tokens, the reply another\n 1,000-2,000, and every reference picture costs ~260 on top.\n- **Reasoning off.** Thinking tokens come out of the same budget; a reply that\n stops before the JSON closes is the tell.\n- **Temperature 0.3.** Higher and the JSON grows trailing commas and smart quotes.\n\nServer settings are saved on this machine only, never in the workflow, so a\nshared `.json` never points at your server.\n\n**Treat the result as a strong draft, not a finished plan.** Two things are\nworth reading every time: each reference's `desc`, which can be confidently\nwrong about its own photograph, and the spoken words in every beat.\n\n### By hand, in a chat window\n\n`prompt_pack/` turns any chat model into the same writer:\n\n1. Load a model with **context 32768**, for the reasons above.\n2. Paste **`prompt_pack/SYSTEM_PROMPT.md`** into the **System Prompt** box.\n Nothing else goes in that box.\n3. **Temperature 0.3-0.5.**\n4. Describe the scene, and say how many hops and what pictures you have:\n\n > Six hops. A cook in a kitchen; she says one line, walks out into a hallway,\n > waits by a window, then comes back. I have a face photo, a photo of her\n > apron, and a photo of the kitchen.\n\n5. Each panel section has its own **JSON** disclosure at the bottom. The first\n ```json``` block goes in the one under **SCRIPT** (`shot_plan`), the second\n in the one under **REFERENCES** (`ref_plan`). Bad JSON keeps the last good\n version on screen and says so, rather than discarding your paste.\n6. **If the node rejects it, paste the error straight back into the chat.** One\n round trip usually fixes it.\n\nWant it to match a shape? Paste `prompt_pack/EXAMPLE_6_HOP.md` first.\n\nSmall models (7B-8B) hold the JSON schema but drift on the prose rules -- they\nwrite negations. Skim the beats before queueing.\n\n## Fixing one hop without re-rendering the rest\n\nSet **`cache_hops` to `on` before your first run.** It is off by default, and a\nhop that was never cached cannot be reused. Nothing to install.\n\nThe cache key **chains**, so editing shot 5 of 8 re-renders 5 to 8 and reuses 1\nto 4 off disk. Hop 6 was rendered *from* hop 5, so it has to. **Edit the\nearliest hop you dislike and work forward** -- that way each hop is paid for\nonce.\n\nAnything chain-wide re-renders everything: resolution, aspect, overlap, sampler,\nscheduler, either shift, `ref_image_size`, `pin_to_qwen`, the LoRA stack, or\n**any reference picture** (keyed on pixels, so a re-crop counts even under the\nsame filename). That is the usual reason the cache looks broken.\n\nLoved a hop? Put `\"locked\": true` and a stable `\"id\"` on that shot and it keeps\nthat exact take even when its inputs move. Unrelated to `subjects.N.locked`,\nwhich is identity text.\n\nFull detail in `PROMPTING.md`, under *Re-rolling one hop*."
1395
  ],
1396
  "color": "#432",
1397
  "bgcolor": "#653"
 
1261
  "htc_card": "rules"
1262
  },
1263
  "widgets_values": [
1264
+ "## The rules that decide whether it works\n\nNot style preferences. This is how this model fails.\n\n### 1. The prompt is additive\n\nSampling runs at **cfg 1.0 with no negative branch**. Every concept you name is\nadded, and nothing can be removed by mentioning it -- `no cut` puts the word\n*cut* in front of the encoder. **Never write a negation.**\n\n### 2. Never name the thing you want to end\n\n\"The cook stops talking\" keeps her talking. Write the state you want as **a pose\nplus a sound**:\n\n> leans back against the counter with her lips closed, and lets her eyes move\n> slowly across the room. The kitchen is quiet apart from the low hum of the\n> refrigerator.\n\nAudio is always generated. Silence written as an absence comes back as speech,\nso **silence has to be written as a sound** -- room tone, a fridge, a single\nclick. Keep it narrowband: \"faint street noise\" renders as a five-second hiss.\n\n**The ban is on the idea, not on a word list.** *Fades, passes, wanes, subsides,\ndies down, eases off* all name an ending as surely as *stops* does, and all of\nthem add the thing they describe. Ask of each sentence: is this happening, or\nhas it finished happening?\n\n### 3. A state change belongs at the END of the previous shot\n\nEvery hop opens holding the frames it was handed, and the audio pin carries the\nprevious hop's tail across the join. Nothing you write in shot 3 can make shot 3\nstart quiet. **Arrive there before the previous shot ends.**\n\n### 4. A hop that ends on dialogue keeps talking\n\nSpeech at the end of hop N opens hop N+1 and propagates down the whole chain.\nLand each line **mid-hop** and leave a non-verbal action running into the seam --\nslicing, walking, a hand on a doorframe. Give every hop with no dialogue a sound\nbed of its own.\n\n### 5. A walk between two rooms is `match_cut`\n\n`join: continuous` across a real location change makes the model morph one room\ninto the other mid-movement.\n\n### 6. Set `tail` on your last shot\n\nLeft at `ongoing`, the model is told action is still underway at the final frame\nand will invent something to satisfy it. Use `settle` or `hold`."
1265
  ],
1266
  "color": "#432",
1267
  "bgcolor": "#653"
 
1339
  "htc_card": "trouble"
1340
  },
1341
  "widgets_values": [
1342
+ "## When it goes wrong\n\n| symptom | cause | fix |\n|---|---|---|\n| The clip cuts to the reference photo in its last seconds | The beat finished before the frames did | Set `tail`, give the beat enough to do |\n| A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |\n| She keeps talking after you asked for quiet | You named the ending | Pose plus a sound |\n| Dialogue continues into hops that have none written | The hop before ended on speech | Land the line early; non-verbal action into the seam; a sound bed on every quiet hop |\n| A character walks between two rooms and one morphs into the other | `continuous` across a location change | `match_cut` |\n| Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |\n| Ambience is a five-second hiss | Broadband wording | Narrowband, or one discrete event |\n| Two characters' faces merge | Both declared as the same `subject` | One subject number per person |\n| The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |\n| A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |\n| The film gets darker every hop | Each hop dims across its own frames and hands the darker tail on. Seam correction cannot see it | `tone_compensate=anchor`. `tone_anchor=0.35` removed ~60% of measured drift; 0.6 removes ~78% and costs ~0.5/255 more seam step. Also restate the light as a positive property in every beat |\n| A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |\n| A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |\n| A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |\n| A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |\n| The run stops, naming a reference | That row's picture is not in `h3_refs` | Drop the file on the row, or clear its picture to run without it |\n| A reference has no effect on some hop | Its `shots` list leaves that hop out | List every hop the picture should ride |\n| A deliberately dark scene keeps being brightened | `anchor` cannot tell intent from drift | `\"tone\": \"rebase\"` on that scene's first shot |\n| You cannot tell which hop broke | 114 s is a lot to scrub | `contact_sheet=on` -- one row per hop, first and last frame |\n| A pasted plan is rejected as invalid JSON | Escaped double quotes mangled in transit | Single quotes around dialogue |\n\nEvery error message names the shot or the reference it came from. Nothing\nguesses."
1343
  ],
1344
  "color": "#432",
1345
  "bgcolor": "#653"
 
1391
  "htc_card": "author"
1392
  },
1393
  "widgets_values": [
1394
+ "## Let a model write your plan\n\n`prompt_pack/` in the pack folder turns any chat model into a plan writer. In\nLM Studio, or anything with a system-prompt box:\n\n1. Load a model with **context 16384 or more**. The prompt is ~4,700 tokens and\n the reply another 1,000-2,000; a small window truncates the rules and you get\n invented directive names.\n2. Paste **`prompt_pack/SYSTEM_PROMPT.md`** into the **System Prompt** box.\n Nothing else goes in that box.\n3. **Temperature 0.3-0.5.** Higher and the JSON grows trailing commas and smart\n quotes.\n4. Describe the scene, and say how many hops and what pictures you have:\n\n > Six hops. A cook in a kitchen; she says one line, walks out into a hallway,\n > waits by a window, then comes back. I have a face photo, a photo of her\n > apron, and a photo of the kitchen.\n\n5. Each panel section has its own **JSON** disclosure at the bottom. The first\n ```json``` block goes in the one under **SCRIPT** (`shot_plan`), the second\n in the one under **REFERENCES** (`ref_plan`). Bad JSON keeps the last good\n version on screen and says so, rather than discarding your paste.\n6. **If the node rejects it, paste the error straight back into the chat.** One\n round trip usually fixes it.\n\nWant it to match a shape? Paste `prompt_pack/EXAMPLE_6_HOP.md` first.\n\nSmall models (7B-8B) hold the JSON schema but drift on the prose rules -- they\nwrite negations. Skim the beats before queueing.\n\n## Fixing one hop without re-rendering the rest\n\nSet **`cache_hops` to `on` before your first run.** It is off by default, and a\nhop that was never cached cannot be reused. Nothing to install.\n\nThe cache key **chains**, so editing shot 5 of 8 re-renders 5 to 8 and reuses 1\nto 4 off disk. Hop 6 was rendered *from* hop 5, so it has to. **Edit the\nearliest hop you dislike and work forward** -- that way each hop is paid for\nonce.\n\nAnything chain-wide re-renders everything: resolution, aspect, sampler,\nscheduler, either shift, `ref_image_size`, the checkpoint, or the LoRA stack.\nThat is the usual reason the cache looks broken.\n\nSome levers are cheaper than they look. `overlap`, `pin_to_qwen` and the pin\nsettings only reach hop 2 onward, so flipping one re-renders hops 2+ and leaves\nhop 1 on disk. A reference picture is keyed on the hops it actually rides -- so\na re-crop counts even under the same filename, but swapping the file behind a\nref that only rides hop 5 leaves hops 1-4 alone.\n\nLoved a hop? Put `\"locked\": true` and a stable `\"id\"` on that shot and it keeps\nthat exact take even when its inputs move. Unrelated to `subjects.N.locked`,\nwhich is identity text.\n\nFull detail in `PROMPTING.md`, under *Re-rolling one hop*."
1395
  ],
1396
  "color": "#432",
1397
  "bgcolor": "#653"