Hand Tie Clips 0.3.3
Browse filesComfyUI node pack for MiniMax H3 Reference-to-Video. Chains several
generations into one continuous clip with held identity across hops.
First commit of an existing pack. Until now every edit was guarded by a
.bak sibling -- 89 of them accumulated -- which is what this replaces.
Those files stay on disk and are gitignored rather than deleted.
Contents: the four nodes, the on-node editor, the shot_plan / ref_plan
authoring format, the prompt pack for getting a local model to write
plans, two example workflows, and CLAUDE.md -- the engineering log of
what the model actually does, which is where most of the authoring rules
come from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MGjcAV8bDy93qJfmLi9kw
- .gitattributes +11 -0
- .gitignore +7 -0
- CLAUDE.md +1020 -0
- LICENSE +21 -0
- PROMPTING.md +511 -0
- README.md +337 -0
- __init__.py +28 -0
- directives.py +344 -0
- h3_ref_chain.py +1943 -0
- js/editor/media_picker.js +259 -0
- js/editor/media_strip.js +98 -0
- js/editor/plan_editor.js +580 -0
- js/editor/ref_rail.js +408 -0
- js/editor/run_panel.js +369 -0
- js/editor/templates.js +147 -0
- js/editor/widget_utils.js +413 -0
- js/h3_chain_preview.js +352 -0
- js/h3_ref_chain.css +872 -0
- js/h3_ref_chain_ui.js +239 -0
- media.py +270 -0
- plan.py +296 -0
- preview_node.py +127 -0
- prompt_pack/AUTHORING_PROMPT.md +369 -0
- prompt_pack/EXAMPLE_6_HOP.md +157 -0
- prompt_pack/README.md +71 -0
- prompt_pack/SCHEMA.json +223 -0
- prompt_pack/SYSTEM_PROMPT.md +356 -0
- pyproject.toml +19 -0
- refs.py +501 -0
- routes.py +178 -0
- store.py +297 -0
- tone.py +276 -0
- tools/build_notes.py +119 -0
- tools/build_speed_stack.py +168 -0
- tools/check_prompts.py +159 -0
- tools/check_templates.py +110 -0
- tools/check_workflows.py +233 -0
- tools/gen_example.py +83 -0
- tools/gen_schema.py +240 -0
- tools/notes.py +376 -0
- tools/seam_probe.py +96 -0
- tools/tone_probe.py +141 -0
- workflows/HandTieClips_Showcase.json +1213 -0
- workflows/HandTieClips_Starter.json +1358 -0
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Every source file in this pack is written LF. Without this, a clone on
|
| 2 |
+
# Windows checks out CRLF and the next commit shows every line of every file
|
| 3 |
+
# as changed -- which would bury real diffs in a pack whose whole value is
|
| 4 |
+
# being able to see what changed between renders.
|
| 5 |
+
* text=auto eol=lf
|
| 6 |
+
|
| 7 |
+
# Keep binary-ish assets out of the text pipeline.
|
| 8 |
+
*.png binary
|
| 9 |
+
*.jpg binary
|
| 10 |
+
*.mp4 binary
|
| 11 |
+
*.safetensors binary
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
|
| 4 |
+
# Every source in this pack has .bak siblings -- the reflex that stands in for
|
| 5 |
+
# version control here. They are working files, not releases.
|
| 6 |
+
*.bak
|
| 7 |
+
*.bak*
|
|
@@ -0,0 +1,1020 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
+
|
| 5 |
+
## What this is
|
| 6 |
+
|
| 7 |
+
`ComfyUI-Hand-Tie-Clips` chains multiple MiniMax H3 Reference-to-Video generates into one longer clip. Each hop after the first is guided by the previous hop's last overlap frames + audio via stock `MiniMaxH3AddGuide` (requires a ComfyUI build with `MiniMaxH3AddGuide`, ComfyUI PR #15439). It is explicitly **not** the H3-Multishot/airlock pack; do not merge that pack's syntax in here.
|
| 8 |
+
|
| 9 |
+
Two nodes ship: `HandTieClips` (**H3 Ref2VA Chain**, the whole pipeline) and `HTCContinuityState` (legacy continuity text, superseded — see below).
|
| 10 |
+
|
| 11 |
+
It lives inside a full ComfyUI checkout (`D:\ComfyUI\custom_nodes\ComfyUI-Hand-Tie-Clips`) and imports directly from ComfyUI internals (`comfy.model_management`, `comfy.samplers`, `comfy.utils`, `comfy_extras.nodes_minimax_h3`, `comfy_extras.nodes_custom_sampler`, `comfy_extras.nodes_audio`, `nodes`, `folder_paths`). It only runs as a loaded custom node inside that ComfyUI instance — there is no standalone entrypoint, package manager, or test harness.
|
| 12 |
+
|
| 13 |
+
## The target sampling regime
|
| 14 |
+
|
| 15 |
+
**Turbo LoRA at 4-8 steps.** That is what the node is built for and what the shipped workflow uses (8 steps, `minimax_h3_ref2v_turbo_4step`). High-step runs (14+) are diagnostic instruments to check whether an effect exists at all; **any change must be re-validated at 8 steps before it counts as working**, because at low step counts seed structure dominates and can swamp conditioning differences. Never report a 14-step result as validation on its own.
|
| 16 |
+
|
| 17 |
+
## Development workflow
|
| 18 |
+
|
| 19 |
+
No build step, linter config, or test suite (`pyproject.toml` declares zero dependencies beyond ComfyUI). The dev loop is:
|
| 20 |
+
|
| 21 |
+
- **Python changes**: restart ComfyUI to reload the node.
|
| 22 |
+
- **JS changes** (anything under `js/`): hard-refresh the ComfyUI browser tab.
|
| 23 |
+
- Validate by loading `workflows/HandTieClips_Starter.json` and queuing a run. Two signals:
|
| 24 |
+
- the `[HandTieClips]`-prefixed console lines (hop count, shot plan table, reference register table, model-patch log, cache hits, overlap drop, final frame/duration summary);
|
| 25 |
+
- the node's third output, **`info`**, which carries the fully assembled per-hop prompts. Wire it to a Preview Text node to read exactly what each hop sent to the text encoder. The editor shows what you wrote; `info` shows what the compiler made of it, which is not the same thing.
|
| 26 |
+
|
| 27 |
+
## Architecture
|
| 28 |
+
|
| 29 |
+
Python modules feed four nodes, plus a DOM editor in `js/`. `h3_ref_chain.py` holds `HandTieClips.run()` and the prompt-assembly helpers; `plan.py`, `directives.py`, `refs.py`, `store.py` and `tone.py` are parsers, compilers and estimators with no ComfyUI imports in their math, which is what makes them testable outside a running server. `routes.py` serves the editor its vocabulary. `preview_node.py` and `tone.py` each register a standalone side node.
|
| 30 |
+
|
| 31 |
+
### Two independent reference channels — do not conflate them
|
| 32 |
+
|
| 33 |
+
The core invariant, and the thing most likely to be broken by a careless change:
|
| 34 |
+
|
| 35 |
+
1. **References** (files in `<ComfyUI input>/h3_refs`, named by each ref's `file`) → bound into the tokenizer as `<Picture 1..N>` *and* into the DiT as `minimax_refs`. Identity/place stills. On `hop_script=next`, they ride **hop 1** unless a ref's `shots` lists later hops. They are never the join pin. Riding a face/outfit plate of a different room on hop 2+ opens a new Ref2VA generate (chain_00030..00034: commercial-kitchen face+outfit beat the residential pin; hop 2 dropped the apron).
|
| 36 |
+
2. **The AddGuide pin** (`MiniMaxH3AddGuide.execute`, once per hop after 0) → DiT `minimax_keyframes` at t=0 only. **The text encoder never sees this pin.** The master throws the overlapped frames away after decode.
|
| 37 |
+
|
| 38 |
+
`pin_to_qwen` (`_attach_pin_to_qwen`) optionally makes the *text encoder* aware of the incoming state, since channel 2 is otherwise invisible to it: `last frame` is **`<Picture 1>`** of the previous hop's last frame (identity stills that still ride shift to Picture 2+), `pin clip` appends the overlap frames as an extra `<Video>` (~2 fps, no audio), `both` does both, `off` neither. Additive to channel 1's slots (max 9 images / 3 videos). **`last frame` is the default and stays that way.** Appending the pin after the stills made it Picture 4 against three stills (chain_00034).
|
| 39 |
+
|
| 40 |
+
**DiT pin (hop 2+):** keep the previous hop’s **sampler AV latent** and call `MiniMaxH3MotionContext` (already installed) with `context_length` matching overlap (22) and `audio_context_length=24`. That is the Multishot `context_pin` path: no pixel VAE round trip, audio end-aligned on this clip’s timeline. If the node is missing, fall back to `MiniMaxH3AddGuide` on decoded frames and log it. A cache hit no longer forces that fallback: the sampler latent is stored beside the frames and restored on a hit (see the hop store below). Hop 1 `start_image` still uses AddGuide.
|
| 41 |
+
|
| 42 |
+
**5 s is not a join-validation canvas.** Multishot: 124 f drops the airlock. `00028` 1→2 joined at 8 s. Verify after this pass is 2 × **8 s**. `join=continuous` at 5 s logs a note. A different seed on the 5 s pin-only graph did join (not a strict pass) — 777777 was join-hostile; `seed_per_shot` stays ON. Seed is not a substitute for the latent pin or an 8 s airlock budget.
|
| 43 |
+
|
| 44 |
+
### 1. Shot plan (`plan.py`)
|
| 45 |
+
|
| 46 |
+
A plan is one JSON string in the `shot_plan` widget: an ordered list of shots, one per hop. **The shot count IS the hop count**, which removes the whole class of "3 blocks but chains=4" mismatches — when `shot_plan` is non-empty it is authoritative, `hop_script` is forced to `next`, and `chains` is ignored with a log line (`h3_ref_chain.py`, `run()`; search `shot_plan present -> hop_script=next`).
|
| 47 |
+
|
| 48 |
+
Shot fields (all optional except `beat`): `beat`, `directives`, `prose`, `seed`, `steps`, `duration`, `locked`, `id`. There is no shot-level `refs` field -- a reference activates itself through its own `shots` list in `ref_plan`, and the shot-level one was removed 2026-08-27 (parsed and printed, read by nothing, and silently dropped by the editor). Per-shot `seed`/`steps`/`duration` really are per-shot — sigmas are built per distinct step count behind `sigma_cache` and durations are validated up front so a bad value fails before any sampling.
|
| 49 |
+
|
| 50 |
+
**`refs` on a shot is parsed and printed but not consumed.** Ref activation comes only from each ref's own `shots` field in `refs.py`. The editor deliberately does not expose it, so nothing implies it works. Either wire it up or drop the field.
|
| 51 |
+
|
| 52 |
+
`prompt` is the legacy path, read only when `shot_plan` is empty (`_parse_shots`/`_expand_shots` + `hop_script=verbatim|next`).
|
| 53 |
+
|
| 54 |
+
### 2. Directive compiler (`directives.py`) — the differentiator
|
| 55 |
+
|
| 56 |
+
`join` / `camera` / `framing` / `pace` / `tail` compile to vetted prose from `VOCAB`, so improving a phrase improves every existing plan at once. `join` is skipped on hop 0, which has nothing to join to; `tail` is the only axis with a default (`ongoing`).
|
| 57 |
+
|
| 58 |
+
**Phrasing rules — read before editing VOCAB:**
|
| 59 |
+
|
| 60 |
+
1. **AFFIRMATIVE ONLY.** Sampling runs through `BasicGuider` at cfg 1.0 with no negative branch, so every concept named is additive and cannot be subtracted. "no cut" puts `cut` in front of the encoder. This is not a style preference — it is the load-bearing finding the whole rework was built on.
|
| 61 |
+
2. **NO PRONOUNS.** The beat text owns the subject; plural "they" has been observed rendering two people from one reference.
|
| 62 |
+
3. **NO ENUMERATED DETAIL.** Naming a prop or garment to control it adds it. Directives cover camera, join, framing, pace, tail only.
|
| 63 |
+
4. **ONE SENTENCE PER ENTRY.** These get concatenated; long entries crowd the beat.
|
| 64 |
+
|
| 65 |
+
`check_coherence` warns (never raises) when `join=continuous` is asked for alongside a framing change with a held camera — that combination implies a cut, so the model picks one.
|
| 66 |
+
|
| 67 |
+
### 3. Reference register (`refs.py`)
|
| 68 |
+
|
| 69 |
+
Fixes two real bugs:
|
| 70 |
+
|
| 71 |
+
- **Ordinal instability.** Core assigns `<Picture N>` 1-based in list order (`comfy/text_encoders/minimax.py:148-202`) and refs are dense-packed, so unplugging a `Load Image` silently renumbers every later one, and any prompt naming a literal ordinal then points at the wrong picture. Here each ref carries a stable `@tag` and a **filename**; ordinals are **derived per hop** and `resolve_tags` rewrites the prose.
|
| 72 |
+
- **Subject collapse.** Declaring every picture as a photo of `<Subject 1>` makes the model render the average of two different people. `subject` numbers group pictures per person; `retention` (`fully_preserved` / `partially_copy` / `reference`) generates the carry-over prose.
|
| 73 |
+
|
| 74 |
+
`run()` keeps a raw `slot -> tensor` map *before* `_collect_ref_images` dense-packs it, because a `@tag` is pinned to one picture. Since 2026-08-28 `slot` is **derived from the ref's position in the rail**, not authored — it is an ordering index, not a socket number. The image dict and the cited ordinals are built from the same `hop_active` list, so they cannot drift apart.
|
| 75 |
+
|
| 76 |
+
**Only subject-bearing refs are identities.** `_identity_lock` takes `identity_ordinals` and `n_subjects` from the register; without them it falls back to "every wired still is an identity", which is right when nothing better is known but wrong the moment a register is wired — it told the encoder a photograph of a kitchen had a face and hairstyle to match exactly, on every hop. `n_subjects` also drives number agreement: two photographs of one person is one identity, and plural phrasing has been observed rendering two people from one reference.
|
| 77 |
+
|
| 78 |
+
**`HTCContinuityState` was meant to be retired by this and has not been.** It is still registered, still wireable, still parsed (`_parse_state` / `_state_header`). It cannot express two people at all (one `character_id`, one `characters` key) and names people by an arbitrary id while `<Picture N>` is positional, with nothing joining the two — precisely the subject-collapse setup. Prefer `ref_plan`; the `setting_*` half is the only part `ref_plan` does not cover. `run()` warns when both define identity text, but only when a character field actually holds content — the node emits a `characters` entry for its `character_id` even when every field is blank, so the key's presence means nothing.
|
| 79 |
+
|
| 80 |
+
### 4. Hop store (`store.py`)
|
| 81 |
+
|
| 82 |
+
Lossless FFV1 (`rgb48le`) video + a float32 `.npy` waveform + a `.latent.pt` sidecar per hop under ComfyUI's temp dir, enabled by `cache_hops`, LRU-evicted above `cache_budget_gb`. **Needs `ffmpeg` on PATH** or it raises.
|
| 83 |
+
|
| 84 |
+
**The latent sidecar is what makes the cache useful past hop 1** (added 2026-08-27). It stores this hop's sampler output so a hit can seed the *next* hop's Motion-Context pin. Without it, a hit left `prev_sampled` empty, the next hop predicted the AddGuide fallback, and because the mechanism is in the per-hop key that key no longer matched what was on disk -- so **nothing past hop 1 could ever hit, and the hop after a hit was joined by the weaker mechanism.** `cache_hops=on` was measurably worse than off. Verified in-browser: hop 1 hit, hop 2 logged `previous hop has no sampler latent (cache hit); AddGuide pixel pin`, hops 2-3 re-rendered.
|
| 85 |
+
|
| 86 |
+
The sidecar is optional in both directions: `has()` ignores it, so entries written before this change still hit (returning `latent=None` and the old fallback), and a latent that will not serialise is logged and skipped rather than failing the hop. It is `torch.save`/`torch.load(weights_only=False)` because `samples` is a `comfy.nested_tensor.NestedTensor` -- a plain Python class holding a tensor list, which `weights_only=True` refuses. Cost is ~2.9% of the entry (measured: 8.4 MB against a ~285 MB video at 0.3 MP / 243f). **Verified end to end 2026-08-27:** fresh render 186.9 s writing three sidecars, then a `cache_budget_gb` nudge re-queue at 17.97 s with all three hops logging `loaded from cache`, zero DiT loads, zero SLA passes, and the same `drift -80 ms` as the fresh run. Invalidation confirmed in the same sitting: LoRA strength 0.800 -> 1.000 produced **zero** cache hits and re-rendered every hop, so `_model_fingerprint` does see a strength change.
|
| 87 |
+
|
| 88 |
+
**The key chains**: each hop's key mixes in the previous hop's key plus a `chain_salt` of everything constant across the run — canvas, sampler, scheduler, shifts, `pin_to_qwen`, tensor digests of every wired ref / voice / reference video / start image, **and `_model_fingerprint`**, because a hop rendered under different LoRAs or a different attention path is not the same hop. The *pin mechanism* is keyed per hop rather than chain-wide (Motion-Context vs the AddGuide fallback produce different frames, and which one runs depends on whether the previous hop was a cache hit). So editing shot 1 correctly invalidates 2..N. That is correct behaviour and must be surfaced in any UI, or it reads as a bug.
|
| 89 |
+
|
| 90 |
+
**Anti-ratchet levers were dead until 2026-08-27.** `pin_renorm` and `pin_noise` both ran through `_condition_pin_latent`, which called `.std()` on `latent["samples"]` — a `comfy.nested_tensor.NestedTensor`, which has no `.std()`. Every hop logged `pin conditioning skipped (AttributeError(...))` and both widgets did nothing; the line looked like routine noise next to the other per-hop output. `NestedTensor` is a trap to write against: it *does* have `.float()`, `.cpu()` and `.shape`, but `.shape` returns `tensors[0].shape` — the video component's, silently speaking for both — so the noise draw would have been sized to the video and broadcast onto the audio.
|
| 91 |
+
|
| 92 |
+
Now conditioned **per component** via `_latent_parts` / `_rebuild_latent_samples`, with `anchor_std` a list of one sigma per stream. Per-stream is also the correct semantics, not just the working one: in a synthetic hop-3 case with video inflated 1.60x and audio 2.51x, the per-stream corrections were 0.6365 and 0.3988 — one global scale would have left the audio ~60% hot. Verified offline against the real class; not yet exercised in a render, because both levers ship off.
|
| 93 |
+
|
| 94 |
+
**Why 16-bit:** a cached hop's last frame becomes the next hop's Qwen pin and its AddGuide guide, so an 8-bit round trip would make a resumed chain diverge from an uninterrupted one — the cache would change the output, defeating the point.
|
| 95 |
+
|
| 96 |
+
A shot's `locked` flag pins it to its last render regardless of hash, resolved through `set_pointer`/`get_pointer` keyed on the shot `id` (the content key has moved by definition, so only the pointer can find it).
|
| 97 |
+
|
| 98 |
+
The master is **preallocated** — `total_frames` is known up front — and slice-written, rather than grown with `torch.cat`, which allocates a fresh full-size tensor every hop while `prev_imgs` and `imgs` are also live. Only the overlap tail stays resident between hops.
|
| 99 |
+
|
| 100 |
+
### 5. The MODEL wire stays drawn
|
| 101 |
+
|
| 102 |
+
The speed stack is **four separate nodes in the graph**, not a widget on this one:
|
| 103 |
+
|
| 104 |
+
UNETLoader → LTX_lora_loader → H3AdaLNLoRAFix → MiniMaxLowVRAMAttention → H3SLAAttention → HandTieClips
|
| 105 |
+
|
| 106 |
+
CLIP comes off `LTX_lora_loader`'s second output, not straight from the encoder, or the text-side keys H3 LoRAs carry (`condition_proj`, `token_refiner`) are silently discarded.
|
| 107 |
+
|
| 108 |
+
A `model_patches` widget that folded all four into this node existed briefly and was **reverted by decision**: it made the graph shorter and the node harder to read, and legibility is the point. Do not re-collapse them. If it ever comes back it belongs behind the editor's advanced section, not as a bare JSON widget.
|
| 109 |
+
|
| 110 |
+
Note the wire order differs from the pre-rework graph, which ran SLA *before* low-VRAM. SLA's own documentation puts it last; that ordering was researched and is deliberate.
|
| 111 |
+
|
| 112 |
+
**`_model_fingerprint` exists because of this revert.** With the patches drawn upstream, this node cannot see their settings, so a hop cached under one LoRA stack would be served under another — silently wrong frames. The fingerprint hashes `sorted(model.patches.keys())`, the per-key `strength_patch` / `strength_model` scalars, and the scalar half of `model_options["transformer_options"]`, and that goes into `chain_salt`.
|
| 113 |
+
|
| 114 |
+
- Patch **values** are tensors and are deliberately not hashed.
|
| 115 |
+
- `model.patches_uuid` is *not* usable: `add_patches` assigns a fresh `uuid4()` on every call, so it would bust the cache every run.
|
| 116 |
+
- **Known collision:** two different LoRAs touching an identical key set at identical strengths fingerprint the same. Rare; the alternative is a full state-dict walk per run.
|
| 117 |
+
|
| 118 |
+
### Prompt assembly
|
| 119 |
+
|
| 120 |
+
`hop_script=next` (forced under a shot plan): hop 1's block is the only full prompt; later hops get the new beat plus a live-frame citation. Register `subject_prose` stays off hop 2+ (it is a `subject_definitions` block). Unscheduled stills stay off hop 2+; `@tag` of a person still resolves to `<Subject N>`. `_assemble_next` composes `state_header` ahead of the beat when `HTCContinuityState` is wired.
|
| 121 |
+
|
| 122 |
+
**Official H3 tags and prompt methods still work here** (user 2026-08-26). This pack is a hop compiler and a join pin, not a new prompt language. Do not invent beat-keywords or pack-specific interpretation (`Silence.` as a spell, “still talking”, “says one short line”, folklore about leftover duration). Use the MiniMax H3 Ref2VA contract the encoder already knows:
|
| 123 |
+
|
| 124 |
+
- six fields in order: `subject_definitions` / `summary` / `retention_analysis` / `detailed_description` / `overall_soundscape` / `non_diegetic_music`
|
| 125 |
+
- labels `<Subject N>`, `<Picture N>`, `<Video N>`, `<Audio N>`
|
| 126 |
+
- speakers `(S1)` and dialogue `<d>[English] …</d>` (exact words; `<cutoff>` if the line overruns the hop; `<scenetrans>` if a line crosses a join)
|
| 127 |
+
- `[Shot N]` / `At MM:SS.mmm` inside `detailed_description`
|
| 128 |
+
- `overall_soundscape` = ambience + physical + non-verbal only; `N/A` only when the hop is requested silent throughout; do not put dialogue there
|
| 129 |
+
- `non_diegetic_music: N/A` when there is no score
|
| 130 |
+
|
| 131 |
+
`_is_full_h3_prompt` already detects `subject_definitions:` / `integrated_multimodal_description:` and routes through `_continue_prompt`. A shot-card beat may *be* that official block. Short prose on a card is a convenience path, not a dialect. When a hop is a full H3 prompt, do not wrap it in invented closer sentences that fight the official fields.
|
| 132 |
+
|
| 133 |
+
Hop 2+ carries **no identity header at all**. `_assemble_next` has no `identity_header` parameter (removed 2026-08-27 along with the dead `_extract_identity_header`/`_section_block` pair). Register `subject_prose` is a `subject_definitions` block, and putting one on hop 2+ made it a second Ref2VA generate -- `chain_00033_`.
|
| 134 |
+
|
| 135 |
+
### 6. The editor (`js/editor/*`, `routes.py`)
|
| 136 |
+
|
| 137 |
+
The node is driven by a DOM panel, not by hand-written JSON. Five files:
|
| 138 |
+
|
| 139 |
+
| file | job |
|
| 140 |
+
|---|---|
|
| 141 |
+
| `js/h3_ref_chain_ui.js` | extension entry, the sample preview, mounts the panel |
|
| 142 |
+
| `js/editor/widget_utils.js` | widget hiding, the height guard, vocab fetch, DOM helpers |
|
| 143 |
+
| `js/editor/plan_editor.js` | Simple/Shots toggle, shot cards, JSON escape hatch |
|
| 144 |
+
| `js/editor/ref_rail.js` | reference register rows and subject blocks |
|
| 145 |
+
| `js/editor/run_panel.js` | the shot-independent dials, grouped (added 2026-08-27) |
|
| 146 |
+
|
| 147 |
+
**`shot_plan` and `ref_plan` remain the only source of truth.** The panel reads them and writes straight back, so a workflow authored in the editor and one typed by hand are the same file. Never add a parallel store.
|
| 148 |
+
|
| 149 |
+
**The run panel is the same rendering-layer contract, applied to the native widgets.** Every control writes to `widget.value` and then fires `widget.callback` — that callback is not optional decoration, it is how `control_after_generate` stays bound to `seed`. Four groups (`output`, `sampling`, `join & pin`, `cache`) in `GROUPS`; a name the build does not define is skipped, so the list can carry a widget that only exists on a newer Python side. Four things worth knowing before editing it:
|
| 150 |
+
|
| 151 |
+
- **Ownership decides hiding, not the group list.** `ownedNames()` reports the widgets the panel actually *drew*, and `applyVisibility` hides exactly those. A dial whose type the panel cannot render therefore stays visible as a native widget instead of vanishing off the node. `sync()` must run **before** `setWidgetVisibility`, or a stale build hides something the panel stopped drawing.
|
| 152 |
+
- **`chains` and `hop_script` are dropped in Shots mode**, with a note saying why. `run()` ignores `chains` and forces `hop_script=next` the moment a shot plan is present, so drawing them would be two controls that do nothing. The digest reads the hop count from the plan for the same reason.
|
| 153 |
+
- **Never read `w.type` or `w.options` directly — use `widgetType(w)` / `widgetOptions(w)`.** `hideWidget` overwrites `w.type` with `"hidden"` and swaps `w.options` for a flagged copy, stashing the originals in `w._h3Saved`. The run panel hid the twenty dials it owned on its first build; when a shot plan loaded and flipped the mode to Shots, the suppression key changed, `build()` re-ran, and every widget now reported type `"hidden"` — so `fieldFor` rejected all of them and the panel emptied itself. It only reproduces on a workflow that *arrives* in Shots mode, since a fresh node builds once and never rebuilds. An empty build also clears `builtFor`, so `sync()` retries instead of caching the failure for the life of the node.
|
| 154 |
+
- **Help text is `widget.options.tooltip`**, i.e. the `tooltip` from `INPUT_TYPES`. Do not retype those sentences in JS — same rule as the vocab route, same reason.
|
| 155 |
+
|
| 156 |
+
The summary line deliberately omits the seed: `control_after_generate` rewrites it after every queue without telling the panel, so a seed shown there would be wrong more often than right. That staleness is also why the panel re-reads every field when it is opened.
|
| 157 |
+
|
| 158 |
+
Two recipes here are load-bearing and both are ported from `PromptMasterLD/js/claude_prompt.js`:
|
| 159 |
+
|
| 160 |
+
- **Four-flag widget hiding.** Classic LiteGraph only needed `computeSize = [0,-4]`. Vue Nodes 2.0 filters on `options.hidden | hideInPanel | canvasOnly`; without those flags every hidden dial reappears as a raw form. Invisible in packs with three widgets, unmissable at 21. Multiline STRING widgets are real DOM textareas, so their element has to be hidden too or it floats over the panel.
|
| 161 |
+
- **The `_h` fixpoint height guard.** `_arrangeWidgets` runs every frame and grows the node when `panelTop + panelH + 4 > size[1]`; reporting a height derived from `node.size[1]` makes that true forever (~130 px of growth per frame). Report from an independent stored `_h`, updated in `onResize`. `chromeCompute`'s `_measuring` flag measures the frontend's own `computeSize` rather than re-deriving it.
|
| 162 |
+
|
| 163 |
+
**Dropdown options come from `routes.py`, never from a copy in JS.** `GET /h3_ref_chain/vocab` serves `directives.VOCAB` *with its prose*, so hovering an option shows the exact sentence it will put in the prompt. A second copy in JavaScript would defeat the reason `directives.py` exists. The route is read-only — no writes, no filesystem; the reference-upload route is a separate unbuilt thing.
|
| 164 |
+
|
| 165 |
+
Simple mode clears `shot_plan` (stashing it in `node.properties.h3_plan_backup` first) so `run()` cannot silently prefer a stale plan over the visible prompt.
|
| 166 |
+
|
| 167 |
+
### 7. Tone compensation (`tone.py`)
|
| 168 |
+
|
| 169 |
+
The H3 denoiser applies a tone bias to each generated segment, so the master steps in brightness at every seam. The estimator is ported from [`rkfg/ComfyUI-MiniMaxH3-ToneCompensate`](https://github.com/rkfg/ComfyUI-MiniMaxH3-ToneCompensate) (MIT, as is this pack). Three modes: `frame_shift` (per-frame per-channel additive), `gain_bias` (global affine), `lut` (tone curve). `frame_shift` is the one that suits our case, because the target's first frames are the model's *regeneration* of the source's last frames — same content, not a pixel-wise transform.
|
| 170 |
+
|
| 171 |
+
**A downstream node cannot do this job, and that is the whole reason `tone_compensate` is a widget on the chain node.** The estimate needs both copies of the overlap: hop N's tail and hop N+1's regeneration of it. The join drops the second (`master_imgs[write_pos:...] = imgs[overlap_n:]`). By the time images leave `run()`, only one copy survives, and a node there would be comparing frames ~0.9 s apart in scene time — measuring content change as much as tone. `HTCToneCompensate` ships anyway for hand-built chains and for A/B work; it is not the fix for this node.
|
| 172 |
+
|
| 173 |
+
**The call site is the design.** It sits in `run()` where the render and cache-hit paths converge, and its position relative to three neighbours is deliberate:
|
| 174 |
+
|
| 175 |
+
- **After `hop_store.put`** — the cache holds *raw* hops, so the mode stays out of the hop key. Switching modes costs nothing instead of invalidating ~285 MB per entry. Correct on the way out, hit or miss.
|
| 176 |
+
- **Before the master write** — the delivered video is corrected.
|
| 177 |
+
- **Before `prev_imgs = imgs[-tail_n:].clone()`** — so hop N+1 is measured against hop N's *corrected* tail, which is what makes each hop's shift cumulative and lands the whole chain on hop 1's tone. **This does not stop the generator drifting, and an earlier version of this note wrongly claimed it did.** Measured 2026-08-28: with tone on and all three hops rendering fresh, hop 3 was generated from a corrected `prev_imgs` and still came out +3.48/255 above raw hop 2 — so it needed a `2d` correction, not `d`. `prev_sampled`, the Motion-Context latent, dominates the conditioning and is never touched by a pixel fix. Correcting here is still right (it is free, and it keeps `prev_imgs` consistent with the master), but the benefit is a clean cumulative repaint, not a cure for the drift at source.
|
| 178 |
+
|
| 179 |
+
**Two consequences, both deliberate:**
|
| 180 |
+
|
| 181 |
+
- **Enabling any mode clamps the master to 0..1**, including hop 1, which is otherwise unclamped VAE output. Correcting hops 2+ and not hop 1 would make the master inconsistent with itself.
|
| 182 |
+
- **The latent path is untouched.** Motion-Context forwards `prev_sampled`, a latent; pixel correction never reaches it. `_condition_pin_latent` matches per-stream **std**, not mean — and a brightness shift is a shift in the *mean*. So nothing currently anchors latent mean. Known gap; do not build a latent-mean anchor speculatively.
|
| 183 |
+
|
| 184 |
+
**Measured, 2026-08-28 — the real number, off the hop cache** (`chain_00052`, 3 hops x 243f, overlap 22, 7 steps res_multistep, Motion-Context pin on both joins). `tools/tone_probe.py` pairs hop N's last 22 frames with hop N+1's first 22 -- the model's regeneration of the same content, which survives only in the cache:
|
| 185 |
+
|
| 186 |
+
| pair | r | g | b | luma |
|
| 187 |
+
|---|---|---|---|---|
|
| 188 |
+
| hop 1 -> 2 | +0.00995 | +0.00973 | +0.00914 | **+2.45/255** |
|
| 189 |
+
| hop 2 -> 3 | +0.01003 | +0.01129 | +0.01085 | **+2.73/255** |
|
| 190 |
+
| cumulative | +0.01998 | +0.02103 | +0.01999 | **+5.18/255** |
|
| 191 |
+
|
| 192 |
+
Three facts follow, and they are the justification for the feature. It is **achromatic** -- r/g/b move together within ~0.001, so it is a luma bias, not a colour cast. It **accumulates linearly** -- ~2.5/255 per hop with nothing pulling it back, so a 5-hop chain lands near +10/255. And it is **brighter**, the opposite sign to the upstream README's "runs darker"; likely because we pin with a Motion-Context latent and never take the decode->encode round trip his workflow does.
|
| 193 |
+
|
| 194 |
+
**Verified end to end, 2026-08-28** (seed `700637295460319`, `chain_00053` = `frame_shift`, `chain_00054` = `off`, both served from the same raw cache so content is identical):
|
| 195 |
+
|
| 196 |
+
- **The mode is not in the hop key.** The `off` run hit all three keys written by the `frame_shift` run (`c442d296`, `4854c20f`, `2e4c1e14`) — 17.5 s against 171 s, zero re-renders. Switching modes costs nothing, which is the whole reason the call site sits after `hop_store.put`.
|
| 197 |
+
- **Chain drift 5.57/255 -> 0.29/255**, a 95% reduction, and it stops accumulating. Applied shifts measured in the delivered video were `0`, `-1.71/255`, `-5.26/255`, matching the logged notes exactly. Segment 1 came out byte-identical, so hop 1's clamp was a no-op on this render.
|
| 198 |
+
- **Do not use "seam -> 0" as the success metric.** It is wrong and it will make you overcorrect. Seam steps went `+1.40 -> -0.16` and `+1.41 -> -2.06`; the second is not an overshoot, it is the scene's own darkening across the cut, which correction should leave alone. The arithmetic closes: uncorrected seam minus true tone bias predicts `-0.31` and `-2.28` against measured `-0.16` and `-2.06`. Judge on cumulative drift, via `tone_probe`.
|
| 199 |
+
- **Hop N needs a correction of N-1 times the per-hop bias** (hop 2 got `d`, hop 3 got `2d`), because `prev_sampled` — the Motion-Context latent the next hop is actually generated from — is never corrected. This is a pixel fix for a latent drift, so the required shift grows linearly: ~5/255 by hop 3, ~10/255 by hop 5, ~23/255 by hop 10. Since a positive drift is *subtracted*, the far-end failure is **crushed blacks** clipping at 0. Fine over the 3-5 hops this pack recommends; not a fix for long chains, and the first hard cost attached to the latent-mean gap below.
|
| 200 |
+
- **The per-frame drift is not flat across the overlap.** It ramps in over ~6 frames, plateaus, then dips sharply at frame 17 in *both* joins — likely a VAE temporal chunk boundary. `frame_shift` uses `drift[-1]`, which sits in that dip and under-corrects by ~0.38/255; the mean of the last 4 overlap frames would be marginally better and is the obvious tuning knob if the residual ever matters. Upstream's reason for `drift[-1]` (an exact internal join) does not apply here, because the master drops the overlap.
|
| 201 |
+
|
| 202 |
+
**All three modes measured, 2026-08-28** (seed `700637295460319`, four masters over identical cached pixels: `chain_00054` off, `00053` frame_shift, `00055` gain_bias, `00056` lut):
|
| 203 |
+
|
| 204 |
+
| mode | worst residual drift | dark-clipped px, seg 3 | notes |
|
| 205 |
+
|---|---|---|---|
|
| 206 |
+
| off | — | 0.32% | |
|
| 207 |
+
| `frame_shift` | +0.38/255 | **0.96%** | `rx-0.0076`, then `-0.0207` |
|
| 208 |
+
| `gain_bias` | **0.20/255** | 0.56% | `rx0.9854-0.0035`, then `rx0.9674-0.0071` |
|
| 209 |
+
| `lut` | 0.27/255 | **0.07%** | mean `0.4372->0.4284`, then `0.4520->0.4291` |
|
| 210 |
+
|
| 211 |
+
**On drift removal there is nothing to choose between them** -- all three land inside 0.4/255, below this measurement's noise. Pick on failure mode instead, and `frame_shift` wins on one argument: **every `gain_bias` slope came out below 1 and moving further away (0.985 -> 0.967), which is attenuation bias, not tone compression.** Fitting `s = A*g + C` where `g = s + delta + noise` yields `A = var(s)/(var(s)+var(noise)) < 1`, driven purely by content mismatch between the source and its regeneration. The drift is a *pure level shift* (r/g/b within 0.001), so any slope != 1 is fitting artifact, and it lands in the output as a contrast reduction that deepens along the chain. `lut` has the same defect with 64 free parameters per channel instead of one -- its dark clipping coming out *below* the uncorrected reference means it is reshaping the tone curve, not correcting level. `frame_shift` cannot make that class of error; it can only shift.
|
| 212 |
+
|
| 213 |
+
The cost is `frame_shift`'s alone: **dark clipping tripled, 0.32% -> 0.96%**, because subtracting a flat 5.26/255 pushes near-black pixels through zero. That is the crushed-blacks endgame already visible at hop 3. A gain-only mode anchored at black (`out = g * mean(src)/mean(tgt)`, no bias term) would fix it without introducing a fitted slope, and is the obvious next mode if long chains ever need one.
|
| 214 |
+
|
| 215 |
+
**Seam measurements understate it by about a third, so never use them to decide.** `tools/seam_probe.py` on the same master reads `+1.56/255` and `+1.83/255` (sum `+3.39/255`) against the cache's `+5.18/255`: the frames either side of a cut are ~0.9 s apart in scene time and the content change partly cancels the drift. Earlier estimates of `≈2/255` from `chain_00047`/`00050`/`00051` were this same floor, mistaken for the value. Seam numbers are still the right *after* instrument -- `tone_probe` reads the cache, which stores raw pre-correction hops by design -- but only as an A/B between two masters from the same seed and cache, where the contamination is identical in both and cancels. **Note `temp/` is wiped on ComfyUI start, so probe before restarting.**
|
| 216 |
+
|
| 217 |
+
Offline results (`frame_shift`, synthetic): recovers a planted additive shift to <1e-5; `gain_bias` recovers 0.92/+0.04 as 1.0870/−0.0435 exactly. A simulated 4-hop chain drifts +1.95/255 uncorrected and −0.13/255 corrected, with each hop's fitted shift staying at the planted per-hop bias rather than accumulating. That validates the mechanism, not the real-world magnitude.
|
| 218 |
+
|
| 219 |
+
### Progress preview (Python ↔ JS)
|
| 220 |
+
|
| 221 |
+
`_push_preview` sends a `h3_refchain_preview` websocket event (via `PromptServer.instance.send_sync`) with hop status and an optional base64 JPEG of the latest frame. `js/h3_ref_chain_ui.js` listens, resolves the target node (`findNodeByQualifiedId`, subgraph-aware), and updates a DOM widget it mounts on the node. If you change the preview payload shape in Python, update the JS listener in the same change.
|
| 222 |
+
|
| 223 |
+
**Status is a short label only.** First stress run ended by passing the full `info` dump (summary + every hop prompt) as `status`, so the preview strip became the prompt. Final push is `done · Nf · Ts`. Assembled prompts stay on the `info` output (Preview Text). The JS strip also ellipsizes anything over 80 chars so this cannot happen again from a long status.
|
| 224 |
+
|
| 225 |
+
**Next (user 2026-08-26, during first stress run):** the in-node preview should become a KJNodes `Model Preview Override`–shaped panel — large “waiting for sample…” frame, idle/status strip, optional a/Δ and step-time strip — not a 260 px strip at the bottom of the chain node. **OK to split it off** as its own node / sidebar on the MODEL (or IMAGE) wire **before** CreateVideo / SaveVideo. Do not fold more chrome onto `HandTieClips`. Reference implementation: `ComfyUI-KJNodes/nodes/preview_override_node.py` + `web/js/preview_override/preview_override.js`. Not built yet; current `mountPreview` stays until this lands.
|
| 226 |
+
|
| 227 |
+
### First 8-step stress run (user 2026-08-26)
|
| 228 |
+
|
| 229 |
+
4 hops × 8 s, seed 777777, turbo. Shipped plan: s1 `says one short line` / s2 `still talking` + `join=continuous` `pan_follow` `keep` / s3 drink + `continuous` `push_in` `close` / s4 `match_cut` `pull_back` `wide`.
|
| 230 |
+
|
| 231 |
+
- **1→2 continuous + keep: clean.** The join directive is doing work; this is the success case.
|
| 232 |
+
- **2→3 continuous + push_in + close: small jump.** `check_coherence` does **not** warn here (it only flags continuous + framing change + *held* camera). The jump is still expected at 8 steps: VOCAB concatenates “carry straight on from the pinned frames” with “A close shot, head and shoulders filling the frame,” so `framing: close` asserts the destination as the opening state while AddGuide is still the medium pin from s2. Next compiler pass should compose camera-move + framing as a *landing*, not as the opening, when `join=continuous`.
|
| 233 |
+
- **3→4 match_cut: ok.** Control cut is distinguishable from 1→2. Do not treat all three seams as identical.
|
| 234 |
+
- **Speech audio outlived the mouth.** s1–s2 ask for talking; s3–s4 do not (drink / lower mug). Picture followed the later beats (mouth busy, then quiet); soundtrack kept the line going for roughly the second half of the master. This is a pack gap, not a seam bug:
|
| 235 |
+
1. AddGuide pins the previous hop’s overlap **audio** at t=0 (`nodes_minimax_h3.py` crops it to remaining duration — the pin is ~0.9 s of *speech*, which seeds the rest of the hop).
|
| 236 |
+
2. cfg 1.0 cannot subtract speech. Omitting “talking” from s3/s4 is a no-op.
|
| 237 |
+
3. Shot-plan compilation emits no `overall_soundscape`. There is no speech/soundtrack axis on `AXES`.
|
| 238 |
+
4. `tail: ongoing` plus `_assemble_next`’s closer (“that action is still underway as the clip ends”) keep whatever the pin started, including a line.
|
| 239 |
+
5. s1’s “says one short line” names speech without giving the words, inside an 8 s hop. H3 invents a line and pads the leftover duration with more speech.
|
| 240 |
+
6. Vague leftover time is what H3 fills. User correction (2026-08-26): **every hop in a chain must carry either enough actual dialogue or a specific mention of silence.** Omitting both is gibberish, especially on hops 2+. “Says one short line” / “still talking” is vague and under-fills an 8 s hop. “No speech” / “no dialogue” is the negation form and also gibberish. The quiet word is **silence**, named in the beat. Spoken hops put the real words in the beat, enough to occupy the duration.
|
| 241 |
+
- **End-of-run preview became the prompt dump.** `_push_preview(unique_id, info, …)` stuffed `info` into the status strip. Fixed to a short `done · Nf · Ts`; dump stays on the `info` output.
|
| 242 |
+
- **Hop-1 compile order.** ~~`tail` sits before the beat~~ **Fixed.** `compile_shot` splits `lead` (join/camera/framing/pace) from `tail` and appends `tail` last, on hop 1 and hop 2+ alike (`directives.py`, `compile_shot`). Verified 2026-08-27. Two residuals survive: ESTABLISH still prepends “Live-action, natural light, one continuous take.” on hop 1 even when the beat already opens “Live-action…”, and a hop-1 beat that *is* a full six-field H3 block returns early and drops every directive including `tail`.
|
| 243 |
+
|
| 244 |
+
**Contract (confirmed 2026-08-26, chain_00030..00032):** a complete six-field H3 / Ref2VA prompt is one generate. `[Shot 1]` is the opening of *that* generate. Hop 1 may be official. Hop 2+ must be a continuation beat only — pin-open + new action + sound. Wrapping hop 2 as another full official block (join splice, pin-open first, `keyframe completion`, “first frame of [Shot 1]”) still hard-cut at f124 onto the outfit still. `compile_shot` / `_assemble_next` flatten an official hop 2+ block to action + `overall_soundscape` + `non_diegetic_music` and drop `subject_definitions` / `summary` / `retention_analysis` / leading `[Shot 1]`.
|
| 245 |
+
|
| 246 |
+
**Join pass (user 2026-08-27, chain_00037_.mp4).** Smooth chain. Console: `2 hop(s), 192f (8.0s) @ 960x544`, `8 steps res_multistep/beta`, hop 2 `Qwen last frame -> <Picture 1>`, `0 identity stills`, `Motion-Context pin: previous hop latent (22f picture, 24f audio, trim 22)`, master **362 f / 15.1 s**. Register: all three stills `shots 1`. That is the working join recipe at 0.5 MP / 8 step: latent pin + pin-only hop 2 + 8 s airlock budget + hop-2 paragraph (no official fields). Native mask (Phase 2) is not needed for this seam.
|
| 247 |
+
|
| 248 |
+
**Next tests** (pack, not kitchen beats):
|
| 249 |
+
|
| 250 |
+
1. **Cache fingerprint.** LoRA strength 1.0 → 0.9, re-queue. Both hops re-render. (Before the latent sidecar, a hop-1 hit made hop 2 log `AddGuide pixel pin`; that line now means the entry predates the sidecar or its latent failed to load.)
|
| 251 |
+
**Note ComfyUI's own node cache sits in front of this one:** re-queueing with *nothing* changed skips `run()` entirely (no `[HandTieClips]` lines at all, ~9 s), so it tests nothing. Nudge `cache_budget_gb` to force re-execution -- it is in neither `chain_salt` nor the hop key, so every key stays byte-identical.
|
| 252 |
+
2. **Chained re-roll.** Revert strength. Change one word in hop 2 only. Hop 1 cache hit, hop 2 renders.
|
| 253 |
+
3. Confirm the same join at **1.0 MP** (`1280×736`) before calling it shipped. Keep `seed_per_shot`. 777777 was join-hostile on the old 5 s pin-only graph; do not treat that seed as the only one.
|
| 254 |
+
|
| 255 |
+
**chain_00030_.mp4 / chain_00031_.mp4 / the keyframe-completion re-queue.** Hard cut at hop 2, f124, onto the outfit still’s commercial kitchen. Drink beat ran; pin did not. Official hop 2 cannot join at 8 steps.
|
| 256 |
+
|
| 257 |
+
**chain_00033_.mp4.** Short hop-2 drink beat, flatten did not fire (card was already short). Still hard-cut. Two findings: (1) `_assemble_next` still prepended register `subject_prose` (`subject_definitions:` + `retention_analysis:`), so hop 2 was a Ref2VA generate again. (2) Console: `8 steps euler/simple`, not `res_multistep/beta`. `tail: settle` also led the compiled beat, so hop 2 opened on “eases to a rest.” Header stripped; tail moved after the beat.
|
| 258 |
+
|
| 259 |
+
**chain_00034_.mp4.** Header-stripped hop 2, `8 steps res_multistep/beta`. Still hard-cut; apron gone on hop 2. Console: `Qwen last frame -> <Picture 4>`, `3 identity stills`. Face still (`h3_stress_hero_face.jpg`) is the same commercial kitchen as the outfit plate, grey shirt, **no apron in the crop**. Outfit still has the apron. Hop 2 followed the face plate (place + wardrobe), not the pin. Stills without `shots` stay off hop 2+ continue; live frame is Picture 1.
|
| 260 |
+
|
| 261 |
+
**H3 soundtrack (official methods, user 2026-08-26):** do not invent a pack dialect for quiet vs speech. Dialogue belongs in `detailed_description` as `(S1)` + `<d>[English] …</d>` with the actual words. Ambience/physical/non-verbal belong in `overall_soundscape`. Requested silence throughout a hop is `overall_soundscape: N/A` (the official complete-silence token), not the English word “Silence” stuffed into a beat. “No speech” / “no dialogue” is still negation and still gibberish. `_assemble_next` currently does not emit these fields; when the beat already contains them, leave them alone.
|
| 262 |
+
|
| 263 |
+
**Iteration canvas (user 2026-08-26):** further tests run at **0.5 MP** (`960×544` landscape) for speed. 8 steps, seed 777777, 4×8 s, overlap 0.9 s stay. Seam times do not move (still master frames 192 / 362 / 532). Resolution is in `chain_salt`, so the 1.0 MP cache will not hit. A 0.5 MP pass validates join / speech / cache behaviour, not 1.0 MP texture. Confirm anything that ships at 1.0 MP (`1280×736`) on that canvas before calling it done.
|
| 264 |
+
|
| 265 |
+
**chain_00028_.mp4 (0.5 MP, 960×544, 702 f / 29.25 s, 8 step).** Same stress plan. Frame-walked.
|
| 266 |
+
|
| 267 |
+
- 1→2 at f192: continuous. Then **an inside-hop cut at f220–228 (~9.2–9.5 s, ~1.3 s after the join)** — over-shoulder + mug → frontal talking head, mug leaves frame. Same class as the old 1.8 s inside-hop cut. Shot 2’s `still talking` beat beat `pan_follow` + the walk.
|
| 268 |
+
- 2→3 at f362: join itself is continuous (window, mug out). Close-up lands later inside hop 3 (~f432–456) on the drink, not at the seam.
|
| 269 |
+
- 3→4 at f532: still the drink CU. `match_cut` + `wide` does **not** cut at the seam; pull-back to wide is ~f576–624. Control cut is late / soft.
|
| 270 |
+
- Soundtrack is speech-level for almost the whole clip (integrated ~−11 LUFS). Brief dip at the 1→2 join; no quiet second half. Mouth can drink (f456–504) while the track keeps talking.
|
| 271 |
+
- Mug survives hops 1, 3, 4; missing during the hop-2 talking-head. Identity holds at 0.5 MP (cross necklace from locked text rendered).
|
| 272 |
+
- A/V: audio 29.131 s vs video 29.250 s (~119 ms short, ~40 ms × 3 hops).
|
| 273 |
+
|
| 274 |
+
**chain_00029_.mp4 (0.5 MP, explicit beats + silence).** Same 702 f / 29.25 s.
|
| 275 |
+
|
| 276 |
+
- Soundtrack: wall-to-wall speech is gone. Hop 1 line-burst ~4.4–7.3 s (mouth open at f120). **Random line at 15.41–16.71 s** (user-confirmed): 0.33 s after the 2→3 seam, 1.3 s of speech inside hop 3’s *kept* audio — the 0.9 s pin was already trimmed, so this is hop 3 inventing a line, not hop-2 leak. Hop 3 beat names Silence once, then “a swallow”, then `_assemble_next` still *ends* on “that action is still underway as the clip ends.” One silence mention in the middle of the prompt does not occupy an 8 s hop. Later hops also have a late spike (~23 s).
|
| 277 |
+
- Mug path: chest height on the walk; lifts to the mouth only on hop 3; no reach to the glass. Opening frame still has the mug **on the counter** because the kitchen still itself shows it there (`h3_stress_kitchen.jpg`) — the photograph is additive, not only the desc.
|
| 278 |
+
- 1→2 at f192: continuous, mug at chest. Camera swings toward the face ~f216 (mug stays). **Not** the 00028 talking-head cut.
|
| 279 |
+
- Place break inside hop 2, f240→f270 (~10.0–11.25 s): residential window kitchen → the **outfit still’s commercial kitchen** (stainless, SANITIZER buckets, range). `h3_stress_hero_outfit.jpg` is that room. Kitchen ref is `retention: reference` (weak); outfit is a subject-bearing still of a *different set*. Walking off the pin lets the outfit photograph’s room take over. Hops 3–4 stay there. 2→3 join is continuous *in the wrong room*. 3→4 is again a delayed pull-back, not a match_cut at f532.
|
| 280 |
+
|
| 281 |
+
## Writing beats (verified against live renders, 2026-08-27)
|
| 282 |
+
|
| 283 |
+
The first end-to-end runs of v1.1 produced three failures that all trace to the
|
| 284 |
+
same root: **sampling runs at cfg 1.0 with no negative branch, so the prompt is
|
| 285 |
+
purely additive.** Nothing can be subtracted by naming it. Each was diagnosed
|
| 286 |
+
from the assembled prompt and fixed by rewriting a beat, not by changing code.
|
| 287 |
+
|
| 288 |
+
**1. A reference is only used where the action line points.**
|
| 289 |
+
`retention_analysis` described `<Picture 3>` correctly ("the layout, surfaces,
|
| 290 |
+
and light carry over as the setting") and the model still took its background
|
| 291 |
+
from `<Picture 2>`, a full-length person still that carries its own room. The
|
| 292 |
+
register is a static header; the beat is what drives the frame. Writing the tag
|
| 293 |
+
into the action line fixed it:
|
| 294 |
+
|
| 295 |
+
> The cook stands at the counter in **@kitchen**, looks up from the chopping board...
|
| 296 |
+
|
| 297 |
+
Phrase the tag as a depicted place, not a container. `in @kitchen` alone once
|
| 298 |
+
produced a literal composite -- the figure pasted onto the reference photo with
|
| 299 |
+
the counter cutting through the body.
|
| 300 |
+
|
| 301 |
+
**2. Naming the thing you want to end adds it.**
|
| 302 |
+
`The cook stops talking, ...` kept her talking. "stops talking" and "talking"
|
| 303 |
+
condition on nearly the same thing. Describe the wanted state as a pose plus a
|
| 304 |
+
sound: `with her lips closed` gives the video branch something renderable, and
|
| 305 |
+
naming room tone ("the hum of the refrigerator") gives the *audio* branch a
|
| 306 |
+
target that is not a voice. H3 always generates audio for the full hop -- silence
|
| 307 |
+
has to be described as a sound, never as an absence. This is the same finding as
|
| 308 |
+
the "No speech / no dialogue is negation" bullet below, reached independently.
|
| 309 |
+
|
| 310 |
+
**4. `tail` now reaches hop 2+ (fixed 2026-08-27).**
|
| 311 |
+
`_assemble_next`'s closer used to end unconditionally on *"that action is still
|
| 312 |
+
underway as the clip ends"*, and being the last sentence it overrode the shot's
|
| 313 |
+
own `tail` directive -- `settle` and `hold` were unreachable on every hop after
|
| 314 |
+
the first. On the final hop nothing absorbs that instruction, so the model
|
| 315 |
+
invented late action to satisfy it: a line of dialogue in the last second of a
|
| 316 |
+
3-hop chain, and the ~23 s spike noted further down this file. The closer now
|
| 317 |
+
selects its terminal clause from `tail`, and every variant still ends on the
|
| 318 |
+
clip's terminal *state* rather than on the photographs, preserving the ordering
|
| 319 |
+
rule documented at the `return`. An absent or unrecognised `tail` keeps the old
|
| 320 |
+
"still underway" wording.
|
| 321 |
+
|
| 322 |
+
**5. `overall_soundscape` is hop-1 only.**
|
| 323 |
+
`_assemble_next` strips `^(overall_soundscape|non_diegetic_music):` on hop 2+,
|
| 324 |
+
leaving the value inline as prose, because official field names there made the
|
| 325 |
+
model start a new Ref2VA generate (`chain_00030..00034`). So ambience on
|
| 326 |
+
continuation hops is prose-only and is tuned by word choice. Choose narrowband,
|
| 327 |
+
specific sources: "faint street noise through the window" is broadband and
|
| 328 |
+
renders as a 5-second hiss; "the low hum of the refrigerator" does not.
|
| 329 |
+
|
| 330 |
+
**3. A state change belongs at the END of the previous hop.**
|
| 331 |
+
Affirmative phrasing alone did not stop the dialogue. Hop 2 was configured
|
| 332 |
+
`tail=ongoing` with the beat `...turns toward the window, still talking`, and
|
| 333 |
+
`_assemble_next` opens every hop 2+ with *"The clip opens already in progress
|
| 334 |
+
from the pinned frames... holds for a short beat... and only then the next
|
| 335 |
+
action begins."* With `audio_pin_frames=24`, the pin hop 3 inherits is literally
|
| 336 |
+
mid-sentence. No wording in hop 3 can undo a boundary defined as mid-speech.
|
| 337 |
+
|
| 338 |
+
The fix was in **shot 2**, not shot 3:
|
| 339 |
+
|
| 340 |
+
> The cook sets the knife down, turns toward the window, and **finishes her last word** as she looks out.
|
| 341 |
+
|
| 342 |
+
Generalises: to be silent/still/elsewhere in hop N, arrive at that state before
|
| 343 |
+
hop N-1 ends. Hop N opens by holding what it was handed.
|
| 344 |
+
|
| 345 |
+
## Known constraints (don't "fix" without reading this)
|
| 346 |
+
- **The join pin is a latent slice, not a VAE round trip** (corrected 2026-08-27). Hop 2+ passes the previous hop's sampler AV latent to `MiniMaxH3MotionContext` as `context_latent`, which never decodes and end-aligns the audio window. Core's `MiniMaxH3AddGuide` does take `IMAGE`, not `LATENT` (`nodes_minimax_h3.py:177`), so it remains the *fallback* -- used when Motion-Context is absent, the overlap has no matching `context_length`, or the cached entry has no latent sidecar (pre-2026-08-27 entries only; the latent is stored now). Do not "simplify" back to AddGuide-only: it is what `chain_00037_`/`chain_00038_` were pinned against.
|
| 347 |
+
- Overlap is fixed per-run to 22 f / 5 f / 39 f. A longer overlap does not fix continuity and can instead pin whatever content is in that longer tail — "increase overlap" is not a general fix for join artifacts.
|
| 348 |
+
- Each join hard-cuts video but crossfades audio ~40 ms, accumulating ~40 ms/hop of A/V desync. Known and deliberately deferred.
|
| 349 |
+
- `ref_image_size="max"` (2048 short-edge) is slower per step than `"match"` — an explicit tradeoff, not a default to silently upgrade.
|
| 350 |
+
- Fewer than 3 wired reference stills triggers a warning log; unconnected `Load Image` nodes do not count as wired.
|
| 351 |
+
- Soundtrack is official H3, not a pack dialect: `(S1)` + `<d>…</d>` for lines, `overall_soundscape` for ambience/physical (or `N/A` for requested silence). Do not invent beat-keywords. “No speech” / “no dialogue” remains negation/gibberish.
|
| 352 |
+
|
| 353 |
+
## 8. References are files, not wires (2026-08-28)
|
| 354 |
+
|
| 355 |
+
The node had **16 sockets** and twelve of them were user media: `ref_image_1..9`,
|
| 356 |
+
`reference_video`, `voice`, `start_image`. They occupied roughly 340px down the
|
| 357 |
+
left of the node before the editor started, each needing its own `Load Image`.
|
| 358 |
+
They are gone. The node now has **five**: `model`, `clip`, `vae`, `audio_vae`,
|
| 359 |
+
`continuity_state`.
|
| 360 |
+
|
| 361 |
+
A reference names a **file** under `<ComfyUI input>/h3_refs`. `media.py` owns
|
| 362 |
+
both halves of that:
|
| 363 |
+
|
| 364 |
+
- **`resolve(name)` is the only thing that turns a name into a path**, and it is
|
| 365 |
+
used by the upload route and the loaders alike. Basename only, `normpath`,
|
| 366 |
+
then a prefix check against the reference directory, then an extension
|
| 367 |
+
whitelist. Verified against absolute paths, `../` traversal, and traversal
|
| 368 |
+
carrying a legal extension — all refused.
|
| 369 |
+
- **The loaders return exactly what the sockets delivered** — float `[N,H,W,3]`
|
| 370 |
+
in 0..1 — so `_ref_frames`' resize, `_collect_ref_images`' dense-pack and
|
| 371 |
+
`store.tensor_digest`'s cache keying are all unchanged. That is why this was a
|
| 372 |
+
small diff rather than a rewrite.
|
| 373 |
+
|
| 374 |
+
**Pixels never enter a widget.** Only the basename is stored. PromptMasterLD
|
| 375 |
+
measured 1.68 MB of widget value for nine base64 thumbnails and ComfyUI then
|
| 376 |
+
failed to save the workflow at all; previews are `/view?...&subfolder=h3_refs`
|
| 377 |
+
URLs, which cost nothing to rebuild and survive a reload. `/view` also brings
|
| 378 |
+
Range support, which a `<video>` needs to seek — so no thumbnail route was
|
| 379 |
+
needed.
|
| 380 |
+
|
| 381 |
+
**`IS_CHANGED` is new and load-bearing.** ComfyUI caches a node's output on its
|
| 382 |
+
inputs, and a filename is a stable input even when the bytes behind it change:
|
| 383 |
+
overwrite `face.png` and the previous render would be served. It hashes
|
| 384 |
+
`name:mtime` for every referenced file. Deliberately **not** `float("nan")` —
|
| 385 |
+
that is the blunt version PromptMasterLD's studio node uses, and it would force
|
| 386 |
+
a full re-render of an expensive node on every queue.
|
| 387 |
+
|
| 388 |
+
**Widget order is part of the saved-workflow format.** `widgets_values` is a
|
| 389 |
+
**positional array** -- ComfyUI restores `value[i]` into `widget[i]` and never
|
| 390 |
+
looks at the name. The three `*_file` widgets were first added at the *top* of
|
| 391 |
+
`optional`, which shifted `hop_script`..`tone_compensate` by +3; every workflow
|
| 392 |
+
saved before that change then loaded `audio_pin_frames`' integer into `ref_plan`
|
| 393 |
+
and the editor died with `(text || "").trim is not a function` on load. That
|
| 394 |
+
throw was the lucky part -- `hop_script`, `shot_plan` and `tone_compensate` were
|
| 395 |
+
being silently misassigned too, and only the type mismatch made any of it
|
| 396 |
+
visible.
|
| 397 |
+
|
| 398 |
+
**New widgets go at the bottom of `optional`, always.** Old workflows are then
|
| 399 |
+
*short* rather than *misaligned*, and the new widget takes its default. There is
|
| 400 |
+
an append-only marker comment in `INPUT_TYPES` saying so. Note `seed` costs
|
| 401 |
+
**two** array slots, not one: the frontend appends `control_after_generate`
|
| 402 |
+
right after it.
|
| 403 |
+
|
| 404 |
+
Both plan parsers now coerce (`String(text ?? "")`) and reject non-object JSON
|
| 405 |
+
instead of dereferencing it, so a future misalignment shows an empty JSON tab
|
| 406 |
+
rather than aborting the whole workflow load.
|
| 407 |
+
|
| 408 |
+
**The hop cache needed no change at all.** `chain_salt` already digests the
|
| 409 |
+
actual tensors, so different pixels behind the same filename still move the key.
|
| 410 |
+
|
| 411 |
+
**Legacy plans cannot be migrated automatically.** An old `ref_image_N` held a
|
| 412 |
+
tensor from a `Load Image`; there is no filename to recover. `refs.py` keeps the
|
| 413 |
+
authored slot as `legacy_slot`, `parse_ref_plan` does **not** raise on it, and
|
| 414 |
+
both the rail row and `check()` say *"was wired to ref_image_3 — pick its
|
| 415 |
+
picture"*. Failing the parse would have stopped the editor opening the very plan
|
| 416 |
+
the author needs to repair.
|
| 417 |
+
|
| 418 |
+
## 9. Presentation (2026-08-28)
|
| 419 |
+
|
| 420 |
+
**The node collapsed on first click and stayed collapsed.** Two causes, both in
|
| 421 |
+
`installHeightGuard` (`js/editor/widget_utils.js`), both ported from
|
| 422 |
+
`PromptMasterLD/js/claude_prompt.js:6285-6370`:
|
| 423 |
+
|
| 424 |
+
- `computeLayoutSize().minWidth` reported a **constant**. The layout pass
|
| 425 |
+
re-reads it on every recompute — selecting the node is enough — and
|
| 426 |
+
faithfully re-declares the node at its stated minimum. It now tracks
|
| 427 |
+
`node.size[0]`. Safe against feedback: `max()` against a constant is a
|
| 428 |
+
fixpoint, not an accumulator, and width never feeds height.
|
| 429 |
+
- A stale `widget.width` shadows `node.width` forever after load, because
|
| 430 |
+
ComfyUI's DOM-widget position updater reads `(widget.width ?? node.width)`. A
|
| 431 |
+
live getter with a dropped setter makes the stale value unrepresentable.
|
| 432 |
+
|
| 433 |
+
Two more fixes alongside: `node.computeSize()[0]` is floored at the panel width
|
| 434 |
+
(with no widget declaring one, LiteGraph falls back to `NODE_WIDTH * 1.5 = 210`
|
| 435 |
+
and every resize command is free to crush the panel), and `domWidget.computeSize`
|
| 436 |
+
now answers a **width-passing** caller with the minimum height and a
|
| 437 |
+
**no-argument** caller with the live height. Reporting the live height to both
|
| 438 |
+
pins the resize-drag floor to the current height, so the node could only ever
|
| 439 |
+
grow.
|
| 440 |
+
|
| 441 |
+
**`chrome()`'s memo key was stale by construction.** It keyed on
|
| 442 |
+
`inputs|outputs|widgets.length`, none of which change when a widget is *hidden*
|
| 443 |
+
— so every panel height computed after `applyVisibility` used a chrome
|
| 444 |
+
measurement taken before it. The hidden count is now part of the key.
|
| 445 |
+
|
| 446 |
+
**The reference rail was permanently crushed, and its rescue was dead code.**
|
| 447 |
+
The 7-track grid needs ~536px and the node offers ~510px at `NODE_WIDTH 560`.
|
| 448 |
+
There was a `@container (max-width: 460px)` block written to relieve it — but
|
| 449 |
+
**nothing in the codebase declared `container-type`**, so the query had no
|
| 450 |
+
containment context and never matched. `.h3e-section` now declares
|
| 451 |
+
`container-type: inline-size`, and there are two breakpoints.
|
| 452 |
+
|
| 453 |
+
**The palette committed to one look.** It used to derive surfaces from the host
|
| 454 |
+
theme via `color-mix()` while hardcoding every accent — the intent was
|
| 455 |
+
light-theme safety, but `--h3-bg` mixed toward `#111827` and `--h3-sunken`
|
| 456 |
+
toward `#000`, so a light theme got dark blue-grey islands anyway. It is now the
|
| 457 |
+
PromptMasterLD `.ldp-root` system: `#0a0a0a`, one hairline `#2a2a2a`, zero
|
| 458 |
+
radius, one accent `#e8ff47`. **Changing `--h3-accent` moves the whole panel.**
|
| 459 |
+
Emphasis **inverts** (accent fill, `--h3-on-accent` ink) rather than tinting,
|
| 460 |
+
because an acid accent at 30% behind unchanged text is olive mud.
|
| 461 |
+
|
| 462 |
+
A cascade trap worth remembering: the override block was first inserted *before*
|
| 463 |
+
the reference-rail section, so `.h3e-chip-on`, `.h3e-inactive .h3e-ord` and
|
| 464 |
+
`.h3e-subj-badge` all kept winning on source order. Overrides live at the end of
|
| 465 |
+
the sheet now. Without a browser, a token audit script is the only thing that
|
| 466 |
+
catches this class of bug.
|
| 467 |
+
|
| 468 |
+
**The panel did not fill the node, and the mirror was why.** `installHeightGuard`
|
| 469 |
+
kept an independent `_h`, updated through an `onResize` hook, so that the
|
| 470 |
+
arrange pass could not feed its own growth. Measured during a drag: `onResize`
|
| 471 |
+
and `setSize` each fired 57 times while `_h` sat at 876 and `node.size[1]`
|
| 472 |
+
climbed past 1400. `measuring` was not stuck, which left one gate --
|
| 473 |
+
`Array.isArray(size)`. **This frontend's `node.size` is not a plain Array**, so
|
| 474 |
+
every write was skipped and `_h` held the install-time height forever. A 1911px
|
| 475 |
+
node had a 742px panel.
|
| 476 |
+
|
| 477 |
+
The repair was to delete the mirror, not fix the hook. `_h` existed only to
|
| 478 |
+
break the arrange loop, and
|
| 479 |
+
|
| 480 |
+
panelHeight() = max(minHeight, node.size[1] - chrome() - SLACK) // SLACK 8
|
| 481 |
+
|
| 482 |
+
breaks it outright: the pass wants `panelTop + panelHeight + 4`, and `panelTop`
|
| 483 |
+
is chrome minus the node's bottom padding, so a panel of exactly `size - chrome`
|
| 484 |
+
asks for up to 4px more than the node has on every frame -- the ~130px/frame
|
| 485 |
+
runaway the header comment records. With slack the inequality holds, the loop
|
| 486 |
+
settles, and the node's height simply *is* the panel's height. Nothing to keep
|
| 487 |
+
in sync, no hook to get wrong. `sync()` now only ever grows a too-short node;
|
| 488 |
+
the height is the user's to choose.
|
| 489 |
+
|
| 490 |
+
**RUN is pinned and always open.** `.h3e-root` used to be the scroll container
|
| 491 |
+
with all four sections inside it, which put RUN below the script -- out of view
|
| 492 |
+
on any workflow with more than two shots, and it is the section touched on every
|
| 493 |
+
queue. The root is now a flex column holding `.h3e-scroll` (the authoring
|
| 494 |
+
sections) and RUN outside it.
|
| 495 |
+
|
| 496 |
+
Two flex details, both of which cost a round trip to learn:
|
| 497 |
+
|
| 498 |
+
- **`.h3e-scroll` needs `min-height: 0`.** A flex item defaults to
|
| 499 |
+
`min-height: auto` and refuses to shrink below its content, which pushes RUN
|
| 500 |
+
off the bottom of the node instead of scrolling.
|
| 501 |
+
- **`.h3e-run` must be `flex: 0 0 auto`, never `0 1 auto`.** Flex divides a
|
| 502 |
+
deficit in proportion to each item's content height. The scroller's content is
|
| 503 |
+
far taller, so a shrinkable RUN loses most of the contest and clips its lower
|
| 504 |
+
groups. The scroller absorbs all the shrinking; its `min-height: 160px` is the
|
| 505 |
+
floor that stops RUN owning the panel and RUN's `max-height: 55%` is the
|
| 506 |
+
ceiling.
|
| 507 |
+
|
| 508 |
+
Always-open removed the only moment RUN re-read its widgets, so it now resyncs
|
| 509 |
+
on `api`'s `promptQueued` -- the client-side event that fires after
|
| 510 |
+
`control_after_generate` has bumped the seed, and the same one ComfyUI's own
|
| 511 |
+
change tracker uses. The listener is dropped in `node.onRemoved`.
|
| 512 |
+
|
| 513 |
+
**Not done:** PromptMasterLD's `--fsc` UI-scale multiplier, which makes every
|
| 514 |
+
dimension `calc(Npx * var(--fsc))` and puts a zoom slider on the panel. It is
|
| 515 |
+
mechanical churn across every rule in the sheet and was not worth doing blind.
|
| 516 |
+
|
| 517 |
+
## 10. Ported for public use (2026-08-29)
|
| 518 |
+
|
| 519 |
+
The prompting craft was written down and the pack was made installable by a
|
| 520 |
+
stranger. Four things, in the order they mattered.
|
| 521 |
+
|
| 522 |
+
**The shipped example workflows were broken.** All four in `workflows/` predated
|
| 523 |
+
the 2026-08-28 socket removal: twelve dead media inputs each, three `LoadImage`
|
| 524 |
+
nodes, and 21 widget values against a 28-widget node. Loading one is a new
|
| 525 |
+
user's *first* action, so this outranked any amount of documentation. They are
|
| 526 |
+
quarantined in `_disabled_custom_nodes/h3_legacy_workflows/` (the pack is not
|
| 527 |
+
under its own version control -- deleting would have been unrecoverable) and
|
| 528 |
+
replaced by two built from the verified `H3_Stress_6x7` structure:
|
| 529 |
+
|
| 530 |
+
- **`HandTieClips_Starter.json`** -- two hops, **no references at all**, runs the
|
| 531 |
+
moment the loaders are pointed at files. The empty register is deliberate: an
|
| 532 |
+
`@tag` whose picture is missing is a *hard* error in `resolve_tags`, so a
|
| 533 |
+
starter that shipped with tags would fail on first queue for everyone.
|
| 534 |
+
- **`HandTieClips_Showcase.json`** -- the six-hop continuity test, with the three
|
| 535 |
+
reference filenames generalised.
|
| 536 |
+
|
| 537 |
+
Both use **core ComfyUI plus this pack only**. The dev workflow reaches KJNodes
|
| 538 |
+
(`ModelPreviewOverrideKJ`, `MiniMaxLowVRAMAttention`) and PlagueKind
|
| 539 |
+
(`LTX_lora_loader`, `H3SLAAttention`, `H3AdaLNLoRAFix`); an example that fails
|
| 540 |
+
to load because of a pack the reader never asked for teaches nothing. The
|
| 541 |
+
speed stack is documented, not shipped.
|
| 542 |
+
|
| 543 |
+
**A declared-but-inactive `@tag` reported the wrong cause.** `check()` already
|
| 544 |
+
warned correctly that a picture was missing, but the run then died on
|
| 545 |
+
`resolve_tags` with *"unknown reference '@kitchen'"* -- pointing at the beat's
|
| 546 |
+
spelling, the one thing that was right. `resolve_tags` now takes `declared`
|
| 547 |
+
(every tag in the register, active this hop or not) and separates the two
|
| 548 |
+
failures. The old two-argument behaviour is unchanged when `declared` is
|
| 549 |
+
omitted.
|
| 550 |
+
|
| 551 |
+
**`PROMPTING.md` and `prompt_pack/`.** The craft rules were spread through
|
| 552 |
+
README prose; they are now a standalone guide, and a copy-paste system prompt
|
| 553 |
+
that gets a language model to emit valid plans. Two files under `prompt_pack/`
|
| 554 |
+
are **generated, never hand-written**:
|
| 555 |
+
|
| 556 |
+
- `tools/gen_schema.py` builds `SCHEMA.json` from `directives.VOCAB`,
|
| 557 |
+
`refs.RETENTION` and the duration table, and **asserts** against
|
| 558 |
+
`plan._SHOT_KEYS`, `refs.REF_FIELDS` and `refs.SUBJECT_FIELDS`. Add a camera
|
| 559 |
+
move and the schema follows; add a shot field and the generator fails loudly
|
| 560 |
+
rather than emitting a stale schema. `--check` is the CI form.
|
| 561 |
+
- `tools/gen_example.py` builds `EXAMPLE_6_HOP.md` from the showcase workflow,
|
| 562 |
+
including its hop/reference table, so the worked example and the shipped
|
| 563 |
+
workflow cannot disagree.
|
| 564 |
+
|
| 565 |
+
**Template patterns in the editor.** `js/editor/templates.js` plus a
|
| 566 |
+
**Templates** button in the SCRIPT header. They **append**, never replace --
|
| 567 |
+
replacing would be the one destructive control on the node, and stacking is how
|
| 568 |
+
a chain is actually built. `freeId()` mints the lowest unused `sN` because `id`
|
| 569 |
+
is the hop cache's pointer and two shots sharing one would make `locked` reuse
|
| 570 |
+
the wrong render.
|
| 571 |
+
|
| 572 |
+
No template contains an `@tag`, for the same reason the starter workflow has no
|
| 573 |
+
references. `tools/check_templates.py` extracts the patterns *out of the JS*
|
| 574 |
+
and runs them through `plan.parse_plan` and `plan.check_coherence`, and lints
|
| 575 |
+
every beat for negation -- a template that produced a plan the node rejects
|
| 576 |
+
would be worse than no templates, because a first-time author would blame their
|
| 577 |
+
own writing. It caught two: a beat reading "They stop at the window" (naming a
|
| 578 |
+
cessation, which is law 2) and a line of dialogue containing "did not".
|
| 579 |
+
|
| 580 |
+
That second one is worth recording as an open question: **whether the additive
|
| 581 |
+
prompt bites inside quoted dialogue was never tested.** The templates avoid it,
|
| 582 |
+
and `PROMPTING.md` says plainly that this is untested rather than inventing a
|
| 583 |
+
rule.
|
| 584 |
+
|
| 585 |
+
## 11. What chain_00057 taught (2026-08-29)
|
| 586 |
+
|
| 587 |
+
The six-hop showcase was rendered for the first time. Three failures, and the
|
| 588 |
+
one that mattered was a code gap the documentation had already claimed was
|
| 589 |
+
fixed.
|
| 590 |
+
|
| 591 |
+
**`locked` and `context` reached hop 1 only.** The register's whole promise is
|
| 592 |
+
that a subject's continuity text carries identity across a hop where the
|
| 593 |
+
photograph is absent. It did not. `subject_prose` is called under `if i == 0`,
|
| 594 |
+
and `_identity_lock` returns `""` when no subject-bearing ref is active -- so on
|
| 595 |
+
the showcase, hops 2, 3, 5 and 6 carried **no identity text of any kind**, and
|
| 596 |
+
hop 5, scheduled with no references at all, lost the character entirely.
|
| 597 |
+
Identity was riding on the pinned frames alone, which is the exact failure the
|
| 598 |
+
register exists to prevent.
|
| 599 |
+
|
| 600 |
+
`refs.continuity_line()` now emits that text on every hop 2+, and
|
| 601 |
+
`_assemble_next` injects it between the lock and the live-frame citation. The
|
| 602 |
+
reason it was suppressed in the first place is real and is preserved: naming
|
| 603 |
+
*pictures* on a pin-only hop sent the encoder back to the plates (chain_00034 --
|
| 604 |
+
commercial kitchen, grey shirt, no apron). So the new line carries **no
|
| 605 |
+
`<Picture N>` and no `<Subject N>`**. It is a description of what stays the
|
| 606 |
+
same, not a citation of anything. `<Subject N>` is excluded specifically because
|
| 607 |
+
there is no `subject_definitions` block on a continuation hop to bind it to.
|
| 608 |
+
|
| 609 |
+
Note this changes the assembled block on hop 2+ of every chain with a register,
|
| 610 |
+
so it invalidates the hop cache. That is correct, not a regression.
|
| 611 |
+
|
| 612 |
+
**Dialogue propagated through five hops.** Shot 1 ended on its spoken line with
|
| 613 |
+
`tail: ongoing`. The audio pin carries the previous hop's tail, so the last
|
| 614 |
+
second of hop 1 -- speech -- opened hop 2, and "action is still underway" was
|
| 615 |
+
the closing instruction; the model satisfied it with the action it could hear.
|
| 616 |
+
Nothing in hops 2-6 gave the audio anywhere else to go.
|
| 617 |
+
|
| 618 |
+
This is the guide's own law 3, broken by the plan written to demonstrate it. The
|
| 619 |
+
fix is authorial, not code: land the line **mid-hop** and leave a non-verbal
|
| 620 |
+
action running into the seam (the knife on the board), and give every
|
| 621 |
+
dialogue-free hop a narrowband sound of its own. Both `PROMPTING.md` and the
|
| 622 |
+
authoring prompt now carry this as a named rule, and the shipped templates were
|
| 623 |
+
rewritten to obey it.
|
| 624 |
+
|
| 625 |
+
**`join: continuous` across a location change morphed one room into the other.**
|
| 626 |
+
Hop 6 walked back from the hallway and the kitchen appeared mid-turn. A
|
| 627 |
+
continuous join asks for one unbroken take between two different rooms, which
|
| 628 |
+
is not a thing. `match_cut` is what a walk through a doorway is. The beat also
|
| 629 |
+
read "steps into @kitchen" -- the container phrasing the README warns produces a
|
| 630 |
+
composite of the photograph -- and now names the counter *in* the kitchen.
|
| 631 |
+
|
| 632 |
+
Hop 5's beat is unchanged in the revised plan **on purpose**: it is the
|
| 633 |
+
measurement, and changing it would forfeit the comparison.
|
| 634 |
+
|
| 635 |
+
The revised plan is in the shipped showcase and in
|
| 636 |
+
`user/default/workflows/H3_Stress_6x7_v2.json`; the original that produced
|
| 637 |
+
chain_00057 is left alone so the A/B survives.
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
## 12. Renamed to Hand Tie Clips (2026-08-29)
|
| 641 |
+
|
| 642 |
+
The pack was `ComfyUI-H3-Ref-Chain`. It is now `ComfyUI-Hand-Tie-Clips`, and the
|
| 643 |
+
four registered ids moved with it:
|
| 644 |
+
|
| 645 |
+
| was | is |
|
| 646 |
+
|---|---|
|
| 647 |
+
| `H3RefChain` | `HandTieClips` |
|
| 648 |
+
| `H3ContinuityState` | `HTCContinuityState` |
|
| 649 |
+
| `H3ChainPreview` | `HTCChainPreview` |
|
| 650 |
+
| `H3ToneCompensate` | `HTCToneCompensate` |
|
| 651 |
+
|
| 652 |
+
Display names are unchanged (`H3 Ref2VA Chain`, `H3 Chain Preview`, ...) because
|
| 653 |
+
the pack only drives MiniMax H3 checkpoints and a name that hides that costs
|
| 654 |
+
somebody an afternoon. The menu category is `Hand Tie Clips`. `TAG` -- and so the
|
| 655 |
+
console prefix -- is `[HandTieClips]`.
|
| 656 |
+
|
| 657 |
+
**The old ids are still registered, as `DEPRECATED` subclasses.** A type id is
|
| 658 |
+
what every saved `.json` carries, and an unregistered one is a red missing-node
|
| 659 |
+
box, not a warning. A plain alias in `NODE_CLASS_MAPPINGS` would have worked but
|
| 660 |
+
listed each node twice in search: ComfyUI falls back to the mapping key when
|
| 661 |
+
`NODE_DISPLAY_NAME_MAPPINGS` has no entry for it. Subclassing and setting
|
| 662 |
+
`DEPRECATED = True` gets both -- `server.py:783` publishes `deprecated: True`,
|
| 663 |
+
and the frontend's `Comfy.Node.ShowDeprecated` (off by default) keeps it out of
|
| 664 |
+
search while leaving it fully functional in workflows that name it.
|
| 665 |
+
|
| 666 |
+
**The JS had to learn both ids or the aliases would have been worse than
|
| 667 |
+
useless.** `js/h3_ref_chain_ui.js` and `js/h3_chain_preview.js` each compared
|
| 668 |
+
`nodeData.name` against a single string; a legacy node would have loaded with no
|
| 669 |
+
editor at all, which looks exactly like the rename having broken the pack. Both
|
| 670 |
+
now test membership of a `Set`. `js/h3_chain_preview.js` needs it twice: once
|
| 671 |
+
for its own type, once for `CHAIN_TYPES`, which is how a preview walks back up
|
| 672 |
+
`images` to find the chain feeding it.
|
| 673 |
+
|
| 674 |
+
**What deliberately kept the `h3` naming**, and must not be "finished" later:
|
| 675 |
+
module filenames (`h3_ref_chain.py`), the `h3e-` CSS class prefix and `--h3-`
|
| 676 |
+
tokens (515 occurrences -- one missed class silently breaks styling), the
|
| 677 |
+
`/h3_ref_chain/*` routes, the `h3_refchain_preview` event name, and
|
| 678 |
+
`input/h3_refs`. Renaming that folder would orphan every reference photo already
|
| 679 |
+
on disk.
|
| 680 |
+
|
| 681 |
+
The two shipped workflows moved to `HandTieClips_Starter.json` /
|
| 682 |
+
`HandTieClips_Showcase.json` and were rewritten onto the new ids, with their
|
| 683 |
+
`SaveVideo` prefix now `video/HANDTIECLIPS/chain`. The three under
|
| 684 |
+
`user/default/workflows/` were left on the legacy ids on purpose: re-running
|
| 685 |
+
`H3_Stress_6x7_v2.json` is then a live test of the alias path, and their existing
|
| 686 |
+
renders stay together under `output/video/H3REFCHAIN/`.
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
## 13. The on-canvas board (2026-08-29)
|
| 690 |
+
|
| 691 |
+
`workflows/HandTieClips_Starter.json` carries six `MarkdownNote` cards to the
|
| 692 |
+
left of the loaders, wrapped in a group titled READ ME. The text lives in
|
| 693 |
+
`tools/notes.py` and is written into the workflow by `tools/build_notes.py`.
|
| 694 |
+
|
| 695 |
+
Why on the canvas: the craft was in `PROMPTING.md`, `prompt_pack/` and the
|
| 696 |
+
Templates panel, and all three require leaving the graph. The rules that decide
|
| 697 |
+
whether a first render works are needed while beats are being written, which is
|
| 698 |
+
on the canvas. The cards are a **condensation**, not a copy -- `PROMPTING.md`
|
| 699 |
+
stays the authority and every card says so.
|
| 700 |
+
|
| 701 |
+
Mechanics worth not rediscovering:
|
| 702 |
+
|
| 703 |
+
- `MarkdownNote` is a core virtual node. The frontend renders it with `marked`
|
| 704 |
+
at `gfm: true` and sanitises with DOMPurify, so headings, GFM tables, bold and
|
| 705 |
+
code fences all work. It needs frontend >= ~1.16.
|
| 706 |
+
- Cards are identified by `properties.htc_card`, which is what makes
|
| 707 |
+
`build_notes.py` idempotent -- it drops marked nodes before writing, so
|
| 708 |
+
re-running replaces the board instead of stacking a second copy.
|
| 709 |
+
- `extra.ds` is **restored** on load, not fitted. Without setting it, a board at
|
| 710 |
+
negative x sits off-screen and is never found. Screen is
|
| 711 |
+
`(world + offset) * scale`, so the offset is what brings it into view.
|
| 712 |
+
- Group serialisation is `{id?, title, bounding:[x,y,w,h], color?, font_size?,
|
| 713 |
+
locked?}` -- confirmed against the frontend's own zod schema, not guessed.
|
| 714 |
+
|
| 715 |
+
**`widgets_values_named`, found while doing this.** Both shipped workflows
|
| 716 |
+
carried a stale copy: `chains: 3`, `duration: 10 s`,
|
| 717 |
+
`control_after_generate: randomize`, and a legacy `ref_plan` naming pictures that
|
| 718 |
+
do not ship -- 25 entries against a 28-widget node. It came from the builder
|
| 719 |
+
deep-copying nodes out of the dev workflow and overwriting only
|
| 720 |
+
`widgets_values`. Dormant while `Comfy.Workflow.NamedValuesRestore` stays off
|
| 721 |
+
(experimental, default false), but anyone who turned that on would have loaded a
|
| 722 |
+
Starter that randomizes its seed and dies on a missing reference.
|
| 723 |
+
`build_notes.py` strips it from both, and `tools/check_workflows.py` now fails if
|
| 724 |
+
it ever comes back.
|
| 725 |
+
|
| 726 |
+
`tools/check_workflows.py` is the promoted version of the validator that guarded
|
| 727 |
+
the shipped workflows against socket/widget drift. It also checks the board:
|
| 728 |
+
every card marked and non-empty, exactly one group, the group enclosing every
|
| 729 |
+
card, and no card reaching past x=0 onto the loaders.
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
## 14. The turbo stack ships in the examples (2026-08-29)
|
| 733 |
+
|
| 734 |
+
Both shipped workflows now carry the dev graph's full MODEL wire:
|
| 735 |
+
|
| 736 |
+
UNETLoader -> LTX_lora_loader -> H3AdaLNLoRAFix -> MiniMaxLowVRAMAttention
|
| 737 |
+
-> H3SLAAttention -> ModelPreviewOverrideKJ -> HandTieClips
|
| 738 |
+
|
| 739 |
+
and **CLIP reaches the chain from the LoRA loader, not the encoder** -- that is
|
| 740 |
+
what makes the text half of every LoRA land, and it is the wire most likely to
|
| 741 |
+
get quietly "fixed" back to the encoder by someone tidying the graph.
|
| 742 |
+
|
| 743 |
+
This reverses the earlier "core ComfyUI and this pack only" rule for the
|
| 744 |
+
examples, at the user's direction: `steps` is 7, which only works with a turbo
|
| 745 |
+
LoRA, so an example without the LoRA stack is not a graph anyone can run at the
|
| 746 |
+
settings it ships with. PlagueKind supplies the loader, the AdaLN fix and SLA;
|
| 747 |
+
KJNodes supplies Low VRAM Attention and the preview override.
|
| 748 |
+
|
| 749 |
+
`tools/build_speed_stack.py` inserts and rewires the five nodes idempotently
|
| 750 |
+
(marker `properties.htc_speed`), rebuilding the plain loader -> chain shape
|
| 751 |
+
first so it is reentrant from either state. `tools/check_workflows.py` walks
|
| 752 |
+
**both** wires link by link -- a patch node that is present but bypassed round
|
| 753 |
+
the side looks right on the canvas and does nothing.
|
| 754 |
+
|
| 755 |
+
`H3SLAAttention` widget values are written out in full including
|
| 756 |
+
`reference_protection`, which post-dates the dev workflow's saved values. That
|
| 757 |
+
workflow has 10 entries against an 11-widget node, which is the positional rule
|
| 758 |
+
working as intended: the new widget was appended last, so old values still line
|
| 759 |
+
up and the missing one falls back to its default. The trailing `""` on the LoRA
|
| 760 |
+
loader and the preview override is carried over verbatim for the same reason --
|
| 761 |
+
a value past the last widget is ignored, and dropping one that turns out to
|
| 762 |
+
belong to a widget would shift every value after it.
|
| 763 |
+
|
| 764 |
+
`head_chunks` ships at 4 (the node's own default, safer on unknown VRAM) rather
|
| 765 |
+
than the 2 used here. Two files have to be on disk as well: the turbo LoRA the
|
| 766 |
+
loader names, and `taeh3.safetensors` for the preview override's `tiny_vae`.
|
| 767 |
+
|
| 768 |
+
## 15. What a shipped diagnosis looked like (2026-08-29)
|
| 769 |
+
|
| 770 |
+
The editor UI vanished after the folder rename -- raw widget boxes, no panel.
|
| 771 |
+
Not a code fault. ComfyUI had been restarted while the pack was still
|
| 772 |
+
`ComfyUI-H3-Ref-Chain`, so the running process held
|
| 773 |
+
`python_module: custom_nodes.ComfyUI-H3-Ref-Chain` and served its web assets
|
| 774 |
+
from a path the move had deleted. `/object_info` had all eight node types;
|
| 775 |
+
`/extensions` listed none of the pack's JS and a direct fetch 404'd.
|
| 776 |
+
|
| 777 |
+
Worth knowing for next time: `/object_info` carries `python_module`, which is
|
| 778 |
+
the fastest way to find out **which copy of a pack a running server actually
|
| 779 |
+
loaded**, and `/extensions` plus a direct fetch of one script separates "the JS
|
| 780 |
+
is broken" from "the JS is not being served at all".
|
| 781 |
+
|
| 782 |
+
## 16. What the Rain Kitchen renders taught (2026-08-29)
|
| 783 |
+
|
| 784 |
+
Three six-hop renders of the same 6x7 s chain, each isolating one variable. All
|
| 785 |
+
three findings are now in `PROMPTING.md`, the troubleshooting table, the
|
| 786 |
+
authoring prompt and the on-canvas board.
|
| 787 |
+
|
| 788 |
+
**Identity drift is permanent, and the old advice was backwards.** This file and
|
| 789 |
+
`PROMPTING.md` used to say a face plate riding a later hop *beats the pin* and
|
| 790 |
+
that `shots` should therefore be kept tight. chain_00059 falsified it: hop 4
|
| 791 |
+
carried a face plate photographed in a *different kitchen* through a walking
|
| 792 |
+
medium shot and held cleanly, while hop 5 — scheduled with no references at
|
| 793 |
+
all — came back a different person, and hop 6 never recovered even though a
|
| 794 |
+
place plate rode it and restored the room instantly. `locked` holds a face that
|
| 795 |
+
is still right; only a plate rebuilds one that is gone. **Face refs go on every
|
| 796 |
+
hop.** The old advice survives for *place* plates only.
|
| 797 |
+
|
| 798 |
+
**A beat must survive an over-delivered hop.** A hop routinely does more than it
|
| 799 |
+
was asked. Shot 3 asked for "a first slow step along the counter" and delivered
|
| 800 |
+
the whole walk; shot 4 was then handed an instruction its own live frame had
|
| 801 |
+
already satisfied, and the only way to obey was to reset the scene — a hard
|
| 802 |
+
cut 1.5 s *into* the hop, not at the seam, because `_assemble_next` holds the
|
| 803 |
+
incoming frames for a short beat first. Give one hop the whole movement, and
|
| 804 |
+
write the next beat true from either ending ("she *reaches* the window", "she
|
| 805 |
+
takes up the bowl *again*").
|
| 806 |
+
|
| 807 |
+
**A noun with no adjective drifts.** Three hops said only "the bowl"; it came
|
| 808 |
+
back stainless steel. Naming it "the white bowl" everywhere *and* stating it in
|
| 809 |
+
`context` as a property (never a location — "stays in her hands" fights the
|
| 810 |
+
beat the moment she puts it down) held it for six hops. The re-run then proved
|
| 811 |
+
the rule by accident: `context` read "the apron stays tied over the **grey**
|
| 812 |
+
t-shirt", and the t-shirt held all six hops while the apron, one clause away
|
| 813 |
+
with no colour, had turned denim blue by hop 6.
|
| 814 |
+
|
| 815 |
+
### The code change this bought
|
| 816 |
+
|
| 817 |
+
`refs.resolve_tags` now takes `subject_names` and the node passes it on
|
| 818 |
+
continuation hops only. `<Subject N>` is bound by `subject_definitions:`, which
|
| 819 |
+
is hop-1 material — so on hop 4 the ordinal dangled, exactly the same defect
|
| 820 |
+
as the undescribed bowl. From hop 2 a person tag resolves to the subject's
|
| 821 |
+
`name` instead ("The cook walks down the hallway"), which binds to the
|
| 822 |
+
`continuity_line` sentence every continuation hop already carries. No name means
|
| 823 |
+
the old `<Subject N>` fallback, so nothing regresses.
|
| 824 |
+
|
| 825 |
+
`tools/check_prompts.py` mirrors the new call, and gained the banned-word check
|
| 826 |
+
that previously ran only against `templates.js`. That gap is how the shipped
|
| 827 |
+
Showcase carried "**None** of the kitchen is visible" (law 1 — it *adds* a
|
| 828 |
+
kitchen at cfg 1.0) and "She **stops** at the window" (law 2) through six
|
| 829 |
+
versions. Both are fixed, and the Showcase's face ref now rides all six hops.
|
| 830 |
+
|
| 831 |
+
## 17. The pre-beta audit (2026-08-29)
|
| 832 |
+
|
| 833 |
+
Read for what a stranger hits, not for what we already check. Six findings; the
|
| 834 |
+
first two would each have cost a tester a run.
|
| 835 |
+
|
| 836 |
+
**The docs promised a stop the code never did.** `refs.check()` is *"warn, never
|
| 837 |
+
raise"*, and its caller only printed. A ref naming a file that is not in
|
| 838 |
+
`h3_refs` had its slot skipped and the chain rendered on — all six hops, with
|
| 839 |
+
the reference silently inactive, which is precisely the uncontrolled output the
|
| 840 |
+
register exists to prevent. Four shipped surfaces said otherwise, including the
|
| 841 |
+
Showcase's own note: *"the run stops and names the reference it could not find.
|
| 842 |
+
Nothing guesses."*
|
| 843 |
+
|
| 844 |
+
Fixed in the code rather than the docs, because there is no reading under which
|
| 845 |
+
rendering without a named-but-absent picture is what the author meant. New
|
| 846 |
+
`refs.missing_files()` returns `(tag, file)` for every ref whose named file did
|
| 847 |
+
not load; `h3_ref_chain.py` raises on it, after printing the register table so
|
| 848 |
+
the error arrives with its context. **The distinction that matters:** a ref with
|
| 849 |
+
*no* `file` stays a warning — the Starter ships that way on purpose so it runs
|
| 850 |
+
before any pictures exist. Only a *named* file that is absent is fatal.
|
| 851 |
+
|
| 852 |
+
**The on-canvas board never got §16's corrections.** `tools/notes.py` still
|
| 853 |
+
carried the row chain_00059 falsified — *"a plate riding a hop it does not
|
| 854 |
+
belong on, beating the pin → tighten `shots`"* — and was missing all three rows
|
| 855 |
+
`PROMPTING.md` gained. The board is what a beginner actually reads, so it was
|
| 856 |
+
shipping the inverse of the rule. Lesson: `PROMPTING.md` is the authority, but
|
| 857 |
+
`tools/notes.py` is a **second copy** of the same craft, and a correction is not
|
| 858 |
+
finished until both move. `build_notes.py` regenerates the Starter's cards *and*
|
| 859 |
+
the Showcase's `Note` from `notes.SHOWCASE_NOTE`, so one rebuild covers both.
|
| 860 |
+
|
| 861 |
+
The rest were packaging, not craft: the MIT `LICENSE` named no copyright holder;
|
| 862 |
+
`pyproject.toml` lacks both fields a Registry publish needs (`PublisherId` and a
|
| 863 |
+
`[project.urls] Repository`), now commented in place; the `README` had **no
|
| 864 |
+
install section at all**; `ComfyUI-H3-Motion-Context` was absent from Needs
|
| 865 |
+
despite the intro calling it the primary guidance path, so testers land on the
|
| 866 |
+
`MiniMaxH3AddGuide` fallback without knowing they changed code paths; and the
|
| 867 |
+
example workflows name quantised checkpoints by filename with no note that they
|
| 868 |
+
are one valid set among many, which reads as a broken graph rather than a
|
| 869 |
+
missing file.
|
| 870 |
+
|
| 871 |
+
Clean on the same pass, worth not re-checking: no personal data anywhere, no
|
| 872 |
+
hardcoded local paths in runtime code (only two dev-tool docstrings), 3.10-safe
|
| 873 |
+
syntax, no third-party dependencies, Motion-Context absence handled with fork
|
| 874 |
+
detection, `WEB_DIRECTORY` present, `h3_refs` auto-created.
|
| 875 |
+
|
| 876 |
+
## 18. What a 27B model got wrong, and what the prompt taught it (2026-08-29)
|
| 877 |
+
|
| 878 |
+
Qwen3 27B at temperature 0.3 was given an 8 x 15 s concept. The `ref_plan` came
|
| 879 |
+
back structurally perfect -- both subjects named, face plate on all eight hops,
|
| 880 |
+
places scheduled off the hops they do not belong on, zero register warnings. The
|
| 881 |
+
`shot_plan` carried four defects, and tracing each one back to
|
| 882 |
+
`prompt_pack/AUTHORING_PROMPT.md` found more than four gaps.
|
| 883 |
+
|
| 884 |
+
**The prompt taught one of the bugs.** Rule 9 illustrated a beat as
|
| 885 |
+
``"stands at the counter in `@kitchen`"`` -- backticks and all, because it was
|
| 886 |
+
the one place a tag appeared inside a beat. The model copied the formatting, and
|
| 887 |
+
literal backticks reach the encoder. Fixed, plus a rule that a beat is plain
|
| 888 |
+
prose.
|
| 889 |
+
|
| 890 |
+
**Beat length had no guidance at all.** The only budget in the file was for
|
| 891 |
+
dialogue, so a no-dialogue plan had nothing to size against and every beat came
|
| 892 |
+
back at 22-28 words regardless of a 15 s hop. There is now a word table -- and
|
| 893 |
+
an honest note on it: the shipped plans run 37-39 words at *both* 5 s and 7 s,
|
| 894 |
+
so beat length is near-constant across the only two hop lengths ever rendered.
|
| 895 |
+
The words-per-second reading that gives 70-100 for a 15 s hop is a reasoned
|
| 896 |
+
extrapolation, not a measurement, and the table says so.
|
| 897 |
+
|
| 898 |
+
**Two rules were simply missing.** Nothing told the model to state a visual style
|
| 899 |
+
-- for a "2D anime, Ufotable" concept neither returned block contained the word
|
| 900 |
+
anime, so the text asked for photoreal while the pictures asked for anime. And
|
| 901 |
+
the over-delivery rule from section 16 had never been carried into the prompt at
|
| 902 |
+
all, only into `PROMPTING.md` and the board.
|
| 903 |
+
|
| 904 |
+
**Two statements were wrong.** The prompt said "at most 9 pictures on any one
|
| 905 |
+
hop"; `parse_ref_plan` counts `len(refs)` over the **whole plan**. The code's own
|
| 906 |
+
error message said "on one hop" too, and has been corrected to match what it
|
| 907 |
+
checks. And the `file` bullet still told the model to invent placeholder
|
| 908 |
+
filenames without noting that, as of 0.3.1, a named file absent from `h3_refs`
|
| 909 |
+
**stops the run**.
|
| 910 |
+
|
| 911 |
+
Law 2 is the interesting non-fix. It is stated plainly, with the exact example
|
| 912 |
+
"The cook stops talking", and the model wrote "stops and looks up" anyway. A
|
| 913 |
+
principle is not something a 27B reliably applies to its own output; the literal
|
| 914 |
+
word list `check_prompts.py` enforces now appears in the prompt, because a word
|
| 915 |
+
list is checkable and a principle is not.
|
| 916 |
+
|
| 917 |
+
## 19. What 114 seconds of rendered film and two 27B models taught (2026-08-29)
|
| 918 |
+
|
| 919 |
+
Three sources landed together: a rendered 8 x 15 s anime chain (`chain_00003`,
|
| 920 |
+
2742 frames, 114.25 s), and Qwen and Gemma each answering two test prompts
|
| 921 |
+
written to trip specific rules. Shipped as **0.3.3**.
|
| 922 |
+
|
| 923 |
+
### The node was fighting every stylised plan
|
| 924 |
+
|
| 925 |
+
`directives.py` prepended `ESTABLISH = "Live-action, natural light, one
|
| 926 |
+
continuous take."` to hop 1 **unconditionally**. At cfg 1.0 with no negative
|
| 927 |
+
branch that is additive, and it landed *ahead* of the style declaration rule 12
|
| 928 |
+
requires. A stop-motion puppet plan compiled to "Live-action, natural light,
|
| 929 |
+
one continuous take. ... Hand-drawn stop-motion puppet animation in felt and
|
| 930 |
+
painted wood", and the two fought.
|
| 931 |
+
|
| 932 |
+
It also explains the anime chain's opening: hop 1 rendered as bright
|
| 933 |
+
naturalistic daylight (**mean luma 72**) against a night plan *and* a night
|
| 934 |
+
place plate, then fell to 46 on hop 2 the moment ESTABLISH stopped riding. That
|
| 935 |
+
had been read as drift; it was the node.
|
| 936 |
+
|
| 937 |
+
Never surfaced because both shipped workflows are live-action.
|
| 938 |
+
|
| 939 |
+
Fixed twice over, because either alone leaves a hole: an `establish` widget
|
| 940 |
+
(appended **last** in `optional`, per the positional-widget rule in section 8),
|
| 941 |
+
and `directives.declares_own_medium()` / `establish_for()`, which drop the
|
| 942 |
+
default when shot 1's opening names a medium. A model-authored plan never
|
| 943 |
+
touches a widget, which is why the automatic arm is the one that matters.
|
| 944 |
+
|
| 945 |
+
### Luminance drifts, and it only goes one way
|
| 946 |
+
|
| 947 |
+
Mean luma per hop across the eight: **72, 46, 35, 17, 11, 11, 19, 14**. Setting
|
| 948 |
+
the ESTABLISH artefact aside, hops 2-6 still slide 46 -> 11. The combat, the
|
| 949 |
+
point of the film, plays at the bottom of it.
|
| 950 |
+
|
| 951 |
+
This is the colour law one level up. `locked` holds a face; nothing holds an
|
| 952 |
+
exposure. Each hop inherits the last frame and darkens it slightly and the
|
| 953 |
+
error compounds. **Restate the light as a positive property in every beat** --
|
| 954 |
+
naming a light *source* ("pale moonlight") does not set a level, and at cfg 1.0
|
| 955 |
+
it only adds a moon.
|
| 956 |
+
|
| 957 |
+
### The seams were never the problem
|
| 958 |
+
|
| 959 |
+
7 of 7 hop joins are invisible: largest frame-to-frame difference at any seam is
|
| 960 |
+
13.6, inside the range of ordinary in-shot motion, and two seams score below the
|
| 961 |
+
film's own mean. Identity held 114 seconds on face plates riding every hop.
|
| 962 |
+
|
| 963 |
+
The one hard cut in the film is **inside** hop 4, 3.25 s in, at 7.1 sd -- more
|
| 964 |
+
than double any other jump. Shot 3 ended "ahead the trunks begin to thin toward
|
| 965 |
+
open ground"; shot 4 opened "Across the flat moonlit stone of @arena_clearing
|
| 966 |
+
the two of them square off". Hop 4 was handed a live frame of a man among trees
|
| 967 |
+
and a beat asserting he stood on open stone, held the forest for 3.25 s, then
|
| 968 |
+
reset the scene. The plan was clean under `check_coherence` and the banned-word
|
| 969 |
+
scan. Hence `plan.check_place_handoff()`.
|
| 970 |
+
|
| 971 |
+
### What the two models did
|
| 972 |
+
|
| 973 |
+
Near-identical answers from Qwen and Gemma on the same prompt -- same beats to
|
| 974 |
+
the word in 5 of 6 shots, same defects, same invented justification. The prompt
|
| 975 |
+
is prescriptive enough to collapse two models onto one answer; a shared blind
|
| 976 |
+
spot is then invisible from output alone.
|
| 977 |
+
|
| 978 |
+
**The word table was inert.** Both models, asked for six 15 s hops, returned
|
| 979 |
+
beats averaging **54 words** against a 70-100 band -- every beat under the floor
|
| 980 |
+
-- and the same models on much shorter hops returned 40-48. Beat length is
|
| 981 |
+
near-invariant to hop length in model output, now measured in a third setting.
|
| 982 |
+
A reference table does not move a model with a prior on paragraph length; the
|
| 983 |
+
rule is now an instruction to count, with a worked 74-word example.
|
| 984 |
+
|
| 985 |
+
**The banned-word list beat the principle, and made things worse.** Zero literal
|
| 986 |
+
banned words in all four plans -- and "The storm's roar begins to fade...
|
| 987 |
+
raindrops strike the glass with decreasing force" and "The storm has passed".
|
| 988 |
+
The list taught token avoidance and handed them a box to tick. It is now framed
|
| 989 |
+
as crude examples of an idea, with the test stated as: is this happening, or has
|
| 990 |
+
it finished happening?
|
| 991 |
+
|
| 992 |
+
**Both abandoned the second location.** Each plated the opening place, moved the
|
| 993 |
+
story elsewhere, gave the new place no plate, and justified it with a rule that
|
| 994 |
+
does not exist ("to avoid conflicting with the frame pin of the new space");
|
| 995 |
+
Qwen cited "rule 8/9", which does not say that. In the lighthouse plan that left
|
| 996 |
+
the lamp room -- four of six hops -- on beat text alone.
|
| 997 |
+
|
| 998 |
+
### `check_place_handoff` has two arms, and both were narrowed by real plans
|
| 999 |
+
|
| 1000 |
+
1. **Handoff.** Shot N names a place tag shot N-1 never mentions, and shot N's
|
| 1001 |
+
own beat does not carry the journey. The arrival vocabulary had to widen: the
|
| 1002 |
+
Showcase's shot 6 ("walks back along the hallway and through the doorway to
|
| 1003 |
+
the counter in @kitchen") is correct and was being flagged.
|
| 1004 |
+
2. **Abandonment**, not gaps. Warning on any unplated hop also flagged the
|
| 1005 |
+
Showcase, which deliberately walks her down an unplated hallway on 4-5 and
|
| 1006 |
+
returns the kitchen plate on 6. The rule that survives contact: warn only
|
| 1007 |
+
when the plates stop and **never resume**, so the film ends somewhere no
|
| 1008 |
+
picture describes.
|
| 1009 |
+
|
| 1010 |
+
Both shipped workflows are clean under the final version; the anime plan raises
|
| 1011 |
+
exactly one warning, on shot 4.
|
| 1012 |
+
|
| 1013 |
+
### Audio, for the record
|
| 1014 |
+
|
| 1015 |
+
No background music and no speech, confirmed on the spectrogram: broadband
|
| 1016 |
+
transients and noise, no harmonic bands. Seam levels hold within +/-2 dB on five
|
| 1017 |
+
of seven joins, and the two exceptions are drops the beats themselves ask for.
|
| 1018 |
+
The real audio issue is range, not seams: peaks reach -1.2 dBFS during the fight
|
| 1019 |
+
while hop 8 averages -42 dBFS. There is no audio equivalent of
|
| 1020 |
+
`HTCToneCompensate`. Left open.
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Sandpies
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Writing for Hand Tie Clips
|
| 2 |
+
|
| 3 |
+
Everything in this file was confirmed against renders, not reasoned from the
|
| 4 |
+
code. Where a rule exists because of something the model actually did, that is
|
| 5 |
+
said out loud — the reasons are more useful than the rules.
|
| 6 |
+
|
| 7 |
+
If you only read one section, read [The three laws](#the-three-laws).
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## What you are actually writing
|
| 12 |
+
|
| 13 |
+
Two JSON documents, both living in widgets on the node, both editable as cards
|
| 14 |
+
in the panel or as raw JSON under the **JSON** tab.
|
| 15 |
+
|
| 16 |
+
| | |
|
| 17 |
+
|---|---|
|
| 18 |
+
| **`shot_plan`** | An ordered list of shots. **One shot per hop** — the shot count *is* the hop count. |
|
| 19 |
+
| **`ref_plan`** | The reference register: which pictures exist, what each one is for, who they are photographs *of*, and which hops each belongs on. |
|
| 20 |
+
|
| 21 |
+
The node writes a great deal that you do not: the identity lock, the citation of
|
| 22 |
+
the live frame carried over from the previous hop, the join sentence, and the
|
| 23 |
+
per-hop `<Picture N>` numbering. Your job is the **beat** — what happens this
|
| 24 |
+
hop — plus the register that tells the node who is in it.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## The three laws
|
| 29 |
+
|
| 30 |
+
### 1. The prompt is additive
|
| 31 |
+
|
| 32 |
+
Sampling runs at **cfg 1.0 with no negative branch**. There is nothing to push
|
| 33 |
+
against. Every concept you name is added to the conditioning and nothing can be
|
| 34 |
+
removed by mentioning it.
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
BAD No cut, no camera shake, don't change the lighting.
|
| 38 |
+
GOOD One unbroken take. The camera holds its position. The light stays as it is.
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
The bad version puts `cut`, `camera shake` and `change the lighting` in front of
|
| 42 |
+
the encoder, which is precisely the opposite of the intent. Write what the shot
|
| 43 |
+
*is* doing. This is also why the directive vocabulary is phrased affirmatively
|
| 44 |
+
throughout, and why an unset axis emits nothing at all rather than asserting a
|
| 45 |
+
default — an unset axis costs zero tokens.
|
| 46 |
+
|
| 47 |
+
**Whether this bites inside quoted dialogue has not been tested.** A line like
|
| 48 |
+
*'I did not think you would come'* is speech, not a stage direction, and the
|
| 49 |
+
model may well treat it as such — but nothing here was confirmed against a
|
| 50 |
+
render, so the shipped templates avoid negation in dialogue rather than assume
|
| 51 |
+
either answer. Your own lines are your call.
|
| 52 |
+
|
| 53 |
+
### 2. Never name the thing you want to end
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
BAD The cook stops talking and stands still.
|
| 57 |
+
GOOD The cook leans back against the counter with her lips closed, and lets her
|
| 58 |
+
eyes move slowly across the room. The kitchen is quiet apart from the low
|
| 59 |
+
hum of the refrigerator.
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
`stops talking` keeps her talking. Describe the state you want as **a pose plus
|
| 63 |
+
a sound**. This follows directly from law 1 but it is the single most common
|
| 64 |
+
mistake, so it gets its own rule.
|
| 65 |
+
|
| 66 |
+
**Silence has to be written as a sound.** H3 generates audio for the whole hop
|
| 67 |
+
whether or not you asked for any. Written as an absence, you get speech — the
|
| 68 |
+
model fills the track with the most likely thing. Written as room tone, a
|
| 69 |
+
refrigerator, a distant car, you get room tone.
|
| 70 |
+
|
| 71 |
+
### 3. Put state changes at the end of the *previous* shot
|
| 72 |
+
|
| 73 |
+
Every hop after the first opens by holding the frames it was handed, and the
|
| 74 |
+
audio pin carries the tail of the previous hop across the join. If shot 2 ends
|
| 75 |
+
mid-sentence, **no wording in shot 3 will make shot 3 start quiet.**
|
| 76 |
+
|
| 77 |
+
```
|
| 78 |
+
shot 2: The cook sets the knife down, turns toward the window, and finishes her
|
| 79 |
+
last word as she looks out.
|
| 80 |
+
shot 3: The cook leans back against the counter with her lips closed...
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
To be silent, still, or somewhere else in a shot, **arrive there before the
|
| 84 |
+
previous shot ends.**
|
| 85 |
+
|
| 86 |
+
### The other half of law 3: a beat must survive an over-delivered hop
|
| 87 |
+
|
| 88 |
+
Law 3 says put a state change at the end of the *previous* shot. Its mirror is
|
| 89 |
+
just as load-bearing: **never write a beat that assumes where the previous hop
|
| 90 |
+
stopped.** A hop routinely does more than you asked. Give it a walk that was
|
| 91 |
+
meant to be a first step and it will finish the whole walk — seven steps of
|
| 92 |
+
turbo sampling has no reason to stop halfway.
|
| 93 |
+
|
| 94 |
+
If the next beat then instructs an action its own live frame has already carried
|
| 95 |
+
out, there is exactly one way for the model to satisfy it: reset the scene and
|
| 96 |
+
do it again. That reads as a hard cut about a second and a half *into* the hop,
|
| 97 |
+
not at the seam, because `_assemble_next` holds the incoming frames for a short
|
| 98 |
+
beat first.
|
| 99 |
+
|
| 100 |
+
Observed on a six-hop chain. Shot 3 asked for *"takes a first slow step along
|
| 101 |
+
the counter"* and delivered the entire walk to the window in a tight close.
|
| 102 |
+
Shot 4 was then handed *"carries the bowl the length of the counter to the
|
| 103 |
+
window and sets it down on the sill"* — already true. It cut at 21.8 s.
|
| 104 |
+
|
| 105 |
+
Two fixes, and use both:
|
| 106 |
+
|
| 107 |
+
- **Give one hop the whole movement.** Splitting a walk across two hops is what
|
| 108 |
+
creates the impossible second instruction.
|
| 109 |
+
- **Write the next beat so it is true from either ending.** *"She reaches the
|
| 110 |
+
window and sets the bowl down"* is satisfiable whether she arrived last hop or
|
| 111 |
+
is still arriving. *"She takes up the bowl again"* asserts no location for the
|
| 112 |
+
bowl, so a hop that never saw it set down still has something legal to do.
|
| 113 |
+
|
| 114 |
+
The rewritten chain rendered 39 s with no cut detectable at a 0.08 scene
|
| 115 |
+
threshold.
|
| 116 |
+
|
| 117 |
+
### Dialogue propagates, and `tail: ongoing` compounds it
|
| 118 |
+
|
| 119 |
+
Observed on a six-hop chain: shot 1 ended on its spoken line, and the character
|
| 120 |
+
**went on talking through all five remaining hops**, none of which had any
|
| 121 |
+
dialogue written.
|
| 122 |
+
|
| 123 |
+
Two things caused it together. The audio pin carries the previous hop's tail, so
|
| 124 |
+
the last second of hop 1 — speech — became the opening of hop 2. And
|
| 125 |
+
`tail: ongoing` closes the prompt with *action is still underway*, which the
|
| 126 |
+
model satisfied with the action it could hear.
|
| 127 |
+
|
| 128 |
+
The fix is not to write "she is silent" in the later shots. It is to **land the
|
| 129 |
+
line early in the hop and leave a non-verbal action running into the seam**:
|
| 130 |
+
|
| 131 |
+
```
|
| 132 |
+
BAD ...looks up from the chopping board and says, 'You are early.'
|
| 133 |
+
tail: ongoing
|
| 134 |
+
|
| 135 |
+
GOOD ...looks up from the chopping board and says, 'You are early. I have
|
| 136 |
+
barely started.' She turns back to the board and goes on slicing, the
|
| 137 |
+
knife tapping steadily against the wood.
|
| 138 |
+
tail: ongoing
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
The knife is what is still underway when the frames stop, so that is what the
|
| 142 |
+
pin carries. Then **give every dialogue-free hop a sound bed of its own** —
|
| 143 |
+
footsteps, a refrigerator, rain on glass — or the audio has nowhere to go but
|
| 144 |
+
back to speech.
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## Templates
|
| 149 |
+
|
| 150 |
+
The **Templates** button in the SCRIPT header appends ready-made patterns —
|
| 151 |
+
*Dialogue, held*, *Cross the room*, *Leave for an unseen space*, *Return to the
|
| 152 |
+
room*, *Quiet close*. They append rather than replace, so a chain is built by
|
| 153 |
+
stacking them.
|
| 154 |
+
|
| 155 |
+
Each one exists to demonstrate a rule on this page in a form you can edit rather
|
| 156 |
+
than read, and each carries a note about how to schedule references around it.
|
| 157 |
+
None of them contains an `@tag`: a template that named a reference you had not
|
| 158 |
+
created would turn one click into a run-time error.
|
| 159 |
+
|
| 160 |
+
## Writing a beat
|
| 161 |
+
|
| 162 |
+
**Shot 1 is the whole opening.** Establish the person, the place, the light, what
|
| 163 |
+
they are doing. **Every later shot is only the new beat** — do not re-describe
|
| 164 |
+
the face, the clothes, or the room. The photographs, the register and the pin
|
| 165 |
+
already carry all three, and re-asserting them competes with the pin rather than
|
| 166 |
+
reinforcing it.
|
| 167 |
+
|
| 168 |
+
```
|
| 169 |
+
shot 1: @hero_face stands at the counter in @kitchen, an apron over a grey
|
| 170 |
+
t-shirt. She looks up from the chopping board and says one short line,
|
| 171 |
+
then turns back to the board and goes on slicing, the knife tapping
|
| 172 |
+
against the wood.
|
| 173 |
+
shot 2: She sets the knife down and walks the length of the counter to the
|
| 174 |
+
window, looking out at the street. Her steps are soft on the tiles and
|
| 175 |
+
the refrigerator hums behind her.
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
Shot 2 says nothing about her face, her apron or the kitchen. It does not need
|
| 179 |
+
to. It *does* name a sound, because shot 2 has no dialogue and the audio needs
|
| 180 |
+
somewhere to go that is not the previous hop's speech.
|
| 181 |
+
|
| 182 |
+
### Point the action line at the reference you want used
|
| 183 |
+
|
| 184 |
+
Describing a reference in the register is **not enough**. The beat is what drives
|
| 185 |
+
the frame. Write the tag into the action:
|
| 186 |
+
|
| 187 |
+
```
|
| 188 |
+
The cook stands at the counter in @kitchen, looks up from the chopping board,
|
| 189 |
+
and speaks one short line to someone off-frame.
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
`@kitchen` resolves to whatever `<Picture N>` that reference happens to be on
|
| 193 |
+
this hop, so adding, removing or rescheduling references never breaks your text.
|
| 194 |
+
|
| 195 |
+
A **person** tag behaves differently on each side of the first hop, and the node
|
| 196 |
+
handles the difference for you. On hop 1 `@hero_face` becomes `<Subject 1>`,
|
| 197 |
+
bound by the `subject_definitions:` block that only hop 1 carries. From hop 2 on
|
| 198 |
+
that block is gone, so an ordinal would dangle — and the node substitutes the
|
| 199 |
+
subject's `name` instead:
|
| 200 |
+
|
| 201 |
+
```
|
| 202 |
+
hop 1: <Subject 1> stands at the counter in <Picture 2>...
|
| 203 |
+
hop 4: The cook walks down a narrow hallway...
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
"The cook" binds to the identity sentence `continuity_line` puts on every
|
| 207 |
+
continuation hop. **So give every subject a `name`** — without one the node
|
| 208 |
+
falls back to `<Subject N>`, which points at nothing on that hop. Capitalisation
|
| 209 |
+
is handled at sentence starts.
|
| 210 |
+
|
| 211 |
+
Phrase a place as **a place that is depicted**, not as a container to be placed
|
| 212 |
+
inside — "in @kitchen", not "inside the image of @kitchen". The latter can
|
| 213 |
+
produce a literal composite of the photograph.
|
| 214 |
+
|
| 215 |
+
### Dialogue
|
| 216 |
+
|
| 217 |
+
Write the line, in quotes, inside the beat:
|
| 218 |
+
|
| 219 |
+
```
|
| 220 |
+
@hero_face looks up from the chopping board and says, "You are early. I have
|
| 221 |
+
barely started." She turns back to the board and goes on slicing.
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
Land the line in the **middle** of the hop, never at its end — see
|
| 225 |
+
[Dialogue propagates](#dialogue-propagates-and-tail-ongoing-compounds-it).
|
| 226 |
+
|
| 227 |
+
Keep it to roughly one line per 5–7 seconds of hop. A long speech in a short hop
|
| 228 |
+
gets truncated mid-word, and that truncation is then pinned into the next hop's
|
| 229 |
+
audio.
|
| 230 |
+
|
| 231 |
+
If you are pasting a plan into the **JSON** tab rather than typing into the
|
| 232 |
+
cards, single quotes around dialogue survive the round trip more reliably than
|
| 233 |
+
escaped double quotes, and read identically to the encoder.
|
| 234 |
+
|
| 235 |
+
### Ambience: narrowband and specific
|
| 236 |
+
|
| 237 |
+
After shot 1 the official soundscape fields are stripped and ambience is plain
|
| 238 |
+
prose, so the exact words carry all the weight.
|
| 239 |
+
|
| 240 |
+
| | |
|
| 241 |
+
|---|---|
|
| 242 |
+
| `faint street noise through the window` | broadband — renders as a five-second hiss |
|
| 243 |
+
| `the low hum of the refrigerator` | narrowband — renders as a refrigerator |
|
| 244 |
+
| `a single click from the refrigerator, then stillness` | a discrete event — the fallback when a continuous bed still misbehaves |
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
## Directives
|
| 249 |
+
|
| 250 |
+
Five axes, all optional. Set on a shot as `"directives": {...}`.
|
| 251 |
+
|
| 252 |
+
| axis | values | notes |
|
| 253 |
+
|---|---|---|
|
| 254 |
+
| `join` | `continuous`, `match_cut`, `hard_cut` | **Ignored on shot 1**, which has nothing to join to. |
|
| 255 |
+
| `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` | |
|
| 256 |
+
| `framing` | `keep`, `wide`, `medium`, `close` | |
|
| 257 |
+
| `pace` | `slow`, `steady`, `brisk` | |
|
| 258 |
+
| `tail` | `ongoing`, `settle`, `hold` | Defaults to `ongoing`. The only axis with a default. |
|
| 259 |
+
|
| 260 |
+
They compile in that order, `join` first, because `join` describes how this hop
|
| 261 |
+
meets the previous one.
|
| 262 |
+
|
| 263 |
+
### `tail` is not decoration — set it on your last shot
|
| 264 |
+
|
| 265 |
+
This axis exists because of an observed failure. A beat whose action completes
|
| 266 |
+
before the frames run out leaves the model with nothing to render, and it settles
|
| 267 |
+
onto its strongest remaining conditioning — **the identity photograph**. The last
|
| 268 |
+
few seconds of a chain would cut to the reference still.
|
| 269 |
+
|
| 270 |
+
`ongoing` is the default for exactly that reason: it gives the tail somewhere to
|
| 271 |
+
go that is not the reference. But on your **final** shot, `ongoing` tells the
|
| 272 |
+
model action is still underway at the final frame, and it will invent something
|
| 273 |
+
to satisfy that — a stray gesture, or a stray line of dialogue in the closing
|
| 274 |
+
second. Use `settle` or `hold` there.
|
| 275 |
+
|
| 276 |
+
### Two combinations the node will warn about
|
| 277 |
+
|
| 278 |
+
**`join: continuous` + a framing change + `camera: hold`.** A framing change asks
|
| 279 |
+
the audience to be somewhere new. With the camera still, the only way to get
|
| 280 |
+
there is a cut — so the join and the framing are asking for opposite things and
|
| 281 |
+
the model picks one. Earn the framing on the move (`push_in`, `pull_back`,
|
| 282 |
+
`pan_follow`) or set `framing: keep`.
|
| 283 |
+
|
| 284 |
+
**`push_in` + `wide`, or `pull_back` + `close`.** The move points the opposite way
|
| 285 |
+
from the destination. Physically contradictory at any join value.
|
| 286 |
+
|
| 287 |
+
Both are warnings, not errors — they are legitimate things to want. They just
|
| 288 |
+
rarely read the way you meant.
|
| 289 |
+
|
| 290 |
+
> When `join: continuous` and the camera *is* moving, the framing sentence
|
| 291 |
+
> compiles as a **landing** ("The move settles into a close shot…") rather than
|
| 292 |
+
> as the shot's opening state, so it does not fight the pin that still holds the
|
| 293 |
+
> previous framing. You do not have to do anything to get this; it is worth
|
| 294 |
+
> knowing it happens.
|
| 295 |
+
|
| 296 |
+
---
|
| 297 |
+
|
| 298 |
+
## The reference register
|
| 299 |
+
|
| 300 |
+
```json
|
| 301 |
+
{
|
| 302 |
+
"refs": [
|
| 303 |
+
{"tag": "hero_face", "file": "cook_face.jpg", "subject": 1,
|
| 304 |
+
"retention": "fully_preserved", "shots": [1, 4],
|
| 305 |
+
"desc": "head-and-shoulders, even light"},
|
| 306 |
+
{"tag": "hero_outfit", "file": "cook_apron.jpg", "subject": 1,
|
| 307 |
+
"retention": "partially_copy", "shots": [1]},
|
| 308 |
+
{"tag": "kitchen", "file": "kitchen_wide.jpg",
|
| 309 |
+
"retention": "reference", "shots": [1, 2, 3, 6]}
|
| 310 |
+
],
|
| 311 |
+
"subjects": {
|
| 312 |
+
"1": {
|
| 313 |
+
"name": "the cook",
|
| 314 |
+
"locked": "the same face, the same short dark hair, the same silver stud earrings",
|
| 315 |
+
"context": "the apron stays tied over the grey t-shirt"
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
```
|
| 320 |
+
|
| 321 |
+
### Fields
|
| 322 |
+
|
| 323 |
+
| ref field | |
|
| 324 |
+
|---|---|
|
| 325 |
+
| `tag` | **Required.** Short, stable, what you write in beats as `@tag`. Must be unique. |
|
| 326 |
+
| `file` | A basename in `ComfyUI/input/h3_refs`. Set for you when you drop a picture on the rail. Never a path. |
|
| 327 |
+
| `subject` | Integer ≥ 1. Groups pictures **of the same person**. |
|
| 328 |
+
| `retention` | `fully_preserved`, `partially_copy`, `reference`. Defaults to `fully_preserved` when `subject` is set, `reference` otherwise. |
|
| 329 |
+
| `desc` | Free text about the picture. |
|
| 330 |
+
| `shots` | 1-based hop numbers this picture rides on. See below — this is the important one. |
|
| 331 |
+
|
| 332 |
+
| retention | what carries over |
|
| 333 |
+
|---|---|
|
| 334 |
+
| `fully_preserved` | face, bone structure and hairstyle, exactly |
|
| 335 |
+
| `partially_copy` | the garment and its cut, moving naturally with the body |
|
| 336 |
+
| `reference` | the layout, surfaces and light, i.e. a place |
|
| 337 |
+
|
| 338 |
+
| subject field | |
|
| 339 |
+
|---|---|
|
| 340 |
+
| `name` | What to call them in prose. |
|
| 341 |
+
| `locked` | What must not change. **This is what survives when the photograph is off the hop.** |
|
| 342 |
+
| `context` | Situational state that should persist — wardrobe, what they are holding. |
|
| 343 |
+
|
| 344 |
+
At most **9 pictures on any one hop**.
|
| 345 |
+
|
| 346 |
+
### `subject` is not optional decoration
|
| 347 |
+
|
| 348 |
+
Declaring two photographs of two *different* people as the same subject makes
|
| 349 |
+
the model render the **average of two faces**. Group photos per person, always.
|
| 350 |
+
|
| 351 |
+
Conversely, a subject block that no ref claims is an error, not a warning: it
|
| 352 |
+
would ride every hop describing someone who never appears, and at cfg 1.0 that
|
| 353 |
+
is purely additive noise.
|
| 354 |
+
|
| 355 |
+
Give every subject a `locked`. Pictures alone put a face in front of the encoder;
|
| 356 |
+
`locked` is what carries the identity across a seam where the picture is absent.
|
| 357 |
+
|
| 358 |
+
> This was **not true before 2026-08-29**. `locked` and `context` reached hop 1
|
| 359 |
+
> only, and a hop scheduled with no references carried no identity text at all
|
| 360 |
+
> — which is exactly how a six-hop test lost its character on the one hop that
|
| 361 |
+
> had no pictures. They now ride every hop, phrased with no `<Picture N>` or
|
| 362 |
+
> `<Subject N>` citation so they cannot send the encoder back to the plates.
|
| 363 |
+
|
| 364 |
+
### Name a colour, or it drifts
|
| 365 |
+
|
| 366 |
+
`locked` and `context` ride every hop, but they only protect what they actually
|
| 367 |
+
describe. A noun with no adjective is exactly as unanchored as a `<Subject N>`
|
| 368 |
+
with no antecedent — each hop is an independent encode, and *"the bowl"* on
|
| 369 |
+
hop 4 gives that encode nothing to bind to.
|
| 370 |
+
|
| 371 |
+
Two renders of the same chain settled it. In the first, three hops said only
|
| 372 |
+
*"the bowl"*; it came back stainless steel on hop 4 and a different ceramic one
|
| 373 |
+
on hop 6. Adding the colour to every mention **and** stating it as a property in
|
| 374 |
+
`context` — *"the bowl she is using is white porcelain"* — held one white bowl
|
| 375 |
+
through all six hops of the re-run.
|
| 376 |
+
|
| 377 |
+
The second render then made the rule sharper than a designed test would have.
|
| 378 |
+
This `context` line ran on every hop:
|
| 379 |
+
|
| 380 |
+
> the apron stays tied over the **grey** t-shirt
|
| 381 |
+
|
| 382 |
+
The t-shirt has its colour named and is grey in all six hops. The apron is one
|
| 383 |
+
clause away in the same sentence, has no colour, and had turned denim blue by
|
| 384 |
+
hop 6. One sentence, one variable.
|
| 385 |
+
|
| 386 |
+
So:
|
| 387 |
+
|
| 388 |
+
- Put persistent properties in `context` as **properties, never locations**.
|
| 389 |
+
*"the bowl is white porcelain"* is true wherever she puts it; *"the bowl stays
|
| 390 |
+
in her hands"* becomes additive text fighting the beat the moment she sets it
|
| 391 |
+
down.
|
| 392 |
+
- Repeat the adjective in the beats too. *"the white bowl"*, every time.
|
| 393 |
+
- This applies to anything the register does not photograph on that hop. A ref
|
| 394 |
+
with `shots: [1]` stops defending its garment at hop 2; from there the only
|
| 395 |
+
thing holding it is the words.
|
| 396 |
+
|
| 397 |
+
### `shots` is how you schedule references — and the default will surprise you
|
| 398 |
+
|
| 399 |
+
> **On a continuation chain, a reference with no `shots` list rides hop 1 only.**
|
| 400 |
+
|
| 401 |
+
That is deliberate for **place** plates: a room still riding a hop set
|
| 402 |
+
somewhere else beats the pin, because the model holds a crisp well-lit picture
|
| 403 |
+
of one room and a noisy carried-over frame of another, and it follows the still.
|
| 404 |
+
List every hop a picture should appear on, explicitly.
|
| 405 |
+
|
| 406 |
+
> **For a face plate, schedule it on every hop.** This page used to warn that a
|
| 407 |
+
> face plate riding a later hop beats the pin the same way. A six-hop render
|
| 408 |
+
> contradicted it flatly: hop 4 carried the face plate through a walking medium
|
| 409 |
+
> shot in a space the plate had never seen — it was photographed in a different
|
| 410 |
+
> kitchen — and held cleanly, while hop 5, scheduled with no refs at all, came
|
| 411 |
+
> back a **different person**, and hop 6 never recovered.
|
| 412 |
+
>
|
| 413 |
+
> Identity drift is permanent. The room came back the instant a place plate rode
|
| 414 |
+
> hop 6; nothing brought the face back, because no face plate rode anything.
|
| 415 |
+
> `locked` holds a face that is already right. Only a plate rebuilds one that is
|
| 416 |
+
> gone, so do not let it go. Putting the face on `[1,2,3,4,5,6]` fixed it and
|
| 417 |
+
> cost nothing visible.
|
| 418 |
+
|
| 419 |
+
The useful consequence: you can take references *away* deliberately. A hop with
|
| 420 |
+
no room reference is a hop the model must invent a room for, which is exactly
|
| 421 |
+
what you want when the character walks somewhere the register has never seen.
|
| 422 |
+
|
| 423 |
+
---
|
| 424 |
+
|
| 425 |
+
## A worked six-hop plan
|
| 426 |
+
|
| 427 |
+
This is `workflows/HandTieClips_Showcase.json`, and it is built to be hard.
|
| 428 |
+
|
| 429 |
+
| hop | beat | refs active | what it tests |
|
| 430 |
+
|---|---|---|---|
|
| 431 |
+
| 1 | kitchen, dialogue | face, outfit, kitchen | establishment |
|
| 432 |
+
| 2 | walks to the window | kitchen | lateral movement, held identity |
|
| 433 |
+
| 3 | exits through the doorway | kitchen | leaving the referenced space |
|
| 434 |
+
| 4 | hallway | face | **an unseen space** — no room reference exists for it |
|
| 435 |
+
| 5 | dialogue at the far window | **none** | **identity, wardrobe and voice on the pin + `locked` alone** |
|
| 436 |
+
| 6 | returns to the kitchen | kitchen | re-entry after three hops away, on a `match_cut` |
|
| 437 |
+
|
| 438 |
+
Hop 6 joins on `match_cut`, not `continuous`. Asking for one unbroken take
|
| 439 |
+
between two different rooms is asking the impossible, and the first run of this
|
| 440 |
+
plan got what it deserved: the hallway morphed into the kitchen mid-turn. A walk
|
| 441 |
+
through a doorway *is* a cut on a matched movement.
|
| 442 |
+
|
| 443 |
+
Hop 5 is the point of the exercise. If the cook is the same person, in the same
|
| 444 |
+
apron, with the same voice, **with no picture in front of the encoder at all**,
|
| 445 |
+
the register is doing its job. Hop 6 then asks whether the room survived three
|
| 446 |
+
hops of absence.
|
| 447 |
+
|
| 448 |
+
The face plate returns on hop 4 and not on 5 on purpose: hop 4 is where the
|
| 449 |
+
character enters a space the register cannot describe, which is where identity
|
| 450 |
+
drift is most likely to start, and re-asserting the face there is cheaper than
|
| 451 |
+
recovering from drift on 5 and 6.
|
| 452 |
+
|
| 453 |
+
---
|
| 454 |
+
|
| 455 |
+
## Duration
|
| 456 |
+
|
| 457 |
+
The label on the widget and the frames the model actually renders agree to a
|
| 458 |
+
tenth of a second, because every offered value satisfies the model's own frame
|
| 459 |
+
alignment (`n % 17 == 5` at 24 fps).
|
| 460 |
+
|
| 461 |
+
| label | frames |
|
| 462 |
+
|---|---|
|
| 463 |
+
| `5 s` | 124 |
|
| 464 |
+
| `7 s` | 175 |
|
| 465 |
+
| `8 s` | 192 |
|
| 466 |
+
| `10 s` | 243 |
|
| 467 |
+
| `15 s` | 362 |
|
| 468 |
+
|
| 469 |
+
A shot may override the chain with `"duration": "7 s"`, using the same labels.
|
| 470 |
+
|
| 471 |
+
---
|
| 472 |
+
|
| 473 |
+
## When it goes wrong
|
| 474 |
+
|
| 475 |
+
| symptom | cause | fix |
|
| 476 |
+
|---|---|---|
|
| 477 |
+
| The clip cuts to the reference photo in its last seconds | The beat finished before the frames did, and the model settled onto its strongest remaining conditioning | Set `tail`, and give the beat enough to do |
|
| 478 |
+
| A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |
|
| 479 |
+
| She keeps talking after you asked for quiet | You named the ending (`stops talking`) — law 2 | Pose plus a sound |
|
| 480 |
+
| Dialogue continues into hops that have none written | The hop before ended on speech, and the audio pin carried it | Land the line early; leave a non-verbal action running into the seam; give every quiet hop its own sound bed |
|
| 481 |
+
| A character walks between two rooms and one morphs into the other | `join: continuous` across a real location change | `match_cut` |
|
| 482 |
+
| Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |
|
| 483 |
+
| Ambience is a five-second hiss | Broadband wording | Narrowband, or a single discrete event |
|
| 484 |
+
| Two characters' faces merge | Both declared as the same `subject` | One subject number per person |
|
| 485 |
+
| 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 |
|
| 486 |
+
| A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs something its own live frame already did. The only way to obey is to reset the scene | Give one hop the whole movement; write the next beat so it is true from either ending |
|
| 487 |
+
| An animated or stylised plan renders photoreal, and hop 1 most of all | The node prepends an establishing line to hop 1 and its default asserts **live action**, so "Live-action, natural light" was added ahead of your style. Fixed in 0.3.3: the default is dropped when shot 1 names a medium | Name the medium in shot 1's **first sentence**, or clear the `establish` widget |
|
| 488 |
+
| The film gets steadily darker hop after hop | Luminance drifts like colour, in one direction, and nothing pushes back. Measured at 72 -> 11 mean luma across six hops of a night chain | Restate the light as a positive property in every beat: "the clearing reads bright under the full moon, pale stone, open detail in the shadows" |
|
| 489 |
+
| A location introduced mid-plan drifts | It has no place plate. Tightening a plate to its own shots does not mean the next location goes without one | Add a plate for it, riding the hop it arrives and every hop after. `check_place_handoff` warns when the plan ends with no plate riding |
|
| 490 |
+
| A prop or garment changes material or colour | It is 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` |
|
| 491 |
+
| A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |
|
| 492 |
+
| The run stops naming a reference | That row's picture is not in `h3_refs`. A named file that is not on disk is fatal -- rendering the chain without it is never what was meant | Drop the file on the row in the REFERENCES rail, or clear its picture to run without it |
|
| 493 |
+
| A reference has no effect on some hop | Its `shots` list leaves that hop out. This is silent, not an error -- it is a legitimate schedule | List every hop the picture should ride |
|
| 494 |
+
| A pasted plan is rejected as invalid JSON | Usually escaped double quotes mangled in transit | Use single quotes around dialogue |
|
| 495 |
+
|
| 496 |
+
---
|
| 497 |
+
|
| 498 |
+
## Having a model write the plan
|
| 499 |
+
|
| 500 |
+
The whole vocabulary, schema and rule set fits in one prompt. `prompt_pack/`
|
| 501 |
+
holds a copy-paste system prompt that turns a plain description of a scene into
|
| 502 |
+
a `shot_plan` and `ref_plan` this node accepts:
|
| 503 |
+
|
| 504 |
+
- **[`prompt_pack/AUTHORING_PROMPT.md`](prompt_pack/AUTHORING_PROMPT.md)** — paste
|
| 505 |
+
into any chat model, describe your scene, paste the two JSON blocks back into
|
| 506 |
+
the node.
|
| 507 |
+
- **[`prompt_pack/SCHEMA.json`](prompt_pack/SCHEMA.json)** — the machine-readable
|
| 508 |
+
version, for anyone wiring this into their own tooling.
|
| 509 |
+
|
| 510 |
+
The node still validates everything, so a model that gets it wrong is caught
|
| 511 |
+
rather than obeyed.
|
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hand Tie Clips
|
| 2 |
+
|
| 3 |
+
One node. Write a shot plan, drop in your reference stills, queue.
|
| 4 |
+
|
| 5 |
+
> **Renamed 2026-08-29.** This pack was `ComfyUI-H3-Ref-Chain`. The nodes now
|
| 6 |
+
> live under the **Hand Tie Clips** category and register as `HandTieClips`,
|
| 7 |
+
> `HTCChainPreview`, `HTCToneCompensate` and `HTCContinuityState`. The old ids
|
| 8 |
+
> are still registered as deprecated aliases, so **every workflow saved before
|
| 9 |
+
> the rename keeps loading** — they are just hidden from node search. Nothing
|
| 10 |
+
> needs migrating.
|
| 11 |
+
|
| 12 |
+
**Writing for it:** [PROMPTING.md](PROMPTING.md) is the authoring guide — the rules that come from what this model actually does, not from taste. [prompt_pack/](prompt_pack/) has a copy-paste prompt that gets a language model to write plans for you.
|
| 13 |
+
|
| 14 |
+
Each hop is native **MiniMax H3 Reference-to-Video**. Hops after the first are guided by the **previous hop's sampler AV latent** via `ComfyUI-H3-Motion-Context` when that pack is installed (22 picture frames + 24-frame end-aligned audio). Stock `MiniMaxH3AddGuide` is the fallback when Motion-Context is missing or the previous hop was a pixel cache hit. Voice stays as a reference every hop. Identity stills ride hop 1; later hops use the pin for wardrobe and room unless a ref lists those hops in `shots`. A 5 s hop drops the airlock on a continuous join — validate seams at 8 s or 15 s.
|
| 15 |
+
|
| 16 |
+
This is not the seamless-chain pack. No airlock script, no Motion-Context, no interior patch.
|
| 17 |
+
|
| 18 |
+
## What v1.0 changed
|
| 19 |
+
|
| 20 |
+
The prompt is no longer a wall of `---`-delimited text, and the MODEL wire is no longer four extra nodes.
|
| 21 |
+
|
| 22 |
+
| | |
|
| 23 |
+
|---|---|
|
| 24 |
+
| **`shot_plan`** | The script, as JSON. One shot per hop — **the shot count is the hop count**, so `chains` can no longer disagree with it. |
|
| 25 |
+
| **`directives`** | `join` / `camera` / `framing` / `pace` / `tail` per shot, compiled to vetted prose. Improving a phrase improves every plan you have ever written. |
|
| 26 |
+
| **`ref_plan`** | Stable `@tags` for reference stills, grouped into subjects, each naming a picture file. Removing one can no longer silently renumber `<Picture N>`. |
|
| 27 |
+
| **`cache_hops`** | Lossless per-hop cache. Re-roll one shot, resume after a crash, and hold roughly one hop in RAM instead of the whole clip. |
|
| 28 |
+
| **the editor** | Cards on the node, not JSON in a textarea. Hover any directive to read the exact sentence it puts in the prompt, or hit **Templates** for a ready-made pattern. |
|
| 29 |
+
|
| 30 |
+
Simple mode keeps the old one-prompt workflow; the speed stack (LoRAs, AdaLN fix, low-VRAM, SLA) stays as four ordinary nodes on the MODEL wire, where you can see it.
|
| 31 |
+
|
| 32 |
+
## Install
|
| 33 |
+
|
| 34 |
+
Unzip the folder into `ComfyUI/custom_nodes/`, so that it lands as:
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
ComfyUI/custom_nodes/ComfyUI-Hand-Tie-Clips/
|
| 38 |
+
__init__.py
|
| 39 |
+
h3_ref_chain.py
|
| 40 |
+
js/
|
| 41 |
+
workflows/
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
One folder level too deep (`custom_nodes/ComfyUI-Hand-Tie-Clips/ComfyUI-Hand-Tie-Clips/`) is the usual mistake, and the pack simply will not appear.
|
| 45 |
+
|
| 46 |
+
No dependencies to install. Everything it imports -- `torch`, `numpy`, `PIL`, `av`, `aiohttp` -- already ships with ComfyUI, which is why `dependencies` in `pyproject.toml` is empty.
|
| 47 |
+
|
| 48 |
+
Then:
|
| 49 |
+
|
| 50 |
+
1. **Restart ComfyUI.** Python changed.
|
| 51 |
+
2. **Hard-refresh the browser** (Ctrl+Shift+R / Cmd+Shift+R). The editor is served from `/extensions/ComfyUI-Hand-Tie-Clips/`, and a stale cache is the single most common reason the node mounts with no UI on a fresh install.
|
| 52 |
+
|
| 53 |
+
It is installed correctly when all three are true:
|
| 54 |
+
|
| 55 |
+
- the startup log carries a line beginning `[HandTieClips]`
|
| 56 |
+
- the browser console says `[HandTieClips] editor ui v1.5.0 loaded`
|
| 57 |
+
- node search shows a **Hand Tie Clips** category with four nodes, each listed once
|
| 58 |
+
|
| 59 |
+
Workflows saved before the 2026-08-29 rename keep loading: the old ids are still registered as deprecated aliases. Nothing needs migrating.
|
| 60 |
+
|
| 61 |
+
The two example graphs are in `workflows/` inside the pack folder -- open them with the **Workflow > Open** menu, or copy them into `ComfyUI/user/default/workflows/` to have them in the sidebar. Start with `HandTieClips_Starter.json`; it ships with no references on purpose, so it runs before you have supplied any pictures.
|
| 62 |
+
|
| 63 |
+
## Needs
|
| 64 |
+
|
| 65 |
+
- ComfyUI new enough to include **Add Guide for MiniMax H3** (`MiniMaxH3AddGuide`)
|
| 66 |
+
- A **ref2va** (or hybrid ref2va) checkpoint — fl2va has no reference rows
|
| 67 |
+
- Video VAE + audio VAE + MiniMax text encoder
|
| 68 |
+
- `ffmpeg` on PATH, if you turn `cache_hops` on
|
| 69 |
+
- **Optional but recommended:** [ComfyUI-H3-Motion-Context](https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context). Hops after the first are guided by the previous hop's sampler AV latent through it. Without it the node falls back to stock `MiniMaxH3AddGuide`, which still works and still chains -- it is a different join, so a seam you are comparing against someone else's render may not be the same code path. The node detects it at runtime and prints which one it took.
|
| 70 |
+
|
| 71 |
+
The two shipped workflows wire the **turbo stack** this node is actually run with, because an example without it is not the graph anyone uses:
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
UNETLoader -> LoRA Loader Stack -> H3 AdaLN LoRA Fix -> MiniMax H3 Low VRAM
|
| 75 |
+
-> H3 SLA Attention -> Model Preview Override -> Hand Tie Clips
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
| pack | nodes |
|
| 79 |
+
|---|---|
|
| 80 |
+
| [ComfyUI-PlagueKind-Nodes](https://github.com/PlagueKind/ComfyUI-PlagueKind-Nodes) | LoRA Loader Stack, H3 AdaLN LoRA Fix, H3 SLA Attention |
|
| 81 |
+
| [ComfyUI-KJNodes](https://github.com/kijai/ComfyUI-KJNodes) | MiniMax H3 Low VRAM Attention (experimental), Model Preview Override |
|
| 82 |
+
|
| 83 |
+
On disk as well: the **turbo LoRA** named in the loader stack, and `taeh3.safetensors` for the live preview (or set `tiny_vae` to `none`).
|
| 84 |
+
|
| 85 |
+
Both shipped workflows are saved pointing at the exact files they were rendered with. These are quantised builds and are **not** the only ones that work -- any ref2va or hybrid ref2va set will do. Repoint the loaders at what you have; a loader showing an empty or red filename is naming a file you do not have, not a broken workflow.
|
| 86 |
+
|
| 87 |
+
| loader | file the example names |
|
| 88 |
+
|---|---|
|
| 89 |
+
| `UNETLoader` | `minimax_h3_hybrid_fl2va_ref2va_b30-49-int8.safetensors` |
|
| 90 |
+
| `CLIPLoader` | `qwen3vl_32b_minimax_h3_int8_convrot.safetensors` |
|
| 91 |
+
| `VAELoader` (video) | `minimax_h3_video_vae_int8_convrot.safetensors` |
|
| 92 |
+
| `VAELoader` (audio) | `minimax_h3_audio_vae_fp32.safetensors` |
|
| 93 |
+
| LoRA Loader Stack | `minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors` |
|
| 94 |
+
| `tiny_vae` | `taeh3.safetensors` (or `none`) |
|
| 95 |
+
|
| 96 |
+
**CLIP reaches the node from the LoRA loader, not from the encoder** — that is what makes the text half of every LoRA land. Do not rewire it back.
|
| 97 |
+
|
| 98 |
+
None of it is required by the node itself. Missing a pack, its nodes load as red boxes: delete them, wire the loader straight into `model` and the encoder into `clip`, and raise `steps` from 7 to around 20.
|
| 99 |
+
|
| 100 |
+
## Use
|
| 101 |
+
|
| 102 |
+
1. Restart ComfyUI and load **`workflows/HandTieClips_Starter.json`** — two hops, no references, runs as soon as the loaders are pointed at your files. It carries a six-card **READ ME** board on its own canvas, to the left of the loaders: the three laws, the directive table, the reference rules and the failure table, where you need them rather than in another file. `workflows/HandTieClips_Showcase.json` is the six-hop version and needs three pictures of your own. Both carry the turbo stack listed above.
|
| 103 |
+
2. Point the loaders at your ref2va DiT, encoder, and both VAEs
|
| 104 |
+
3. Add a row per identity still in the **REFERENCES** rail, then **drop a picture onto its thumbnail** (or click to browse, or pick one already uploaded). Give it a `@tag` and group photos of the same person under one subject number. There are no `Load Image` nodes to wire — files land in `ComfyUI/input/h3_refs`.
|
| 105 |
+
4. In **SHOTS**, write one card per hop. Shot 1 is the whole opening; every later card is only the new beat.
|
| 106 |
+
5. Optional: a `voice_file`, a `start_image_file`, a look `reference_video_file` — all picked in the **MEDIA** strip, all files under `input/h3_refs`
|
| 107 |
+
6. Queue, and wire the **`info`** output to a Preview Text node — it prints the fully assembled prompt for every hop
|
| 108 |
+
|
| 109 |
+
Keep later-hop beats on **what happens next**. Do not re-describe the face; the photos, the register and the pin already carry it.
|
| 110 |
+
|
| 111 |
+
Two modes:
|
| 112 |
+
|
| 113 |
+
- **Simple** — one prompt box and a hop count, the way it always worked. Later hops advance the same action rather than replaying the opening.
|
| 114 |
+
- **Shots** — one card per hop with directives. Switching from Simple carries your prompt over and splits it on `---`.
|
| 115 |
+
|
| 116 |
+
Only one of them is on screen at a time, so there is never a text box quietly doing nothing.
|
| 117 |
+
|
| 118 |
+
Under the script sits **RUN**, collapsed, holding everything that is not per-shot: output size and length, sampling, the join and pin controls, and the hop cache. Its title line summarises the run — `1.0 MP 16:9 · 10s ×3 · 14 steps res_multistep · cache off` — so you can read the setup without opening it. In Shots mode `chains` and `hop_script` are not offered there, because the shot list already decides both.
|
| 119 |
+
|
| 120 |
+
**`tone_compensate`** lives in that panel's *join & pin* group. The H3 denoiser biases each hop's tone, so a chain gets steadily brighter; this measures the bias on the overlap each hop regenerated and undoes it, correcting each hop against the previous **corrected** one so the whole chain lands on hop 1's tone. `frame_shift` is the mode to reach for: all three modes remove the drift equally well (within 0.4/255 of each other), but `gain_bias` and `lut` pair pixels between a frame and its *regeneration*, which fits a slope that is not really there and flattens contrast a little more with every hop. `frame_shift` uses frame averages only, so it can shift but never distort. **Measured on a 3-hop render: chain drift 5.6/255 without it, 0.3/255 with it.** Worth turning on for anything past two hops. It ships off because enabling it also clamps the master to 0..1, and because the correction grows with hop count — by hop 10 it is subtracting ~23/255 and will start crushing blacks. Switching modes never invalidates the hop cache, so it is free to A/B. Do **not** judge it by whether the seams flatten to zero: real scene brightness changes across a cut should survive, and they do.
|
| 121 |
+
|
| 122 |
+
## Shot plan
|
| 123 |
+
|
| 124 |
+
The cards write this; you rarely see it. It is under **JSON** on the node if you want to copy a plan between workflows.
|
| 125 |
+
|
| 126 |
+
```json
|
| 127 |
+
{
|
| 128 |
+
"shots": [
|
| 129 |
+
{
|
| 130 |
+
"beat": "The cook stands at the counter, looks up, and speaks one short line.",
|
| 131 |
+
"directives": {"camera": "hold", "framing": "medium", "pace": "steady", "tail": "ongoing"}
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"beat": "The cook sets the knife down and turns toward the window, still talking.",
|
| 135 |
+
"directives": {"join": "continuous", "camera": "push_in", "framing": "close"}
|
| 136 |
+
}
|
| 137 |
+
]
|
| 138 |
+
}
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Shot 1 is the whole opening. Every later shot is **only the new beat** — the node supplies the identity lock, the live-frame citation and the join itself.
|
| 142 |
+
|
| 143 |
+
Fields, all optional except `beat`:
|
| 144 |
+
|
| 145 |
+
| | |
|
| 146 |
+
|---|---|
|
| 147 |
+
| `beat` | What happens this hop. |
|
| 148 |
+
| `directives` | The five axes below. |
|
| 149 |
+
| `prose` | Free text appended verbatim, for anything the vocabulary lacks. |
|
| 150 |
+
| `seed`, `steps`, `duration` | Per-shot overrides. `duration` takes the same labels as the widget (`"8 s"`). |
|
| 151 |
+
| `locked` | Reuse this shot's cached render even when its inputs changed. Needs `cache_hops=on`. |
|
| 152 |
+
| `id` | Stable name, used as the cache pointer. Generated if absent. |
|
| 153 |
+
|
| 154 |
+
### Directives
|
| 155 |
+
|
| 156 |
+
| axis | options |
|
| 157 |
+
|---|---|
|
| 158 |
+
| `join` | `continuous`, `match_cut`, `hard_cut` — ignored on shot 1, which has nothing to join to |
|
| 159 |
+
| `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
|
| 160 |
+
| `framing` | `keep`, `wide`, `medium`, `close` |
|
| 161 |
+
| `pace` | `slow`, `steady`, `brisk` |
|
| 162 |
+
| `tail` | `ongoing` (default), `settle`, `hold` |
|
| 163 |
+
|
| 164 |
+
An unset axis emits nothing rather than asserting a default, so it costs no tokens.
|
| 165 |
+
|
| 166 |
+
**Everything is phrased affirmatively, on purpose.** Sampling runs at cfg 1.0 with no negative branch, so every concept named is additive — "no cut" puts the word `cut` in front of the encoder. If you add prose of your own, say what the shot *is* doing.
|
| 167 |
+
|
| 168 |
+
`join=continuous` together with a framing change and a held camera will warn: with the camera still, the only way to reach a new framing is a cut, so the two are asking for opposite things. Use `push_in` / `pull_back` / `pan_follow` to earn the framing on the move, or `framing: keep`.
|
| 169 |
+
|
| 170 |
+
A camera move that points the opposite way from the framing (`push_in` with `wide`, `pull_back` with `close`) warns too -- those are physically contradictory whatever the join.
|
| 171 |
+
|
| 172 |
+
When `join=continuous` and the camera *is* moving, the framing sentence is compiled as a **landing** ("The move settles into a close shot...") rather than as the shot's opening state, so it does not fight the pin that still holds the previous framing.
|
| 173 |
+
|
| 174 |
+
## Writing beats
|
| 175 |
+
|
| 176 |
+
> The full authoring guide, including how to have a model write plans for you, is **[PROMPTING.md](PROMPTING.md)**. What follows is the short version.
|
| 177 |
+
|
| 178 |
+
Sampling runs at cfg 1.0 with no negative branch, so **the prompt is additive:
|
| 179 |
+
anything you name is added, and nothing can be removed by mentioning it.** Three
|
| 180 |
+
rules follow, each confirmed against renders rather than reasoned from the code.
|
| 181 |
+
|
| 182 |
+
**Point the action line at the reference you want used.** Describing a reference
|
| 183 |
+
in the register is not enough -- the beat is what drives the frame. Write the tag
|
| 184 |
+
into the action:
|
| 185 |
+
|
| 186 |
+
```
|
| 187 |
+
The cook stands at the counter in @kitchen, looks up from the chopping board,
|
| 188 |
+
and speaks one short line to someone off-frame.
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
`@kitchen` resolves to the right `<Picture N>` on every hop it is active, so
|
| 192 |
+
rewiring references never breaks the text. Phrase it as a place that is depicted,
|
| 193 |
+
not as a container to be placed inside -- otherwise you can get a literal
|
| 194 |
+
composite of the photograph.
|
| 195 |
+
|
| 196 |
+
**Never name the thing you want to end.** `The cook stops talking` keeps her
|
| 197 |
+
talking. Describe the state you want as a pose plus a sound:
|
| 198 |
+
|
| 199 |
+
```
|
| 200 |
+
The cook leans back against the counter with her lips closed, and lets her eyes
|
| 201 |
+
move slowly across the room. The kitchen is quiet apart from the hum of the
|
| 202 |
+
refrigerator and faint street noise through the window.
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
H3 generates audio for the whole hop no matter what, so silence must be written
|
| 206 |
+
as a sound -- room tone, a fridge, traffic. Written as an absence, you get speech.
|
| 207 |
+
|
| 208 |
+
**Put a state change at the end of the previous shot.** Every hop after the first
|
| 209 |
+
opens by holding the frames it was handed, and the audio pin carries the tail of
|
| 210 |
+
the previous hop across the join. If shot 2 ends mid-sentence, no wording in shot
|
| 211 |
+
3 will make it start quiet. Finish the line where it actually finishes:
|
| 212 |
+
|
| 213 |
+
```
|
| 214 |
+
shot 2: The cook sets the knife down, turns toward the window, and finishes her
|
| 215 |
+
last word as she looks out.
|
| 216 |
+
shot 3: The cook leans back against the counter with her lips closed...
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
To be silent, still, or somewhere else in a shot, arrive there before the
|
| 220 |
+
previous shot ends.
|
| 221 |
+
|
| 222 |
+
**Pick ambience that is narrowband and specific.** After the first shot, official
|
| 223 |
+
soundscape fields are stripped and ambience is plain prose, so the exact words
|
| 224 |
+
matter. "faint street noise through the window" is broadband and renders as a
|
| 225 |
+
five-second hiss; "the low hum of the refrigerator" does not. If a continuous bed
|
| 226 |
+
still misbehaves, name a single discrete event instead -- "a single click from the
|
| 227 |
+
refrigerator, then stillness".
|
| 228 |
+
|
| 229 |
+
**Set `tail` on your last shot.** `settle` and `hold` change the final sentence of
|
| 230 |
+
the prompt, which governs the terminal state of the clip. Left on `ongoing`, the
|
| 231 |
+
model is told action is still underway at the final frame and will invent
|
| 232 |
+
something to satisfy it -- on a last shot that means a stray gesture or a stray
|
| 233 |
+
line of dialogue in the closing second.
|
| 234 |
+
|
| 235 |
+
## Reference register
|
| 236 |
+
|
| 237 |
+
```json
|
| 238 |
+
{
|
| 239 |
+
"refs": [
|
| 240 |
+
{"tag": "hero_face", "file": "cook_face.jpg", "subject": 1, "retention": "fully_preserved"},
|
| 241 |
+
{"tag": "hero_outfit", "file": "cook_apron.jpg", "subject": 1, "retention": "partially_copy"},
|
| 242 |
+
{"tag": "kitchen", "file": "kitchen_wide.jpg", "retention": "reference"}
|
| 243 |
+
],
|
| 244 |
+
"subjects": {
|
| 245 |
+
"1": {"name": "the cook", "locked": "the same face, the same short dark hair"}
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
`file` is a picture in `ComfyUI/input/h3_refs`, set by the rail. `tag` is what you write in your beats — `@hero_face` — and the node resolves it to the right `<Picture N>` **per hop**, so pulling a still out of the middle no longer breaks every later reference.
|
| 251 |
+
|
| 252 |
+
`subject` groups pictures per person. This matters: declaring every picture as a photo of `<Subject 1>` makes the model render the *average* of two different people.
|
| 253 |
+
|
| 254 |
+
`retention` says how much of a picture carries over — `fully_preserved` (face and bone structure exactly), `partially_copy` (the garment and its cut), `reference` (layout, surfaces and light, i.e. a place). Refs with a subject default to `fully_preserved`; everything else defaults to `reference`.
|
| 255 |
+
|
| 256 |
+
Add `"shots": [1, 2]` to a ref to keep it out of the hops it does not belong in. On a continuation chain (`hop_script=next` / a shot plan), omitting `shots` means **hop 1 only** — right for a place plate, which beats the pin if it rides a hop set somewhere else. **Put face plates on every hop:** a hop with no face reference comes back a different person and no later hop recovers.
|
| 257 |
+
|
| 258 |
+
## Hop cache
|
| 259 |
+
|
| 260 |
+
`cache_hops=on` writes every rendered hop to ComfyUI's temp dir as lossless FFV1 video plus a float32 `.npy` waveform, evicting least-recently-used above `cache_budget_gb`. The key is chained and includes the model fingerprint and, per hop, which pin mechanism was used -- a hop pinned by the AddGuide fallback is not the same render as one pinned by Motion-Context.
|
| 261 |
+
|
| 262 |
+
The key **chains** — each hop's key includes the previous hop's — because hops are causally dependent. So:
|
| 263 |
+
|
| 264 |
+
- edit shot 3 and re-queue → shots 1 and 2 load from cache, only 3 renders;
|
| 265 |
+
- edit shot 1 → all three re-render, which is correct, not a bug;
|
| 266 |
+
- change resolution, sampler, a reference still, a LoRA, or an attention setting → the whole chain re-renders.
|
| 267 |
+
|
| 268 |
+
That last one is worth knowing about. The node cannot read the settings on your LoRA and attention nodes, so instead it fingerprints what they *did* to the model — which weight keys were patched, at what strengths, and the attention overrides. Change a LoRA strength and the cache correctly invalidates. Two different LoRAs touching exactly the same keys at exactly the same strengths would look identical to it; that is the one gap.
|
| 269 |
+
|
| 270 |
+
Set `locked: true` on a shot to pin it to its last render regardless. Needs `ffmpeg` on PATH.
|
| 271 |
+
|
| 272 |
+
## The MODEL wire
|
| 273 |
+
|
| 274 |
+
Four ordinary nodes, in this order:
|
| 275 |
+
|
| 276 |
+
UNETLoader → LoRA stack (mode=minimax) → H3 AdaLN Fix → MiniMax Low VRAM Attention → H3 SLA Attention → H3 Ref2VA Chain
|
| 277 |
+
|
| 278 |
+
**Take CLIP from the LoRA loader's second output**, not straight from the encoder, or the text-side keys H3 LoRAs carry are silently dropped.
|
| 279 |
+
|
| 280 |
+
They briefly lived inside this node as a `model_patches` JSON widget. That made the graph shorter and the node harder to read, so it was reverted — these belong where you can see them.
|
| 281 |
+
|
| 282 |
+
## Pinning the previous hop
|
| 283 |
+
|
| 284 |
+
Three widgets tune the pin, all defaulting to their pre-existing behaviour:
|
| 285 |
+
|
| 286 |
+
| widget | default | what it does |
|
| 287 |
+
|---|---|---|
|
| 288 |
+
| `audio_pin_frames` | `24` | Audio context handed to the pin, in frames. 24 is one second on the model's 40 Hz grid. Longer audio context costs conditioning rows but **no delivered frames**, so it is the cheap lever on speech that breaks across a join — try `96` (4 s) for continuous dialogue. |
|
| 289 |
+
| `pin_renorm` | `off` | Rescales each pinned latent so its spread matches the one hop 2 established. The pin's own sigma climbs hop over hop and that inflated pin conditions the next one, so texture ratchets along a long chain. A scalar rescale moves no structure, so it cannot blur detail. Video and audio are corrected separately — their sigmas drift by different amounts. Worth turning on for 3+ hops. |
|
| 290 |
+
| `pin_noise` | `0.0` | Mixes seeded noise into the pin — the other half of the same fix. Small values only; measured gains reverse above `0.10`, which is why the range stops there. |
|
| 291 |
+
|
| 292 |
+
The DiT pin is the previous hop’s **sampler latent** through Motion-Context when present (no decode/re-encode; audio window ends at the join). AddGuide on decoded frames is the fallback. `pin_to_qwen` still shows the incoming state to the text encoder:
|
| 293 |
+
|
| 294 |
+
| | |
|
| 295 |
+
|---|---|
|
| 296 |
+
| `off` | Pin stays DiT-only. |
|
| 297 |
+
| `last frame` (default) | Previous hop's last frame is `<Picture 1>`. Identity stills that still ride this hop shift to Picture 2+. |
|
| 298 |
+
| `pin clip` | Overlap frames become an extra `<Video>` at ~2 fps. No soundtrack on it, so `voice` stays `<Audio 1>`. |
|
| 299 |
+
| `both` | Last frame + pin clip. |
|
| 300 |
+
|
| 301 |
+
`@tags` in beats resolve per hop, so stills shifting to Picture 2+ does not break prose. Literal `<Picture N>` in hop 2+ beats would.
|
| 302 |
+
|
| 303 |
+
## Defaults
|
| 304 |
+
|
| 305 |
+
| | |
|
| 306 |
+
|---|---|
|
| 307 |
+
| resolution | 1.0 MP (1280×736 landscape) |
|
| 308 |
+
| duration | 10 s (243 frames) |
|
| 309 |
+
| overlap | 0.9 s (22 frames) |
|
| 310 |
+
| steps | 8, with a 4-step turbo LoRA — the regime this node targets |
|
| 311 |
+
| sampler / scheduler | `res_multistep` / `beta` |
|
| 312 |
+
| seed per hop | on |
|
| 313 |
+
| sigma shift | 12 / 3 |
|
| 314 |
+
| cache budget | 20 GB |
|
| 315 |
+
|
| 316 |
+
Three shots at 10 s with a 0.9 s overlap is about 28 s of master after the overlap is dropped.
|
| 317 |
+
|
| 318 |
+
## Limits
|
| 319 |
+
|
| 320 |
+
- **A workflow saved before 2026-08-28 loses its reference pictures.** The old `ref_image_N` sockets carried tensors, so there is no filename to recover. The rail names each affected ref and asks you to pick its picture; nothing else about the plan is lost.
|
| 321 |
+
- There is no shot-level `refs` field. Activation lives on the reference: give a ref a `shots` list, or use the per-hop chips on its row in the rail.
|
| 322 |
+
- The reference `desc` and subject `locked` text go to the encoder verbatim, every hop. At cfg 1.0 there is no negative branch, so a detail that is not in the photograph is **asked for**, not ignored. Describe what you actually wired.
|
| 323 |
+
- `HTCContinuityState` is **setting only**. Characters live in the reference register; the node's `characters_*` fields were removed because filling in both injected identity text twice.
|
| 324 |
+
- Each join hard-cuts video but crossfades audio ~40 ms, so A/V drifts ~40 ms per hop.
|
| 325 |
+
- Texture still ratchets on long chains. Stay around 3–5 hops until that is handled.
|
| 326 |
+
- A 22-frame pin clip is ~2 Qwen frames at 2 fps. It is a live-state hint, not a full previous-clip watch.
|
| 327 |
+
- A longer overlap does not fix continuity — it can pin whatever content happens to be in that longer tail.
|
| 328 |
+
|
| 329 |
+
## Nodes
|
| 330 |
+
|
| 331 |
+
**H3 Ref2VA Chain** — `images`, `audio`, `info` out. Wire `CreateVideo` + `SaveVideo` as in the example workflow, and `info` to a Preview Text node.
|
| 332 |
+
|
| 333 |
+
**H3 Chain Preview** — a passthrough panel for the IMAGE (and optionally AUDIO) wire, placed **between the chain and `CreateVideo`**. Images and audio come out unchanged, so adding or removing it changes no pixels. It shows the live sample, the seam (the previous hop's last frame beside this hop's first), a chain-wide progress bar, cache hit / seed / steps per hop, **which pin mechanism each hop actually used** — a latent Motion-Context pin or the AddGuide pixel fallback — and end-of-run A/V drift. Drag the grip to resize the stats panel; double-click it to reset.
|
| 334 |
+
|
| 335 |
+
**H3 Tone Compensate** — `images` out. Corrects a generated segment's tone against the previous one, estimated on the overlap they share. **For hand-built chains only.** It cannot fix `H3 Ref2VA Chain`'s output: that node joins its hops internally and drops each hop's first `overlap` frames at the seam, so the regenerated copies this needs are already gone by the time images leave it. Use the chain node's `tone_compensate` widget instead. Estimator ported from [rkfg/ComfyUI-MiniMaxH3-ToneCompensate](https://github.com/rkfg/ComfyUI-MiniMaxH3-ToneCompensate) (MIT).
|
| 336 |
+
|
| 337 |
+
**H3 Continuity State** — `continuity_state` (STRING) out. **Setting only**: `setting_locked` / `setting_context` / `setting_mutable`. Characters belong in `ref_plan`.
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hand Tie Clips — native MiniMax H3 Ref2VA continuation for ComfyUI."""
|
| 2 |
+
from .h3_ref_chain import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
| 3 |
+
from .preview_node import (
|
| 4 |
+
NODE_CLASS_MAPPINGS as _PREVIEW_NODES,
|
| 5 |
+
NODE_DISPLAY_NAME_MAPPINGS as _PREVIEW_NAMES,
|
| 6 |
+
)
|
| 7 |
+
from .tone import (
|
| 8 |
+
NODE_CLASS_MAPPINGS as _TONE_NODES,
|
| 9 |
+
NODE_DISPLAY_NAME_MAPPINGS as _TONE_NAMES,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
NODE_CLASS_MAPPINGS.update(_PREVIEW_NODES)
|
| 13 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_PREVIEW_NAMES)
|
| 14 |
+
NODE_CLASS_MAPPINGS.update(_TONE_NODES)
|
| 15 |
+
NODE_DISPLAY_NAME_MAPPINGS.update(_TONE_NAMES)
|
| 16 |
+
|
| 17 |
+
# The editor's dropdowns are served from directives.VOCAB rather than copied
|
| 18 |
+
# into JS. A failure here costs the dropdowns their tooltips, not the node, so
|
| 19 |
+
# it must never stop the pack from loading.
|
| 20 |
+
try:
|
| 21 |
+
from . import routes as _routes
|
| 22 |
+
_routes.register()
|
| 23 |
+
except Exception as _exc: # noqa: BLE001
|
| 24 |
+
print(f"[HandTieClips] vocab route not registered: {_exc!r}", flush=True)
|
| 25 |
+
|
| 26 |
+
WEB_DIRECTORY = "./js"
|
| 27 |
+
|
| 28 |
+
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Directive vocabulary for H3 Ref Chain shot plans.
|
| 2 |
+
|
| 3 |
+
WHY THIS MODULE EXISTS
|
| 4 |
+
----------------------
|
| 5 |
+
MiniMax H3 output is overwhelmingly carried by the prompt text, not by the
|
| 6 |
+
conditioning channels. Hand-writing continuity prose per shot does not scale and
|
| 7 |
+
drifts: every chain re-invents its own phrasing, so nothing learned on one chain
|
| 8 |
+
carries to the next. Structured directives compile to vetted prose instead, and
|
| 9 |
+
because every string lives in VOCAB below, improving the phrasing improves every
|
| 10 |
+
existing shot plan at once.
|
| 11 |
+
|
| 12 |
+
PHRASING RULES -- read before editing VOCAB
|
| 13 |
+
-------------------------------------------
|
| 14 |
+
1. AFFIRMATIVE ONLY. Sampling runs through BasicGuider at cfg 1.0 with no
|
| 15 |
+
negative branch, so every concept named is additive and cannot be subtracted.
|
| 16 |
+
"no cut" puts `cut` in front of the encoder. Say what the shot IS doing.
|
| 17 |
+
2. NO PRONOUNS. The beat text owns the subject. A pronoun here can disagree with
|
| 18 |
+
it -- plural "they" has been observed rendering two people from one reference.
|
| 19 |
+
3. NO ENUMERATED DETAIL. Naming a prop or garment to control it adds it. Keep
|
| 20 |
+
directives about camera, join, framing, and pace only.
|
| 21 |
+
4. ONE SENTENCE PER ENTRY. These get concatenated; long entries crowd the beat.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import re
|
| 25 |
+
|
| 26 |
+
# axis -> option -> prose. An option of "" (unset) emits nothing, deliberately:
|
| 27 |
+
# an unset axis should cost no tokens rather than assert a default.
|
| 28 |
+
VOCAB = {
|
| 29 |
+
"join": {
|
| 30 |
+
"continuous": "The camera and the action carry straight on from the pinned "
|
| 31 |
+
"frames in one unbroken take.",
|
| 32 |
+
"match_cut": "The shot changes on a matched movement, the new framing "
|
| 33 |
+
"picking up the same gesture already underway.",
|
| 34 |
+
"hard_cut": "A clean cut opens a new setup, the same place a moment later.",
|
| 35 |
+
},
|
| 36 |
+
"camera": {
|
| 37 |
+
"hold": "The camera holds its position throughout.",
|
| 38 |
+
"pan_follow": "The camera pans smoothly to follow the movement, holding it in frame.",
|
| 39 |
+
"push_in": "The camera pushes slowly in.",
|
| 40 |
+
"pull_back": "The camera draws slowly back, opening the frame.",
|
| 41 |
+
"orbit": "The camera arcs slowly around the action.",
|
| 42 |
+
"handheld": "The camera carries a light handheld float.",
|
| 43 |
+
},
|
| 44 |
+
"framing": {
|
| 45 |
+
"keep": "The framing stays as it is.",
|
| 46 |
+
"wide": "A wide shot with the full room in view.",
|
| 47 |
+
"medium": "A medium shot from the waist up.",
|
| 48 |
+
"close": "A close shot, head and shoulders filling the frame.",
|
| 49 |
+
},
|
| 50 |
+
"pace": {
|
| 51 |
+
"slow": "Everything moves at an unhurried pace.",
|
| 52 |
+
"steady": "The action moves at a steady, even pace.",
|
| 53 |
+
"brisk": "The action moves briskly.",
|
| 54 |
+
},
|
| 55 |
+
# How the clip ENDS. This axis exists because a beat whose action
|
| 56 |
+
# completes before the frames run out leaves the model with nothing to
|
| 57 |
+
# render, and it settles onto its strongest remaining conditioning --
|
| 58 |
+
# the identity photograph. Observed as the last ~3s of a chain cutting
|
| 59 |
+
# to the reference still. `ongoing` is the default for exactly that
|
| 60 |
+
# reason: it gives the tail somewhere to go that is not the ref.
|
| 61 |
+
"tail": {
|
| 62 |
+
"ongoing": "The action is still underway as the clip ends.",
|
| 63 |
+
"settle": "The movement eases to a rest and stays there.",
|
| 64 |
+
"hold": "The final position holds steady through the last moments.",
|
| 65 |
+
},
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
# B2 (2026-08-27). When `join=continuous` and the camera is moving, the framing
|
| 69 |
+
# sentence has to describe where the move ENDS, not what the shot opens on.
|
| 70 |
+
# Asserting the destination as the opening state while the pin still holds the
|
| 71 |
+
# previous hop's framing is what put the jump at the 2->3 seam of chain_00028:
|
| 72 |
+
# the compile read "...carry straight on from the pinned frames. The camera
|
| 73 |
+
# pushes slowly in. A close shot, head and shoulders filling the frame."
|
| 74 |
+
#
|
| 75 |
+
# Affirmative, one sentence, no pronouns -- same rules as the rest of VOCAB.
|
| 76 |
+
# Set FRAMING_AS_LANDING = False to restore the pre-2026-08-27 phrasing; that
|
| 77 |
+
# is the A/B against chain_00038.
|
| 78 |
+
FRAMING_AS_LANDING = True
|
| 79 |
+
_FRAMING_LANDING = {
|
| 80 |
+
"wide": "The move settles into a wide shot with the full room in view.",
|
| 81 |
+
"medium": "The move settles into a medium shot from the waist up.",
|
| 82 |
+
"close": "The move settles into a close shot, head and shoulders filling the frame.",
|
| 83 |
+
}
|
| 84 |
+
# "hold" is not a move, and "keep" needs no landing -- it already says the
|
| 85 |
+
# framing does not change.
|
| 86 |
+
_CAMERA_MOVES = ("pan_follow", "push_in", "pull_back", "orbit", "handheld")
|
| 87 |
+
|
| 88 |
+
# Order matters: this is the order the compiled sentences appear in the prompt.
|
| 89 |
+
# `join` leads because it describes how this hop meets the previous one.
|
| 90 |
+
AXES = ("join", "camera", "framing", "pace", "tail")
|
| 91 |
+
|
| 92 |
+
# Applied when a shot names no value for the axis. Only `tail` carries one:
|
| 93 |
+
# every other axis is legitimately "unspecified", but an unspecified tail is
|
| 94 |
+
# what lets the clip settle onto the reference image.
|
| 95 |
+
DEFAULTS = {"tail": "ongoing"}
|
| 96 |
+
|
| 97 |
+
# Opening line for hop 1, which has no previous hop to join to.
|
| 98 |
+
#
|
| 99 |
+
# 2026-08-29. This is the DEFAULT, not a constant. It used to be prepended to
|
| 100 |
+
# every hop-1 prompt unconditionally, which is correct for the live-action
|
| 101 |
+
# plans the pack shipped with and actively wrong for anything else: at cfg 1.0
|
| 102 |
+
# with no negative branch "Live-action" and "natural light" are ADDED, ahead of
|
| 103 |
+
# whatever style the beat declares. A stop-motion puppet plan compiled to
|
| 104 |
+
#
|
| 105 |
+
# Live-action, natural light, one continuous take.
|
| 106 |
+
# ...
|
| 107 |
+
# Hand-drawn stop-motion puppet animation in felt and painted wood ...
|
| 108 |
+
#
|
| 109 |
+
# and the two fought. It is also the reason hop 1 of the 8x15 s anime chain
|
| 110 |
+
# rendered as bright naturalistic daylight (mean luma 72) against a night plan
|
| 111 |
+
# and a night place plate, then dropped to 46 on hop 2 the moment this line
|
| 112 |
+
# stopped riding.
|
| 113 |
+
#
|
| 114 |
+
# Two fixes, because either alone leaves a hole. The node exposes an
|
| 115 |
+
# `establish` widget so a human can set or clear it; and `establish_for` below
|
| 116 |
+
# drops it automatically when the beat names a medium of its own, because a
|
| 117 |
+
# model-authored plan never touches a widget.
|
| 118 |
+
ESTABLISH = "Live-action, natural light, one continuous take."
|
| 119 |
+
|
| 120 |
+
# Media that contradict ESTABLISH's "Live-action". Matched against the opening
|
| 121 |
+
# of shot 1 only -- a beat that says "a photograph of a painting" later on is
|
| 122 |
+
# describing a prop, not declaring its own medium.
|
| 123 |
+
_STYLE_WORDS = (
|
| 124 |
+
"anime", "animation", "animated", "cartoon", "cel ", "cel-",
|
| 125 |
+
"stop-motion", "stop motion", "claymation", "clay-mation", "puppet",
|
| 126 |
+
"watercolour", "watercolor", "gouache", "oil-painted", "oil painted",
|
| 127 |
+
"hand-drawn", "hand drawn", "illustrated", "illustration", "storyboard",
|
| 128 |
+
"pixel art", "cgi", "3d render", "3d-render", "rendered in", "rotoscope",
|
| 129 |
+
"comic", "manga", "woodcut", "linocut", "papercraft", "paper-craft",
|
| 130 |
+
)
|
| 131 |
+
# How far into the beat to look. Rule 12 asks for the style in the first
|
| 132 |
+
# sentence; 220 characters covers a generous one without reaching the action.
|
| 133 |
+
_STYLE_WINDOW = 220
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def declares_own_medium(beat):
|
| 137 |
+
"""True when shot 1 opens by naming a medium that is not live action.
|
| 138 |
+
|
| 139 |
+
Deliberately narrow. It reads only the opening of the beat, and it defers
|
| 140 |
+
to an explicit "live-action" there, so a plan that means live action keeps
|
| 141 |
+
the establishing line even when it also mentions, say, an animated sign in
|
| 142 |
+
the background.
|
| 143 |
+
"""
|
| 144 |
+
head = (beat or "")[:_STYLE_WINDOW].lower()
|
| 145 |
+
if "live-action" in head or "live action" in head:
|
| 146 |
+
return False
|
| 147 |
+
return any(w in head for w in _STYLE_WORDS)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def establish_for(beat, establish=None):
|
| 151 |
+
"""The establishing line to prepend to hop 1, or "" for none.
|
| 152 |
+
|
| 153 |
+
`establish=None` means "the node did not say", which is every caller that
|
| 154 |
+
predates the widget -- those keep the historical default.
|
| 155 |
+
"""
|
| 156 |
+
line = ESTABLISH if establish is None else str(establish or "").strip()
|
| 157 |
+
if line and declares_own_medium(beat):
|
| 158 |
+
return ""
|
| 159 |
+
return line
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def validate(axis, value, where=""):
|
| 163 |
+
"""Raise ValueError naming the offending shot and the valid options."""
|
| 164 |
+
v = str(value or "").strip()
|
| 165 |
+
if not v:
|
| 166 |
+
return ""
|
| 167 |
+
if v not in VOCAB[axis]:
|
| 168 |
+
valid = ", ".join(sorted(VOCAB[axis]))
|
| 169 |
+
raise ValueError(
|
| 170 |
+
f"HandTieClips: {where}directive {axis}='{v}' is not valid. Use one of: {valid}"
|
| 171 |
+
)
|
| 172 |
+
return v
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def directive_prose(directives, hop_index, axes=None):
|
| 176 |
+
"""Compile a shot's directives into prose. Hop 1 has nothing to join to."""
|
| 177 |
+
d = directives or {}
|
| 178 |
+
out = []
|
| 179 |
+
for axis in (axes or AXES):
|
| 180 |
+
if axis == "join" and hop_index == 0:
|
| 181 |
+
continue
|
| 182 |
+
value = str(d.get(axis) or "").strip() or DEFAULTS.get(axis, "")
|
| 183 |
+
line = VOCAB.get(axis, {}).get(value)
|
| 184 |
+
if (axis == "framing" and FRAMING_AS_LANDING and hop_index > 0
|
| 185 |
+
and str(d.get("join") or "").strip() == "continuous"
|
| 186 |
+
and str(d.get("camera") or "").strip() in _CAMERA_MOVES
|
| 187 |
+
and value in _FRAMING_LANDING):
|
| 188 |
+
line = _FRAMING_LANDING[value]
|
| 189 |
+
if line:
|
| 190 |
+
out.append(line)
|
| 191 |
+
return " ".join(out)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
_OFFICIAL_FIELDS = (
|
| 195 |
+
"subject_definitions:",
|
| 196 |
+
"summary:",
|
| 197 |
+
"retention_analysis:",
|
| 198 |
+
"detailed_description:",
|
| 199 |
+
"integrated_multimodal_description:",
|
| 200 |
+
"overall_soundscape:",
|
| 201 |
+
"non_diegetic_music:",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
def append_section_line(text, name, line):
|
| 205 |
+
"""Append a line to the end of an official field. No-op when absent.
|
| 206 |
+
|
| 207 |
+
Carries a hop-1 `tail` directive into a complete six-field block: `tail`
|
| 208 |
+
describes how the clip *ends*, so it belongs at the end of
|
| 209 |
+
detailed_description rather than wrapped around the outside, where it
|
| 210 |
+
would fight the official fields.
|
| 211 |
+
"""
|
| 212 |
+
t = text or ""
|
| 213 |
+
line = str(line or "").strip()
|
| 214 |
+
if not line or line in t:
|
| 215 |
+
return t
|
| 216 |
+
marker = name if name.endswith(":") else name + ":"
|
| 217 |
+
start = t.find(marker)
|
| 218 |
+
if start < 0:
|
| 219 |
+
marker = "integrated_multimodal_description:"
|
| 220 |
+
start = t.find(marker)
|
| 221 |
+
if start < 0:
|
| 222 |
+
return t
|
| 223 |
+
end = len(t)
|
| 224 |
+
for other in _OFFICIAL_FIELDS:
|
| 225 |
+
if other == marker:
|
| 226 |
+
continue
|
| 227 |
+
i = t.find(other, start + len(marker))
|
| 228 |
+
if i != -1:
|
| 229 |
+
end = min(end, i)
|
| 230 |
+
return t[:end].rstrip() + "\n" + line + "\n\n" + t[end:].lstrip()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def is_full_h3_prompt(text):
|
| 234 |
+
t = text or ""
|
| 235 |
+
return "subject_definitions:" in t or "integrated_multimodal_description:" in t
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def field_body(text, name):
|
| 239 |
+
"""Inner text of one official field, or ''."""
|
| 240 |
+
t = text or ""
|
| 241 |
+
marker = name if str(name).endswith(":") else str(name) + ":"
|
| 242 |
+
start = t.find(marker)
|
| 243 |
+
if start < 0:
|
| 244 |
+
return ""
|
| 245 |
+
end = len(t)
|
| 246 |
+
for other in _OFFICIAL_FIELDS:
|
| 247 |
+
if other == marker:
|
| 248 |
+
continue
|
| 249 |
+
i = t.find(other, start + len(marker))
|
| 250 |
+
if i != -1:
|
| 251 |
+
end = min(end, i)
|
| 252 |
+
return t[start + len(marker):end].strip()
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def flatten_official_continue(body):
|
| 256 |
+
"""Hop 2+ cannot be a complete Ref2VA generate (chain_00030..00032).
|
| 257 |
+
|
| 258 |
+
Keep the action + soundscape. Drop subject_definitions / summary /
|
| 259 |
+
retention_analysis and a leading [Shot 1] — those start a new scene.
|
| 260 |
+
"""
|
| 261 |
+
if not is_full_h3_prompt(body):
|
| 262 |
+
return body
|
| 263 |
+
dd = (field_body(body, "detailed_description")
|
| 264 |
+
or field_body(body, "integrated_multimodal_description")
|
| 265 |
+
or "")
|
| 266 |
+
dd = re.sub(r"^\[Shot 1\]\s*", "", dd.strip())
|
| 267 |
+
for prefix in (
|
| 268 |
+
"The clip opens already in progress from the pinned frames, "
|
| 269 |
+
"continuing the scene they leave off. ",
|
| 270 |
+
"The clip opens already in progress from the pinned frames. ",
|
| 271 |
+
"The clip opens on the action already in progress from the pinned frames. ",
|
| 272 |
+
):
|
| 273 |
+
if dd.startswith(prefix):
|
| 274 |
+
dd = dd[len(prefix):].lstrip()
|
| 275 |
+
break
|
| 276 |
+
parts = []
|
| 277 |
+
if dd:
|
| 278 |
+
parts.append(dd)
|
| 279 |
+
sound = field_body(body, "overall_soundscape")
|
| 280 |
+
if sound:
|
| 281 |
+
parts.append("overall_soundscape:\n" + sound)
|
| 282 |
+
music = field_body(body, "non_diegetic_music")
|
| 283 |
+
if music:
|
| 284 |
+
parts.append("non_diegetic_music:\n" + music)
|
| 285 |
+
return "\n\n".join(parts)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def compile_shot(shot, hop_index, establish=None):
|
| 289 |
+
"""Build the body text for one hop.
|
| 290 |
+
|
| 291 |
+
Returns the *beat* portion only. For hop 2+ the caller wraps this with the
|
| 292 |
+
identity lock and live-frame citation via _assemble_next, so this must not
|
| 293 |
+
duplicate either of those.
|
| 294 |
+
|
| 295 |
+
Hop 1 may be a complete official H3 block and is returned intact. Hop 2+
|
| 296 |
+
of a complete block is flattened to a continuation beat — a second full
|
| 297 |
+
Ref2VA generate is a new scene (chain_00030..00032).
|
| 298 |
+
"""
|
| 299 |
+
beat = str((shot or {}).get("beat") or "").strip()
|
| 300 |
+
prose = str((shot or {}).get("prose") or "").strip()
|
| 301 |
+
body = "\n\n".join(p for p in (beat, prose) if p)
|
| 302 |
+
dirs = (shot or {}).get("directives")
|
| 303 |
+
if is_full_h3_prompt(body):
|
| 304 |
+
if hop_index == 0:
|
| 305 |
+
# A complete six-field block is one generate and is returned
|
| 306 |
+
# intact -- lead directives would fight its own field text. `tail`
|
| 307 |
+
# is the exception: one affirmative sentence about how the clip
|
| 308 |
+
# ends, which is what stops the last seconds settling onto a
|
| 309 |
+
# reference still, so it is appended inside detailed_description
|
| 310 |
+
# rather than dropped. The rest are reported, because silently
|
| 311 |
+
# ignoring dropdowns the editor still renders is how this went
|
| 312 |
+
# unnoticed.
|
| 313 |
+
body = append_section_line(
|
| 314 |
+
body, "detailed_description",
|
| 315 |
+
directive_prose(dirs, hop_index, axes=("tail",)))
|
| 316 |
+
ignored = sorted(
|
| 317 |
+
a for a in ("join", "camera", "framing", "pace")
|
| 318 |
+
if str((dirs or {}).get(a) or "").strip())
|
| 319 |
+
if ignored:
|
| 320 |
+
print("[HandTieClips] shot 1 is a complete H3 block; its own fields "
|
| 321 |
+
"win, so these directives are not applied: " + ", ".join(ignored),
|
| 322 |
+
flush=True)
|
| 323 |
+
return body
|
| 324 |
+
beat = flatten_official_continue(body)
|
| 325 |
+
prose = ""
|
| 326 |
+
lead = directive_prose(dirs, hop_index, axes=("join", "camera", "framing", "pace"))
|
| 327 |
+
tail = directive_prose(dirs, hop_index, axes=("tail",))
|
| 328 |
+
|
| 329 |
+
parts = []
|
| 330 |
+
if hop_index == 0:
|
| 331 |
+
# `beat` is shot 1's own text, so a plan that declares its own medium
|
| 332 |
+
# suppresses the live-action default rather than fighting it.
|
| 333 |
+
opening = establish_for(beat, establish)
|
| 334 |
+
if opening:
|
| 335 |
+
parts.append(opening)
|
| 336 |
+
if lead:
|
| 337 |
+
parts.append(lead)
|
| 338 |
+
if beat:
|
| 339 |
+
parts.append(beat)
|
| 340 |
+
if prose:
|
| 341 |
+
parts.append(prose)
|
| 342 |
+
if tail:
|
| 343 |
+
parts.append(tail)
|
| 344 |
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,1943 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Native MiniMax H3 Ref2VA chain: several generates joined into one clip.
|
| 2 |
+
|
| 3 |
+
Hop 1 is a full Ref2VA generate and may carry an official six-field H3 prompt.
|
| 4 |
+
Hop 2+ is a *continuation*, not a second generate: a continuation beat with the
|
| 5 |
+
previous hop pinned in front of it.
|
| 6 |
+
|
| 7 |
+
The pin is the whole point. Hop 2+ slices the previous hop's sampler AV latent
|
| 8 |
+
through MiniMaxH3MotionContext, which keeps the join in the latent domain and
|
| 9 |
+
end-aligns the audio window. MiniMaxH3AddGuide on decoded pixels is the
|
| 10 |
+
fallback -- taken when Motion-Context is missing, or when the previous hop came
|
| 11 |
+
from the cache and so has no sampler latent to slice.
|
| 12 |
+
|
| 13 |
+
Authoring is `shot_plan` + `ref_plan`, both JSON strings, both edited by the DOM
|
| 14 |
+
panel in js/ and both the single source of truth. The legacy `prompt` widget
|
| 15 |
+
(optional --- / JSON blocks) still runs for `hop_script=verbatim`.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import base64
|
| 20 |
+
import hashlib
|
| 21 |
+
import queue
|
| 22 |
+
import threading
|
| 23 |
+
import io as pyio
|
| 24 |
+
import json
|
| 25 |
+
import math
|
| 26 |
+
import os
|
| 27 |
+
import re
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from server import PromptServer
|
| 33 |
+
except Exception:
|
| 34 |
+
PromptServer = None
|
| 35 |
+
|
| 36 |
+
import comfy.model_management as mm
|
| 37 |
+
import comfy.samplers
|
| 38 |
+
import comfy.utils
|
| 39 |
+
from comfy_extras.nodes_audio import vae_decode_audio
|
| 40 |
+
from comfy_extras.nodes_custom_sampler import (
|
| 41 |
+
BasicGuider,
|
| 42 |
+
BasicScheduler,
|
| 43 |
+
KSamplerSelect,
|
| 44 |
+
RandomNoise,
|
| 45 |
+
SamplerCustomAdvanced,
|
| 46 |
+
)
|
| 47 |
+
try:
|
| 48 |
+
from comfy_extras.nodes_minimax_h3 import (
|
| 49 |
+
MiniMaxH3AddGuide,
|
| 50 |
+
MiniMaxH3ReferenceToVideo,
|
| 51 |
+
MiniMaxH3SigmaShift,
|
| 52 |
+
align_frame_count,
|
| 53 |
+
)
|
| 54 |
+
except ImportError as _exc: # pragma: no cover - depends on the host build
|
| 55 |
+
# The one failure a first-time installer is actually likely to hit. Left
|
| 56 |
+
# bare it surfaces as "cannot import name 'MiniMaxH3AddGuide'", which says
|
| 57 |
+
# nothing about what to do. The pack cannot work without these, so it still
|
| 58 |
+
# refuses to load -- it just says why.
|
| 59 |
+
raise ImportError(
|
| 60 |
+
"Hand Tie Clips needs MiniMax H3 support in ComfyUI itself "
|
| 61 |
+
"(comfy_extras/nodes_minimax_h3.py, ComfyUI PR #15439). Update ComfyUI "
|
| 62 |
+
"to a build that ships it, then restart. Original error: %s" % _exc
|
| 63 |
+
) from _exc
|
| 64 |
+
from nodes import VAEDecode
|
| 65 |
+
|
| 66 |
+
from . import directives as _d
|
| 67 |
+
from . import plan as _plan
|
| 68 |
+
from . import refs as _refs
|
| 69 |
+
from . import store as _store
|
| 70 |
+
from . import media as _media
|
| 71 |
+
from . import tone as _tone
|
| 72 |
+
# One definition, in refs.py -- routes.py publishes that copy to the editor, so
|
| 73 |
+
# a second constant here meant the node's slot count and the number the UI was
|
| 74 |
+
# told could drift apart.
|
| 75 |
+
from .refs import MAX_REF_IMAGES
|
| 76 |
+
|
| 77 |
+
FPS = 24
|
| 78 |
+
TAG = "HandTieClips"
|
| 79 |
+
|
| 80 |
+
# H3 canvas: multiples of 32, short-edge ~768, area cap 768*1344.
|
| 81 |
+
CANVAS = {
|
| 82 |
+
"0.2 MP": {
|
| 83 |
+
"16:9 landscape": (608, 352),
|
| 84 |
+
"9:16 portrait": (352, 608),
|
| 85 |
+
"1:1 square": (448, 448),
|
| 86 |
+
},
|
| 87 |
+
"0.3 MP": {
|
| 88 |
+
"16:9 landscape": (736, 416),
|
| 89 |
+
"9:16 portrait": (416, 736),
|
| 90 |
+
"1:1 square": (544, 544),
|
| 91 |
+
},
|
| 92 |
+
"0.5 MP": {
|
| 93 |
+
"16:9 landscape": (960, 544),
|
| 94 |
+
"9:16 portrait": (544, 960),
|
| 95 |
+
"1:1 square": (704, 704),
|
| 96 |
+
},
|
| 97 |
+
"0.7 MP": {
|
| 98 |
+
"16:9 landscape": (1120, 640),
|
| 99 |
+
"9:16 portrait": (640, 1120),
|
| 100 |
+
"1:1 square": (832, 832),
|
| 101 |
+
},
|
| 102 |
+
"1.0 MP": {
|
| 103 |
+
"16:9 landscape": (1280, 736),
|
| 104 |
+
"9:16 portrait": (736, 1280),
|
| 105 |
+
"1:1 square": (992, 992),
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
DURATION_FRAMES = {
|
| 109 |
+
# Every value satisfies align_frame_count (n % 17 == 5) at FPS 24, so the
|
| 110 |
+
# label and the frames the model actually renders agree to a tenth.
|
| 111 |
+
"5 s": 124,
|
| 112 |
+
"7 s": 175,
|
| 113 |
+
"8 s": 192,
|
| 114 |
+
"10 s": 243,
|
| 115 |
+
"15 s": 362,
|
| 116 |
+
}
|
| 117 |
+
OVERLAP_FRAMES = {
|
| 118 |
+
"0.9 s": 22,
|
| 119 |
+
"0.2 s": 5,
|
| 120 |
+
"1.6 s": 39,
|
| 121 |
+
}
|
| 122 |
+
# MiniMaxH3MotionContext.apply takes `context_length` as a *string* combo and
|
| 123 |
+
# accepts only these values. Derived from OVERLAP_FRAMES so the two cannot
|
| 124 |
+
# drift: add an overlap without a matching context_length and the pin would
|
| 125 |
+
# silently clamp to 22 while the master trims the real value -- a misaligned
|
| 126 |
+
# seam with no error. _pin_continue logs and falls back instead.
|
| 127 |
+
MC_CONTEXT_LENGTHS = frozenset(str(v) for v in OVERLAP_FRAMES.values())
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _canvas(resolution, aspect):
|
| 131 |
+
try:
|
| 132 |
+
return CANVAS[str(resolution)][str(aspect)]
|
| 133 |
+
except KeyError:
|
| 134 |
+
return (1280, 736)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _duration_frames(duration):
|
| 138 |
+
return int(DURATION_FRAMES.get(str(duration), 243))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _overlap_frames(overlap):
|
| 142 |
+
key = str(overlap)
|
| 143 |
+
if key in OVERLAP_FRAMES:
|
| 144 |
+
return OVERLAP_FRAMES[key]
|
| 145 |
+
return int(overlap)
|
| 146 |
+
|
| 147 |
+
# Prompt phrasing rule: AFFIRMATIVE ONLY.
|
| 148 |
+
# Sampling runs through BasicGuider at cfg 1.0 with no negative branch, so every
|
| 149 |
+
# concept named in the prompt is additive and cannot be subtracted -- "Do not
|
| 150 |
+
# restart the scene" puts `restart` in front of the encoder. State what the shot
|
| 151 |
+
# IS doing, never what it must not do. Keep this rule when editing below.
|
| 152 |
+
CONTINUE_PREFIX = (
|
| 153 |
+
"The clip opens on the action already in progress from the pinned frames. "
|
| 154 |
+
"The same people continue from where the pinned frames leave off, in the same "
|
| 155 |
+
"wardrobe, the same room, and the same lighting. "
|
| 156 |
+
"After a brief hold, the action carries forward from its current point.\n\n"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
ADVANCE_BEAT = (
|
| 160 |
+
"The action already in progress carries forward from its current point."
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
MAX_REF_VIDEOS = 3
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _result(out):
|
| 167 |
+
if hasattr(out, "args"):
|
| 168 |
+
return out.args
|
| 169 |
+
if isinstance(out, (tuple, list)):
|
| 170 |
+
return tuple(out)
|
| 171 |
+
return (out,)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _model_fingerprint(model):
|
| 175 |
+
"""Identify the incoming MODEL by what has been patched onto it.
|
| 176 |
+
|
| 177 |
+
The hop cache has to notice when a hop was rendered under a different LoRA
|
| 178 |
+
stack or a different attention path, or it will happily serve frames that
|
| 179 |
+
do not belong to the current graph -- silently wrong output, which is worse
|
| 180 |
+
than no cache at all. When the patch nodes were a widget on this node the
|
| 181 |
+
parsed plan went into the key directly; with them drawn upstream the only
|
| 182 |
+
thing available is the ModelPatcher itself.
|
| 183 |
+
|
| 184 |
+
Cheap and content-derived: the set of weight keys any LoRA touched plus the
|
| 185 |
+
per-key strength scalars, and the scalar half of `transformer_options`,
|
| 186 |
+
which is where the SLA and low-VRAM attention overrides land. Patch *values*
|
| 187 |
+
are tensors and are deliberately not hashed.
|
| 188 |
+
|
| 189 |
+
`patches_uuid` is not usable here: `ModelPatcher.add_patches` assigns a
|
| 190 |
+
fresh `uuid4()` on every call, so it would change every run and bust the
|
| 191 |
+
cache even when nothing about the graph moved.
|
| 192 |
+
|
| 193 |
+
Known collision: two different LoRAs touching an identical key set at
|
| 194 |
+
identical strengths fingerprint the same. Rare, and the alternative costs a
|
| 195 |
+
full state-dict walk per run.
|
| 196 |
+
"""
|
| 197 |
+
h = hashlib.sha256()
|
| 198 |
+
patches = getattr(model, "patches", None) or {}
|
| 199 |
+
for key in sorted(patches):
|
| 200 |
+
h.update(str(key).encode())
|
| 201 |
+
for entry in patches[key]:
|
| 202 |
+
# (strength_patch, weights, strength_model, offset, function)
|
| 203 |
+
try:
|
| 204 |
+
h.update(f"{float(entry[0]):.6g}".encode())
|
| 205 |
+
if len(entry) > 2 and isinstance(entry[2], (int, float)):
|
| 206 |
+
h.update(f"{float(entry[2]):.6g}".encode())
|
| 207 |
+
except (TypeError, ValueError, IndexError):
|
| 208 |
+
h.update(b"?")
|
| 209 |
+
|
| 210 |
+
opts = getattr(model, "model_options", None) or {}
|
| 211 |
+
transformer = opts.get("transformer_options") or {}
|
| 212 |
+
|
| 213 |
+
def _closure_scalars(fn):
|
| 214 |
+
"""The scalar settings a callable closed over.
|
| 215 |
+
|
| 216 |
+
H3-SLA-Attention installs its config by closure --
|
| 217 |
+
`_make_override(state, float(sparsity_ratio), blkq, blkk,
|
| 218 |
+
int(min_seq_len), bool(protect_audio))` -- so a callable rendered as
|
| 219 |
+
`type(fn).__name__` hashes to the bare string "function" and SLA's
|
| 220 |
+
settings vanish from the key. Changing sparsity 0.90 -> 0.50 then left
|
| 221 |
+
the fingerprint unmoved and the cache served hops rendered under a
|
| 222 |
+
different attention path.
|
| 223 |
+
|
| 224 |
+
Scalars only, deliberately: the first cell is a mutable `state` dict the
|
| 225 |
+
sampler counts into during the run, and hashing that would change the
|
| 226 |
+
fingerprint on every queue and never hit the cache at all.
|
| 227 |
+
"""
|
| 228 |
+
parts = [getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "")]
|
| 229 |
+
for cell in (getattr(fn, "__closure__", None) or ()):
|
| 230 |
+
try:
|
| 231 |
+
v = cell.cell_contents
|
| 232 |
+
except ValueError: # empty cell, e.g. a recursive closure
|
| 233 |
+
parts.append("?")
|
| 234 |
+
continue
|
| 235 |
+
parts.append(repr(v)
|
| 236 |
+
if isinstance(v, (str, int, float, bool)) or v is None
|
| 237 |
+
else type(v).__name__)
|
| 238 |
+
return "fn(" + ",".join(parts) + ")"
|
| 239 |
+
|
| 240 |
+
def _scalars(obj, depth=0):
|
| 241 |
+
"""Only names and scalars -- tensors and mutable state are not stable."""
|
| 242 |
+
if depth > 3:
|
| 243 |
+
return "..."
|
| 244 |
+
if isinstance(obj, dict):
|
| 245 |
+
# sorted(obj, key=str), not sorted(map(str, obj)): stringifying the
|
| 246 |
+
# keys first drops every non-str key from the hash, because the
|
| 247 |
+
# `k in obj` guard then fails against the real key.
|
| 248 |
+
return "{" + ",".join(
|
| 249 |
+
f"{k}:{_scalars(obj[k], depth + 1)}" for k in sorted(obj, key=str)
|
| 250 |
+
) + "}"
|
| 251 |
+
if isinstance(obj, (list, tuple)):
|
| 252 |
+
return "[" + ",".join(_scalars(v, depth + 1) for v in obj) + "]"
|
| 253 |
+
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
| 254 |
+
return repr(obj)
|
| 255 |
+
if callable(obj):
|
| 256 |
+
return _closure_scalars(obj)
|
| 257 |
+
return type(obj).__name__
|
| 258 |
+
|
| 259 |
+
h.update(_scalars(transformer).encode())
|
| 260 |
+
return h.hexdigest()[:16]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _parse_shots(text):
|
| 264 |
+
text = (text or "").strip()
|
| 265 |
+
if not text:
|
| 266 |
+
raise ValueError(f"{TAG}: prompt is empty")
|
| 267 |
+
if text.startswith("{") or text.startswith("["):
|
| 268 |
+
try:
|
| 269 |
+
data = json.loads(text)
|
| 270 |
+
except json.JSONDecodeError as e:
|
| 271 |
+
raise ValueError(f"{TAG}: prompt looks like JSON but does not parse ({e})") from e
|
| 272 |
+
if isinstance(data, dict):
|
| 273 |
+
shots = [str(p).strip() for p in data.get("prompts", []) if str(p).strip()]
|
| 274 |
+
elif isinstance(data, list):
|
| 275 |
+
shots = [str(p).strip() for p in data if str(p).strip()]
|
| 276 |
+
else:
|
| 277 |
+
shots = []
|
| 278 |
+
if shots:
|
| 279 |
+
return shots
|
| 280 |
+
parts = [b.strip() for b in re.split(r"(?m)^---\s*$", text) if b.strip()]
|
| 281 |
+
return parts or [text]
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _parse_state(text):
|
| 285 |
+
"""continuity_state input: blank -> no-op, else a JSON object (from HTCContinuityState)."""
|
| 286 |
+
text = (text or "").strip()
|
| 287 |
+
if not text:
|
| 288 |
+
return {}
|
| 289 |
+
try:
|
| 290 |
+
data = json.loads(text)
|
| 291 |
+
except json.JSONDecodeError as e:
|
| 292 |
+
raise ValueError(f"{TAG}: continuity_state looks like JSON but does not parse ({e})") from e
|
| 293 |
+
if not isinstance(data, dict):
|
| 294 |
+
raise ValueError(f"{TAG}: continuity_state must be a JSON object")
|
| 295 |
+
return data
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _continue_prompt(block):
|
| 299 |
+
"""Wrap a verbatim-mode block as a continuation.
|
| 300 |
+
|
| 301 |
+
Never rewrite the official summary task types here. This used to replace
|
| 302 |
+
`[keyframe completion]` with `[video continuation + reference generation]`,
|
| 303 |
+
which is what made hop 2 of chain_00030/00031 a new stills generate instead
|
| 304 |
+
of a first-frame continue. Combine types with ` + `; never drop one that is
|
| 305 |
+
already present.
|
| 306 |
+
"""
|
| 307 |
+
text = (block or "").strip()
|
| 308 |
+
if _d.is_full_h3_prompt(text):
|
| 309 |
+
text = _d.flatten_official_continue(text)
|
| 310 |
+
return CONTINUE_PREFIX + (text or ADVANCE_BEAT)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def _expand_shots(blocks, chains, hop_script="verbatim"):
|
| 314 |
+
original = list(blocks)
|
| 315 |
+
if len(blocks) > chains:
|
| 316 |
+
print(f"[{TAG}] dropping {len(blocks) - chains} extra --- block(s)", flush=True)
|
| 317 |
+
blocks = blocks[:chains]
|
| 318 |
+
unique = len(original) if len(original) <= chains else chains
|
| 319 |
+
while len(blocks) < chains:
|
| 320 |
+
if hop_script == "next" and unique == 1:
|
| 321 |
+
blocks.append("")
|
| 322 |
+
else:
|
| 323 |
+
blocks.append(blocks[-1])
|
| 324 |
+
if hop_script == "next":
|
| 325 |
+
return blocks, unique
|
| 326 |
+
out = []
|
| 327 |
+
for i, block in enumerate(blocks):
|
| 328 |
+
wrap = i > 0 and (unique == 1 or i >= unique)
|
| 329 |
+
out.append(_continue_prompt(block) if wrap else block)
|
| 330 |
+
return out, unique
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _state_entry_text(entry, hop_index):
|
| 334 |
+
"""locked (verbatim) + context (current-state) + this hop's mutable beat."""
|
| 335 |
+
if not isinstance(entry, dict):
|
| 336 |
+
return ""
|
| 337 |
+
locked = str(entry.get("locked") or "").strip()
|
| 338 |
+
context = str(entry.get("context") or "").strip()
|
| 339 |
+
mutable = entry.get("mutable") or []
|
| 340 |
+
if isinstance(mutable, str):
|
| 341 |
+
mutable = _parse_shots(mutable) if mutable.strip() else []
|
| 342 |
+
mutable = [str(b).strip() for b in mutable if str(b).strip()]
|
| 343 |
+
beat = ""
|
| 344 |
+
if mutable:
|
| 345 |
+
idx = hop_index if hop_index < len(mutable) else len(mutable) - 1
|
| 346 |
+
beat = mutable[idx]
|
| 347 |
+
return "\n".join(p for p in (locked, context, beat) if p)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _state_header(state, hop_index):
|
| 351 |
+
"""Compose the continuity_state block for this hop (locked/context every hop, mutable indexed)."""
|
| 352 |
+
if not state:
|
| 353 |
+
return ""
|
| 354 |
+
sections = []
|
| 355 |
+
setting_text = _state_entry_text(state.get("setting") or {}, hop_index)
|
| 356 |
+
if setting_text:
|
| 357 |
+
sections.append("setting:\n" + setting_text)
|
| 358 |
+
for char_id, entry in (state.get("characters") or {}).items():
|
| 359 |
+
char_text = _state_entry_text(entry, hop_index)
|
| 360 |
+
if char_text:
|
| 361 |
+
sections.append(f"character {char_id}:\n" + char_text)
|
| 362 |
+
return "\n\n".join(sections)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _identity_lock(n_stills, live_picture, identity_ordinals=None, n_subjects=None):
|
| 366 |
+
"""Name the pictures that are people, and only those.
|
| 367 |
+
|
| 368 |
+
`identity_ordinals` comes from the reference register, which is the only
|
| 369 |
+
thing that knows a picture is a face rather than a room. Without it this
|
| 370 |
+
falls back to "every wired still is an identity", which is what it always
|
| 371 |
+
did and is right when nothing better is known -- but with a register wired
|
| 372 |
+
that fallback tells the encoder a photograph of a kitchen has a face and a
|
| 373 |
+
hairstyle to match exactly. At cfg 1.0 there is no negative branch, so that
|
| 374 |
+
is additive noise on every hop.
|
| 375 |
+
|
| 376 |
+
`n_subjects` drives number agreement, not the picture count: two
|
| 377 |
+
photographs of one person is still one identity.
|
| 378 |
+
"""
|
| 379 |
+
ords = (list(identity_ordinals) if identity_ordinals is not None
|
| 380 |
+
else list(range(1, int(n_stills) + 1)))
|
| 381 |
+
if not ords:
|
| 382 |
+
# A register with no subject-bearing refs -- setting plates only. There
|
| 383 |
+
# is no identity to lock, and _live_cite still cites the live frame.
|
| 384 |
+
return ""
|
| 385 |
+
pics = ", ".join(f"<Picture {i}>" for i in ords)
|
| 386 |
+
count = int(n_subjects) if n_subjects is not None else len(ords)
|
| 387 |
+
# Qwen3-VL is a language encoder, so number agreement is not cosmetic:
|
| 388 |
+
# "<Picture 1> are the only identities" is what a single wired ref produced.
|
| 389 |
+
if count == 1:
|
| 390 |
+
line = (
|
| 391 |
+
f"{pics} is the only identity. That face, bone structure, and hairstyle "
|
| 392 |
+
"match the photograph exactly."
|
| 393 |
+
) if len(ords) == 1 else (
|
| 394 |
+
f"{pics} are the same one person, and the only identity. That face, bone "
|
| 395 |
+
"structure, and hairstyle match those photographs exactly."
|
| 396 |
+
)
|
| 397 |
+
else:
|
| 398 |
+
line = (
|
| 399 |
+
f"{pics} are the only identities. Each face, bone structure, and hairstyle "
|
| 400 |
+
"matches its photograph exactly."
|
| 401 |
+
)
|
| 402 |
+
if live_picture and live_picture not in ords:
|
| 403 |
+
who = ("that same person as they stand" if count == 1
|
| 404 |
+
else "those same people as they stand")
|
| 405 |
+
line += f" <Picture {live_picture}> shows {who} right now, mid-action."
|
| 406 |
+
return line
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _live_cite(live_picture, live_video):
|
| 410 |
+
bits = []
|
| 411 |
+
if live_picture:
|
| 412 |
+
bits.append(
|
| 413 |
+
f"<Picture {live_picture}> is the live frame at the start of this clip, "
|
| 414 |
+
"already in progress from the pinned tail."
|
| 415 |
+
)
|
| 416 |
+
if live_video:
|
| 417 |
+
bits.append(
|
| 418 |
+
f"<Video {live_video}> is the pinned tail of the previous clip and the first "
|
| 419 |
+
"moments of this generate. The motion continues at the speed it already has."
|
| 420 |
+
)
|
| 421 |
+
return " ".join(bits)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def _assemble_next(beat, live_picture=None, live_video=None,
|
| 425 |
+
n_stills=0, state_header="",
|
| 426 |
+
identity_ordinals=None, n_subjects=None, tail=None,
|
| 427 |
+
continuity=""):
|
| 428 |
+
"""Hop 2+ in `next` mode: user text is only the new beat."""
|
| 429 |
+
text = (beat or "").strip() or ADVANCE_BEAT
|
| 430 |
+
cite = _live_cite(live_picture, live_video)
|
| 431 |
+
lock = _identity_lock(n_stills, live_picture,
|
| 432 |
+
identity_ordinals=identity_ordinals,
|
| 433 |
+
n_subjects=n_subjects)
|
| 434 |
+
solo = (n_subjects == 1) or (n_subjects is None and identity_ordinals is not None
|
| 435 |
+
and len(identity_ordinals) == 1)
|
| 436 |
+
whoever = "The same person holds" if solo else "The same people hold"
|
| 437 |
+
if _d.is_full_h3_prompt(text):
|
| 438 |
+
print(
|
| 439 |
+
f"[{TAG}] hop 2+ full H3 block flattened to a continuation beat "
|
| 440 |
+
"(a complete Ref2VA prompt on hop 2+ starts a new scene)",
|
| 441 |
+
flush=True,
|
| 442 |
+
)
|
| 443 |
+
text = _d.flatten_official_continue(text) or ADVANCE_BEAT
|
| 444 |
+
# No identity header on hop 2+ at all: register subject_prose is a
|
| 445 |
+
# subject_definitions block, and putting one here made hop 2 a second
|
| 446 |
+
# Ref2VA generate (chain_00033, the short drink beat). The call site passes
|
| 447 |
+
# nothing, and there is no parameter to pass.
|
| 448 |
+
header = str(state_header or "").strip()
|
| 449 |
+
top = header + "\n\n" if header else ""
|
| 450 |
+
# Order: who the pictures are, then what stays the same, then which
|
| 451 |
+
# frame is live. `continuity` carries no ordinals, so it is safe on a
|
| 452 |
+
# pin-only hop where `lock` is deliberately empty.
|
| 453 |
+
inject = " ".join(p for p in (lock, str(continuity or "").strip(), cite) if p)
|
| 454 |
+
inject = (inject + "\n\n") if inject else "\n"
|
| 455 |
+
# Pin-only hops have no identity photographs. Naming them (chain_00034)
|
| 456 |
+
# sent the encoder back to the face/outfit stills — commercial kitchen,
|
| 457 |
+
# grey shirt, no apron.
|
| 458 |
+
# The closer used to end on "still underway" unconditionally, which made it
|
| 459 |
+
# the last sentence of the prompt and so overrode the shot's own `tail`
|
| 460 |
+
# directive -- `settle` and `hold` were unreachable on hop 2+. On the final
|
| 461 |
+
# hop nothing absorbs that instruction and the model invents late action to
|
| 462 |
+
# satisfy it (observed: a line of dialogue in the last second of a 3-hop
|
| 463 |
+
# chain, and the ~23 s spike noted in CLAUDE.md). Each variant below still
|
| 464 |
+
# ends on the clip's terminal *state*, never on the photographs, so the
|
| 465 |
+
# ordering rule documented at the return is preserved.
|
| 466 |
+
terminal = {
|
| 467 |
+
"settle": "and that action eases to a rest and stays there through the "
|
| 468 |
+
"final frames.",
|
| 469 |
+
"hold": "and the final position holds steady through the last moments.",
|
| 470 |
+
}.get(str(tail or "").strip(), "and that action is still underway as the clip ends.")
|
| 471 |
+
|
| 472 |
+
if lock:
|
| 473 |
+
hold = (
|
| 474 |
+
f"{whoever} their current pose, room, lighting, and camera side, "
|
| 475 |
+
"and the shot continues from exactly there. "
|
| 476 |
+
)
|
| 477 |
+
closer = (
|
| 478 |
+
"Faces and hair follow the identity photographs. Clothing follows "
|
| 479 |
+
"whatever is already on them in the live frame. After a brief hold "
|
| 480 |
+
"on the incoming action, the shot advances through what the "
|
| 481 |
+
f"next-beat describes, {terminal}"
|
| 482 |
+
)
|
| 483 |
+
else:
|
| 484 |
+
hold = (
|
| 485 |
+
"The incoming frame holds the current pose, room, lighting, and "
|
| 486 |
+
"camera side, and the shot continues from exactly there. "
|
| 487 |
+
)
|
| 488 |
+
closer = (
|
| 489 |
+
"Wardrobe, room, and lighting stay as they are in the live frame. "
|
| 490 |
+
"After a brief hold on the incoming action, the shot advances "
|
| 491 |
+
f"through what the next-beat describes, {terminal}"
|
| 492 |
+
)
|
| 493 |
+
# Official field names on hop 2+ start a new Ref2VA generate
|
| 494 |
+
# (chain_00030..00034). One paragraph: airlock, then the beat.
|
| 495 |
+
text = re.sub(
|
| 496 |
+
r"(?m)^(overall_soundscape|non_diegetic_music):\s*", "", text).strip()
|
| 497 |
+
return (
|
| 498 |
+
f"{top}"
|
| 499 |
+
"The clip opens already in progress from the pinned frames. "
|
| 500 |
+
"The incoming arrangement holds for a short beat -- breath, a weight "
|
| 501 |
+
"shift, an eyeline -- and only then the next action begins. "
|
| 502 |
+
f"{hold}"
|
| 503 |
+
f"{inject}"
|
| 504 |
+
"What happens next:\n"
|
| 505 |
+
f"{text}\n\n"
|
| 506 |
+
# Ordering rule: the prompt must END on ongoing motion, never on the
|
| 507 |
+
# photographs. The final sentence governs the terminal state of the clip,
|
| 508 |
+
# and when the beat action finishes before the frames run out the model
|
| 509 |
+
# renders whatever the prompt last pointed it at. Ending on "follow the
|
| 510 |
+
# identity photographs" made the tail settle onto the reference image --
|
| 511 |
+
# observed as the last ~3s of a 3-hop chain cutting to the ref still.
|
| 512 |
+
# Hops 1..N-1 hide this because the pin consumes their tail; the final
|
| 513 |
+
# hop has no successor, so its drift is what you see.
|
| 514 |
+
f"{closer}"
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def _attach_pin_to_qwen(pin_mode, hop_images, hop_videos, last_frame, pin_clip):
|
| 519 |
+
"""AddGuide is invisible to Qwen. Optionally put the incoming state in ref slots.
|
| 520 |
+
|
| 521 |
+
The live frame is <Picture 1>. Identity stills shift up. Appending it after
|
| 522 |
+
the stills (chain_00034) made the pin Picture 4 against a commercial-kitchen
|
| 523 |
+
face and outfit as Pictures 1–2; hop 2 hard-cut and dropped the apron.
|
| 524 |
+
"""
|
| 525 |
+
images = dict(hop_images or {})
|
| 526 |
+
videos = dict(hop_videos or {})
|
| 527 |
+
live_p = live_v = None
|
| 528 |
+
if pin_mode in ("last frame", "both") and last_frame is not None:
|
| 529 |
+
used = len(images)
|
| 530 |
+
if used >= MAX_REF_IMAGES:
|
| 531 |
+
print(f"[{TAG}] pin_to_qwen last frame skipped: already {MAX_REF_IMAGES} stills",
|
| 532 |
+
flush=True)
|
| 533 |
+
else:
|
| 534 |
+
# Insertion order IS Picture order (core walks .values()).
|
| 535 |
+
# Put the live frame in the dict first or it becomes the last picture.
|
| 536 |
+
ordered = {"ref_image_1": last_frame[:1].contiguous()}
|
| 537 |
+
for key, tensor in images.items():
|
| 538 |
+
n = int(str(key).rsplit("_", 1)[-1])
|
| 539 |
+
ordered[f"ref_image_{n + 1}"] = tensor
|
| 540 |
+
images = ordered
|
| 541 |
+
live_p = 1
|
| 542 |
+
extra = f", {used} still(s) -> Picture 2+" if used else ""
|
| 543 |
+
print(f"[{TAG}] Qwen last frame -> <Picture 1>{extra}", flush=True)
|
| 544 |
+
if pin_mode in ("pin clip", "both") and pin_clip is not None:
|
| 545 |
+
if pin_clip.shape[0] < 5:
|
| 546 |
+
print(f"[{TAG}] pin clip too short for a video ref "
|
| 547 |
+
f"({int(pin_clip.shape[0])}f)", flush=True)
|
| 548 |
+
elif len(videos) >= MAX_REF_VIDEOS:
|
| 549 |
+
print(f"[{TAG}] pin_to_qwen pin clip skipped: already {MAX_REF_VIDEOS} videos",
|
| 550 |
+
flush=True)
|
| 551 |
+
else:
|
| 552 |
+
live_v = len(videos) + 1
|
| 553 |
+
videos[f"ref_video_{live_v}"] = pin_clip.contiguous()
|
| 554 |
+
print(
|
| 555 |
+
f"[{TAG}] Qwen pin clip -> <Video {live_v}> "
|
| 556 |
+
f"({int(pin_clip.shape[0])}f, no soundtrack — voice stays <Audio 1>)",
|
| 557 |
+
flush=True,
|
| 558 |
+
)
|
| 559 |
+
return images, live_p, videos, live_v
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def _latent_cpu(lat):
|
| 563 |
+
"""Keep the previous hop's sampler output off GPU between hops."""
|
| 564 |
+
if not isinstance(lat, dict) or "samples" not in lat:
|
| 565 |
+
return lat
|
| 566 |
+
out = dict(lat)
|
| 567 |
+
samples = lat["samples"]
|
| 568 |
+
try:
|
| 569 |
+
out["samples"] = samples.cpu()
|
| 570 |
+
except Exception as e:
|
| 571 |
+
# Falling back to the GPU tensor is correct -- the pin still works --
|
| 572 |
+
# but it is also a per-hop VRAM leak, so it must not be silent.
|
| 573 |
+
print(f"[{TAG}] could not move the hop latent to CPU ({e!r}); "
|
| 574 |
+
f"keeping it on device", flush=True)
|
| 575 |
+
out["samples"] = samples
|
| 576 |
+
return out
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _motion_context_cls():
|
| 580 |
+
"""Upstream MiniMaxH3MotionContext, skipping forks with a different apply()."""
|
| 581 |
+
try:
|
| 582 |
+
import inspect
|
| 583 |
+
import nodes as nodes_mod
|
| 584 |
+
except Exception:
|
| 585 |
+
return None
|
| 586 |
+
cls = getattr(nodes_mod, "NODE_CLASS_MAPPINGS", {}).get("MiniMaxH3MotionContext")
|
| 587 |
+
|
| 588 |
+
def compatible(c):
|
| 589 |
+
try:
|
| 590 |
+
params = inspect.signature(c.apply).parameters
|
| 591 |
+
need = [k for k, v in params.items()
|
| 592 |
+
if v.default is inspect.Parameter.empty and k != "self"]
|
| 593 |
+
return "context_frames" not in need
|
| 594 |
+
except Exception:
|
| 595 |
+
return False
|
| 596 |
+
|
| 597 |
+
if cls is not None and compatible(cls):
|
| 598 |
+
return cls
|
| 599 |
+
import sys
|
| 600 |
+
for mod in list(sys.modules.values()):
|
| 601 |
+
cand = getattr(mod, "MiniMaxH3MotionContext", None)
|
| 602 |
+
if cand is not None and compatible(cand):
|
| 603 |
+
if cls is not None:
|
| 604 |
+
print(
|
| 605 |
+
f"[{TAG}] MiniMaxH3MotionContext registry entry is a fork; "
|
| 606 |
+
f"using upstream class from {getattr(mod, '__name__', '?')}",
|
| 607 |
+
flush=True,
|
| 608 |
+
)
|
| 609 |
+
return cand
|
| 610 |
+
return None
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _latent_parts(x):
|
| 614 |
+
"""The component tensors inside a sampler `samples`, as a flat list.
|
| 615 |
+
|
| 616 |
+
H3 hands back a `comfy.nested_tensor.NestedTensor` -- the video and audio
|
| 617 |
+
latents in one object -- and it is not a Tensor. It has no `.std()`, and
|
| 618 |
+
the attributes it *does* expose are traps: `.shape` returns
|
| 619 |
+
`tensors[0].shape`, i.e. the video component's shape while silently
|
| 620 |
+
speaking for both. Reading the components is the only honest way to touch
|
| 621 |
+
the numbers. Returns None for anything unrecognised, which callers treat as
|
| 622 |
+
"leave this latent alone".
|
| 623 |
+
"""
|
| 624 |
+
if isinstance(x, torch.Tensor):
|
| 625 |
+
return [x]
|
| 626 |
+
if getattr(x, "is_nested", False) and hasattr(x, "unbind"):
|
| 627 |
+
parts = list(x.unbind())
|
| 628 |
+
if parts and all(isinstance(t, torch.Tensor) for t in parts):
|
| 629 |
+
return parts
|
| 630 |
+
return None
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
def _rebuild_latent_samples(x, parts):
|
| 634 |
+
"""Put conditioned components back into the container they came from."""
|
| 635 |
+
if isinstance(x, torch.Tensor):
|
| 636 |
+
return parts[0]
|
| 637 |
+
return type(x)(parts)
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
def _condition_pin_latent(lat, anchor_std, renorm=False, noise=0.0, seed=0):
|
| 641 |
+
"""Anti-ratchet preprocessing for the latent handed to Motion-Context.
|
| 642 |
+
|
| 643 |
+
MiniMaxH3MotionContext.apply() takes `context_latent` as-is and exposes no
|
| 644 |
+
hook, so both levers have to be applied to the latent before it goes in.
|
| 645 |
+
|
| 646 |
+
`renorm` rescales this pin so its standard deviation matches hop 2's. The
|
| 647 |
+
pin's own sigma climbs hop over hop, and because that inflated pin is what
|
| 648 |
+
conditions the next hop the growth compounds upstream of anything a master
|
| 649 |
+
pass can reach. A scalar rescale moves no structure, so it cannot blur
|
| 650 |
+
detail. `noise` mixes in a seeded perturbation, which attacks the same
|
| 651 |
+
ratchet from the other side; measured gains reverse above 0.10, hence the
|
| 652 |
+
widget cap.
|
| 653 |
+
|
| 654 |
+
**Per component, not per latent** (fixed 2026-08-27). Video and audio are
|
| 655 |
+
two tensors in one NestedTensor and their sigmas drift independently, so
|
| 656 |
+
each carries its own anchor. Before this, `.std()` raised on the nested
|
| 657 |
+
object and both levers were dead -- the failure announced itself once per
|
| 658 |
+
hop as `pin conditioning skipped` and was easy to read as routine noise.
|
| 659 |
+
A single global scale would also have been wrong on its own terms, letting
|
| 660 |
+
the much larger video component dictate the audio's correction.
|
| 661 |
+
|
| 662 |
+
Returns `(latent, new_anchor_std)` where the anchor is a list, one sigma
|
| 663 |
+
per component -- hop 2 establishes what hops 3+ are matched against. Both
|
| 664 |
+
levers default off, in which case the latent is returned untouched.
|
| 665 |
+
"""
|
| 666 |
+
if not isinstance(lat, dict) or "samples" not in lat:
|
| 667 |
+
return lat, anchor_std
|
| 668 |
+
x = lat["samples"]
|
| 669 |
+
parts = _latent_parts(x)
|
| 670 |
+
if parts is None:
|
| 671 |
+
print(f"[{TAG}] pin conditioning skipped: unrecognised latent "
|
| 672 |
+
f"({type(x).__name__})", flush=True)
|
| 673 |
+
return lat, anchor_std
|
| 674 |
+
try:
|
| 675 |
+
cur = [float(t.float().std()) for t in parts]
|
| 676 |
+
except Exception as e: # noqa: BLE001
|
| 677 |
+
print(f"[{TAG}] pin conditioning skipped ({e!r})", flush=True)
|
| 678 |
+
return lat, anchor_std
|
| 679 |
+
if not all(c == c and c for c in cur): # zero or NaN in any stream
|
| 680 |
+
return lat, anchor_std
|
| 681 |
+
if anchor_std is None:
|
| 682 |
+
anchor_std = cur
|
| 683 |
+
if len(anchor_std) != len(cur):
|
| 684 |
+
# Stream count changed mid-chain. Nothing sensible to match against.
|
| 685 |
+
print(f"[{TAG}] pin conditioning skipped: latent has {len(cur)} "
|
| 686 |
+
f"component(s), anchor has {len(anchor_std)}", flush=True)
|
| 687 |
+
return lat, anchor_std
|
| 688 |
+
if not renorm and noise <= 0.0:
|
| 689 |
+
return lat, anchor_std
|
| 690 |
+
out_parts, notes = [], []
|
| 691 |
+
for idx, (t, c, a) in enumerate(zip(parts, cur, anchor_std)):
|
| 692 |
+
o = t
|
| 693 |
+
if renorm:
|
| 694 |
+
scale = a / c
|
| 695 |
+
o = o * scale
|
| 696 |
+
notes.append(f"renorm[{idx}] x{scale:.4f} (sigma {c:.4f} -> {a:.4f})")
|
| 697 |
+
if noise > 0.0:
|
| 698 |
+
# Per component: `.shape` on the nested object reports only the
|
| 699 |
+
# first component's shape, so one draw for the whole latent would
|
| 700 |
+
# size its noise to the video and broadcast that onto the audio.
|
| 701 |
+
g = torch.Generator(device="cpu").manual_seed((int(seed) + idx) & 0x7FFFFFFF)
|
| 702 |
+
n = torch.randn(o.shape, generator=g, dtype=torch.float32)
|
| 703 |
+
o = o + n.to(dtype=o.dtype, device=o.device) * (float(noise) * a)
|
| 704 |
+
notes.append(f"noise[{idx}] {float(noise):.3f}")
|
| 705 |
+
out_parts.append(o)
|
| 706 |
+
if notes:
|
| 707 |
+
print(f"[{TAG}] pin conditioning: " + ", ".join(notes), flush=True)
|
| 708 |
+
new = dict(lat)
|
| 709 |
+
new["samples"] = _rebuild_latent_samples(x, out_parts)
|
| 710 |
+
return new, anchor_std
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
def _pin_mech_for(hop_index, overlap_n, prev_sampled):
|
| 714 |
+
"""Which mechanism `_pin_continue` will pick, without doing the work.
|
| 715 |
+
|
| 716 |
+
The hop cache key has to be built *before* the pin runs, and the two
|
| 717 |
+
mechanisms produce different frames, so the key needs the mechanism up
|
| 718 |
+
front. Every condition here mirrors _pin_continue; the one thing it cannot
|
| 719 |
+
predict is Motion-Context raising at call time, which the caller catches by
|
| 720 |
+
comparing this against the mechanism actually used and declining to cache
|
| 721 |
+
that hop.
|
| 722 |
+
"""
|
| 723 |
+
if hop_index == 0:
|
| 724 |
+
return "none"
|
| 725 |
+
if _motion_context_cls() is None:
|
| 726 |
+
return "addguide_pixels"
|
| 727 |
+
if str(overlap_n) not in MC_CONTEXT_LENGTHS:
|
| 728 |
+
return "addguide_pixels"
|
| 729 |
+
if prev_sampled is None:
|
| 730 |
+
return "addguide_pixels"
|
| 731 |
+
return "motion_context"
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _pin_continue(cond, latent, vae, audio_vae, overlap_n,
|
| 735 |
+
prev_sampled, prev_imgs, prev_audio, audio_ctx=24):
|
| 736 |
+
"""Hop 2+ motion pin. Latent Motion-Context when possible; AddGuide otherwise.
|
| 737 |
+
|
| 738 |
+
AddGuide re-encodes decoded pixels and anchors audio forwards from frame 0
|
| 739 |
+
(cover-band soundtrack). Motion-Context slices the previous sampler AV
|
| 740 |
+
latent and end-aligns the audio window on this clip's timeline.
|
| 741 |
+
|
| 742 |
+
Returns `(conditioning, mech)` where mech is one of "motion_context",
|
| 743 |
+
"addguide_pixels" or "none". The caller puts mech in the *per-hop* cache
|
| 744 |
+
key: the two mechanisms produce different frames, so a hop rendered under
|
| 745 |
+
the AddGuide fallback must not be served later to a run where the latent
|
| 746 |
+
pin was available.
|
| 747 |
+
"""
|
| 748 |
+
ctx_label = str(overlap_n)
|
| 749 |
+
mc = _motion_context_cls()
|
| 750 |
+
if mc is not None and ctx_label not in MC_CONTEXT_LENGTHS:
|
| 751 |
+
print(
|
| 752 |
+
f"[{TAG}] overlap {overlap_n}f has no Motion-Context context_length "
|
| 753 |
+
f"(accepts {sorted(MC_CONTEXT_LENGTHS, key=int)}); AddGuide pixel pin",
|
| 754 |
+
flush=True,
|
| 755 |
+
)
|
| 756 |
+
mc = None
|
| 757 |
+
if mc is not None and prev_sampled is not None:
|
| 758 |
+
try:
|
| 759 |
+
a_ctx = int(audio_ctx)
|
| 760 |
+
cond, trim = mc().apply(
|
| 761 |
+
conditioning=cond, vae=vae, latent=latent,
|
| 762 |
+
context_length=ctx_label, audio_context_length=a_ctx,
|
| 763 |
+
context_latent=prev_sampled,
|
| 764 |
+
)
|
| 765 |
+
print(
|
| 766 |
+
f"[{TAG}] Motion-Context pin: previous hop latent "
|
| 767 |
+
f"({ctx_label}f picture, {a_ctx}f audio, trim {trim})",
|
| 768 |
+
flush=True,
|
| 769 |
+
)
|
| 770 |
+
return cond, "motion_context"
|
| 771 |
+
except Exception as e:
|
| 772 |
+
# This downgrade changes the join, so it is logged with the real
|
| 773 |
+
# exception rather than swallowed -- repr(), because a bare
|
| 774 |
+
# TypeError from a renamed upstream kwarg stringifies to nothing
|
| 775 |
+
# useful.
|
| 776 |
+
print(
|
| 777 |
+
f"[{TAG}] Motion-Context pin failed ({e!r}); AddGuide pixel pin",
|
| 778 |
+
flush=True,
|
| 779 |
+
)
|
| 780 |
+
if mc is None:
|
| 781 |
+
print(
|
| 782 |
+
f"[{TAG}] Motion-Context not available; AddGuide pixel pin "
|
| 783 |
+
f"({overlap_n}f). Install ComfyUI-H3-Motion-Context for a latent join.",
|
| 784 |
+
flush=True,
|
| 785 |
+
)
|
| 786 |
+
elif prev_sampled is None:
|
| 787 |
+
print(
|
| 788 |
+
f"[{TAG}] previous hop has no sampler latent (cache hit); "
|
| 789 |
+
f"AddGuide pixel pin ({overlap_n}f)",
|
| 790 |
+
flush=True,
|
| 791 |
+
)
|
| 792 |
+
pin_image = prev_imgs[-overlap_n:] if prev_imgs is not None else None
|
| 793 |
+
pin_audio = _tail_audio(prev_audio, overlap_n) if prev_audio is not None else None
|
| 794 |
+
if pin_image is None and pin_audio is None:
|
| 795 |
+
return cond, "none"
|
| 796 |
+
return _result(MiniMaxH3AddGuide.execute(
|
| 797 |
+
cond, latent, 0,
|
| 798 |
+
vae=vae if pin_image is not None else None,
|
| 799 |
+
audio_vae=audio_vae if pin_audio is not None else None,
|
| 800 |
+
image=pin_image,
|
| 801 |
+
audio=pin_audio,
|
| 802 |
+
))[0], "addguide_pixels"
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
def _collect_ref_images(slot_images):
|
| 806 |
+
"""Dense-pack the wired slots into <Picture N> order.
|
| 807 |
+
|
| 808 |
+
Takes the slot -> tensor map the caller already built, rather than reading
|
| 809 |
+
the nine node inputs a second time. Gathering them twice -- once here, once
|
| 810 |
+
for `slot_images` -- is how a slot goes missing from one of the two and
|
| 811 |
+
silently renumbers every later <Picture N>, which is the exact failure
|
| 812 |
+
refs.py exists to prevent.
|
| 813 |
+
"""
|
| 814 |
+
frames = [slot_images[s] for s in sorted(slot_images)]
|
| 815 |
+
if not frames:
|
| 816 |
+
return None
|
| 817 |
+
bits = []
|
| 818 |
+
for i, im in enumerate(frames):
|
| 819 |
+
bits.append(f"<Picture {i + 1}> {int(im.shape[2])}x{int(im.shape[1])}")
|
| 820 |
+
print(f"[{TAG}] {len(frames)} reference image(s) -> " + ", ".join(bits), flush=True)
|
| 821 |
+
if len(frames) < 3:
|
| 822 |
+
print(f"[{TAG}] warning: fewer than 3 stills. A reference with no "
|
| 823 |
+
"picture chosen does not count.", flush=True)
|
| 824 |
+
return {f"ref_image_{i + 1}": frames[i] for i in range(len(frames))}
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
def _audio_samples(frames, sr):
|
| 828 |
+
return max(1, int(round(frames / float(FPS) * int(sr))))
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
def _tail_audio(audio, frames):
|
| 832 |
+
wav = audio["waveform"]
|
| 833 |
+
sr = int(audio["sample_rate"])
|
| 834 |
+
n = min(_audio_samples(frames, sr), int(wav.shape[-1]))
|
| 835 |
+
return {"waveform": wav[..., -n:].contiguous(), "sample_rate": sr}
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
def _trim_audio_head(audio, frames):
|
| 839 |
+
wav = audio["waveform"]
|
| 840 |
+
sr = int(audio["sample_rate"])
|
| 841 |
+
n = min(_audio_samples(frames, sr), int(wav.shape[-1]))
|
| 842 |
+
return {"waveform": wav[..., n:].contiguous(), "sample_rate": sr}, n
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
def _xfade_audio(left, right, sr, ms=40):
|
| 846 |
+
n = max(1, int(sr * ms / 1000.0))
|
| 847 |
+
k = min(n, int(left.shape[-1]), int(right.shape[-1]))
|
| 848 |
+
if k < 8:
|
| 849 |
+
return torch.cat([left, right], dim=-1)
|
| 850 |
+
t = torch.linspace(0, 1, k, dtype=left.dtype, device=left.device)
|
| 851 |
+
fade_out = torch.cos(t * math.pi / 2)
|
| 852 |
+
fade_in = torch.sin(t * math.pi / 2)
|
| 853 |
+
while fade_out.ndim < left.ndim:
|
| 854 |
+
fade_out = fade_out.unsqueeze(0)
|
| 855 |
+
fade_in = fade_in.unsqueeze(0)
|
| 856 |
+
seam = left[..., -k:] * fade_out + right[..., :k] * fade_in
|
| 857 |
+
return torch.cat([left[..., :-k], seam, right[..., k:]], dim=-1)
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
def _frame_to_jpeg_b64(frame, max_side=512, quality=80):
|
| 861 |
+
from PIL import Image
|
| 862 |
+
arr = (frame.detach().float().cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
|
| 863 |
+
if arr.ndim == 3 and arr.shape[-1] > 3:
|
| 864 |
+
arr = arr[..., :3]
|
| 865 |
+
img = Image.fromarray(arr)
|
| 866 |
+
w, h = img.size
|
| 867 |
+
scale = min(1.0, float(max_side) / float(max(w, h)))
|
| 868 |
+
if scale < 1.0:
|
| 869 |
+
img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
|
| 870 |
+
buf = pyio.BytesIO()
|
| 871 |
+
img.save(buf, format="JPEG", quality=int(quality))
|
| 872 |
+
return base64.b64encode(buf.getvalue()).decode("ascii"), img.size
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
class _PreviewEncoder:
|
| 876 |
+
"""Encode preview JPEGs off the sampling thread.
|
| 877 |
+
|
| 878 |
+
Encoding inline cost the sampler a PIL resize plus a JPEG write at every
|
| 879 |
+
push. The queue is bounded and *drops* when full: a preview frame is worth
|
| 880 |
+
nothing if delivering it slows the render that produced it.
|
| 881 |
+
"""
|
| 882 |
+
|
| 883 |
+
def __init__(self, depth=2):
|
| 884 |
+
self._q = queue.Queue(maxsize=depth)
|
| 885 |
+
self._t = None
|
| 886 |
+
|
| 887 |
+
def _run(self):
|
| 888 |
+
while True:
|
| 889 |
+
job = self._q.get()
|
| 890 |
+
if job is None:
|
| 891 |
+
return
|
| 892 |
+
payload, frames = job
|
| 893 |
+
try:
|
| 894 |
+
for key, frame in frames.items():
|
| 895 |
+
b64, (w, h) = _frame_to_jpeg_b64(frame)
|
| 896 |
+
payload[key] = b64
|
| 897 |
+
if key == "image":
|
| 898 |
+
payload["w"], payload["h"] = w, h
|
| 899 |
+
except Exception as e:
|
| 900 |
+
print(f"[{TAG}] preview encode skipped: {e!r}", flush=True)
|
| 901 |
+
try:
|
| 902 |
+
PromptServer.instance.send_sync(
|
| 903 |
+
"h3_refchain_preview", payload, PromptServer.instance.client_id)
|
| 904 |
+
except Exception as e:
|
| 905 |
+
print(f"[{TAG}] preview send skipped: {e!r}", flush=True)
|
| 906 |
+
|
| 907 |
+
def submit(self, payload, frames):
|
| 908 |
+
if self._t is None:
|
| 909 |
+
self._t = threading.Thread(target=self._run, name="h3rc-preview",
|
| 910 |
+
daemon=True)
|
| 911 |
+
self._t.start()
|
| 912 |
+
try:
|
| 913 |
+
self._q.put_nowait((payload, frames))
|
| 914 |
+
except queue.Full:
|
| 915 |
+
pass # deliberate: never block the sampler for a preview
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
_PREVIEW = _PreviewEncoder()
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
def _push_preview(unique_id, status, frame=None, hop=0, total=0,
|
| 922 |
+
pin_mech=None, frac=None, seam_frame=None, meta=None):
|
| 923 |
+
"""Send one preview update.
|
| 924 |
+
|
| 925 |
+
`status` stays a SHORT label. The full per-hop prompt dump belongs on the
|
| 926 |
+
`info` output -- passing it here once turned the status strip into the
|
| 927 |
+
prompt. Everything structured goes in its own field instead, which is what
|
| 928 |
+
a panel can actually lay out.
|
| 929 |
+
"""
|
| 930 |
+
if not unique_id or PromptServer is None:
|
| 931 |
+
return
|
| 932 |
+
payload = {
|
| 933 |
+
"node_id": unique_id,
|
| 934 |
+
"status": status,
|
| 935 |
+
"hop": int(hop),
|
| 936 |
+
"total": int(total),
|
| 937 |
+
}
|
| 938 |
+
if pin_mech:
|
| 939 |
+
payload["pin_mech"] = str(pin_mech)
|
| 940 |
+
if frac is not None:
|
| 941 |
+
payload["frac"] = max(0.0, min(1.0, float(frac)))
|
| 942 |
+
if meta:
|
| 943 |
+
payload.update(meta)
|
| 944 |
+
frames = {}
|
| 945 |
+
if frame is not None:
|
| 946 |
+
frames["image"] = frame
|
| 947 |
+
if seam_frame is not None:
|
| 948 |
+
frames["seam_image"] = seam_frame
|
| 949 |
+
_PREVIEW.submit(payload, frames)
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
def _offload_text_encoder(clip, model):
|
| 953 |
+
te_dev = getattr(clip.patcher, "load_device", None)
|
| 954 |
+
dit_dev = getattr(model, "load_device", None)
|
| 955 |
+
if te_dev is not None and dit_dev is not None and str(te_dev) != str(dit_dev):
|
| 956 |
+
return
|
| 957 |
+
try:
|
| 958 |
+
clip.patcher.model.to(mm.text_encoder_offload_device())
|
| 959 |
+
except Exception as e:
|
| 960 |
+
print(f"[{TAG}] TE offload skipped: {e}", flush=True)
|
| 961 |
+
return
|
| 962 |
+
try:
|
| 963 |
+
dev = mm.get_torch_device()
|
| 964 |
+
mm.free_memory(mm.get_total_memory(dev) * 0.9, dev)
|
| 965 |
+
mm.soft_empty_cache()
|
| 966 |
+
free = mm.get_free_memory(dev) / (1024 ** 3)
|
| 967 |
+
print(f"[{TAG}] TE evicted; {free:.1f} GB free for the DiT", flush=True)
|
| 968 |
+
except Exception as e:
|
| 969 |
+
print(f"[{TAG}] VRAM purge skipped: {e}", flush=True)
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
def _decode_av(video_vae, audio_vae, latent):
|
| 973 |
+
imgs = VAEDecode().decode(video_vae, latent)[0]
|
| 974 |
+
audio = vae_decode_audio(audio_vae, latent)
|
| 975 |
+
return imgs, audio
|
| 976 |
+
|
| 977 |
+
|
| 978 |
+
class HandTieClips:
|
| 979 |
+
"""Refs + shot plan + N hops, assembled into one clip.
|
| 980 |
+
|
| 981 |
+
Each hop after the first pins the previous hop's sampler latent through
|
| 982 |
+
Motion-Context, falling back to an AddGuide pixel pin when that is not
|
| 983 |
+
available. See the module docstring.
|
| 984 |
+
"""
|
| 985 |
+
|
| 986 |
+
@classmethod
|
| 987 |
+
def INPUT_TYPES(cls):
|
| 988 |
+
return {
|
| 989 |
+
"required": {
|
| 990 |
+
"model": ("MODEL",),
|
| 991 |
+
"clip": ("CLIP",),
|
| 992 |
+
"vae": ("VAE",),
|
| 993 |
+
"audio_vae": ("VAE",),
|
| 994 |
+
"prompt": ("STRING", {
|
| 995 |
+
"multiline": True,
|
| 996 |
+
"dynamicPrompts": False,
|
| 997 |
+
"default": (
|
| 998 |
+
"Live-action, natural indoor light. The person looks exactly "
|
| 999 |
+
"as in the reference photographs.\n\n"
|
| 1000 |
+
"They sit at a table, look up, and speak one short line. "
|
| 1001 |
+
"Then they settle, watching the room."
|
| 1002 |
+
),
|
| 1003 |
+
"tooltip": (
|
| 1004 |
+
"Hop 1 prompt. Separate hops with --- on its own line, "
|
| 1005 |
+
"or JSON {\"prompts\": [...]}. hop_script=verbatim: one block "
|
| 1006 |
+
"+ chains>1 wraps later hops. hop_script=next: later --- blocks "
|
| 1007 |
+
"are only 'what happens next'."
|
| 1008 |
+
),
|
| 1009 |
+
}),
|
| 1010 |
+
"chains": (["1", "2", "3", "4", "5", "6", "7", "8"], {
|
| 1011 |
+
"default": "3",
|
| 1012 |
+
"tooltip": "How many generates to run and join. 3 at 10 s is about 28 s of master after the overlap trim.",
|
| 1013 |
+
}),
|
| 1014 |
+
"resolution": (["0.2 MP", "0.3 MP", "0.5 MP", "0.7 MP", "1.0 MP"], {
|
| 1015 |
+
"default": "1.0 MP",
|
| 1016 |
+
"tooltip": "Output area. 1.0 MP landscape is 1280x736 (the verified H3 size). Snapped to H3's 32 px grid.",
|
| 1017 |
+
}),
|
| 1018 |
+
"aspect": (["16:9 landscape", "9:16 portrait", "1:1 square"], {
|
| 1019 |
+
"default": "16:9 landscape",
|
| 1020 |
+
"tooltip": "Frame shape. Combined with resolution to set width and height.",
|
| 1021 |
+
}),
|
| 1022 |
+
"duration": (["5 s", "7 s", "8 s", "10 s", "15 s"], {
|
| 1023 |
+
"default": "10 s",
|
| 1024 |
+
"tooltip": "Length of each hop at 24 fps (H3 17k+5 grid: 124 / 192 / 243 / 362 frames). 5 s (124f) drops the airlock on a continuous join; use 8 s or 15 s to validate a seam.",
|
| 1025 |
+
}),
|
| 1026 |
+
"overlap": (["0.9 s", "0.2 s", "1.6 s"], {
|
| 1027 |
+
"default": "0.9 s",
|
| 1028 |
+
"tooltip": "Pinned clip from the previous hop at frame 0. 0.9 s (22 frames) is the native continuation length.",
|
| 1029 |
+
}),
|
| 1030 |
+
"seed": ("INT", {
|
| 1031 |
+
"default": 0, "min": 0, "max": 0xffffffffffffffff,
|
| 1032 |
+
"control_after_generate": True,
|
| 1033 |
+
}),
|
| 1034 |
+
"seed_per_shot": ("BOOLEAN", {
|
| 1035 |
+
"default": True,
|
| 1036 |
+
"label_on": "vary per hop",
|
| 1037 |
+
"label_off": "same seed every hop",
|
| 1038 |
+
}),
|
| 1039 |
+
"steps": ("INT", {"default": 14, "min": 1, "max": 50}),
|
| 1040 |
+
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}),
|
| 1041 |
+
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta"}),
|
| 1042 |
+
"shift_video": ("FLOAT", {"default": 12.0, "min": 0.01, "max": 100.0, "step": 0.01}),
|
| 1043 |
+
"shift_audio": ("FLOAT", {"default": 3.0, "min": 0.01, "max": 100.0, "step": 0.01}),
|
| 1044 |
+
"ref_image_size": (["match", "max"], {
|
| 1045 |
+
"tooltip": "match = faster. max = 2048 short-edge identity, slower every step.",
|
| 1046 |
+
}),
|
| 1047 |
+
},
|
| 1048 |
+
"optional": {
|
| 1049 |
+
# The nine ref_image_N sockets, plus reference_video, voice
|
| 1050 |
+
# and start_image, used to live at the TOP of this block --
|
| 1051 |
+
# twelve of a sixteen-socket column that occupied a third of
|
| 1052 |
+
# the node before the editor started. They are files now, and
|
| 1053 |
+
# their replacements are appended at the BOTTOM of `optional`
|
| 1054 |
+
# instead. See the note beside them: widget ORDER is part of
|
| 1055 |
+
# the saved-workflow format.
|
| 1056 |
+
"hop_script": (["verbatim", "next"], {
|
| 1057 |
+
"default": "verbatim",
|
| 1058 |
+
"tooltip": (
|
| 1059 |
+
"verbatim: your text is the hop prompt. next: hop 1 is the first "
|
| 1060 |
+
"block; every later block is only the new beat. One block + next: "
|
| 1061 |
+
"hops 2+ advance without replaying the opening."
|
| 1062 |
+
),
|
| 1063 |
+
}),
|
| 1064 |
+
"pin_to_qwen": (["off", "last frame", "pin clip", "both"], {
|
| 1065 |
+
"default": "last frame",
|
| 1066 |
+
"tooltip": (
|
| 1067 |
+
"AddGuide is invisible to the text encoder. last frame = "
|
| 1068 |
+
"<Picture 1> of the previous hop's last frame (identity stills "
|
| 1069 |
+
"shift to Picture 2+). pin clip = overlap as extra <Video>. "
|
| 1070 |
+
"Voice stays <Audio 1>."
|
| 1071 |
+
),
|
| 1072 |
+
}),
|
| 1073 |
+
"continuity_state": ("STRING", {
|
| 1074 |
+
"multiline": True,
|
| 1075 |
+
"default": "",
|
| 1076 |
+
"forceInput": True,
|
| 1077 |
+
"tooltip": (
|
| 1078 |
+
"Optional JSON continuity state from HTCContinuityState (or a String "
|
| 1079 |
+
"Primitive node for hand-typed JSON). hop_script=next only: locked + "
|
| 1080 |
+
"context text rides every hop 2+, mutable beats are indexed per hop. "
|
| 1081 |
+
"Unwired = no effect."
|
| 1082 |
+
),
|
| 1083 |
+
}),
|
| 1084 |
+
"shot_plan": ("STRING", {
|
| 1085 |
+
"multiline": True,
|
| 1086 |
+
"default": "",
|
| 1087 |
+
"tooltip": (
|
| 1088 |
+
"Shot plan JSON: {\"shots\":[{\"beat\":\"...\","
|
| 1089 |
+
"\"directives\":{\"join\":\"continuous\"}}, ...]}. "
|
| 1090 |
+
"The shot count is the hop count, so `chains` is ignored. "
|
| 1091 |
+
"Directives compile to vetted continuity prose; `prose` per shot "
|
| 1092 |
+
"is appended verbatim. Blank = use the `prompt` widget instead."
|
| 1093 |
+
),
|
| 1094 |
+
}),
|
| 1095 |
+
"ref_plan": ("STRING", {
|
| 1096 |
+
"multiline": True,
|
| 1097 |
+
"default": "",
|
| 1098 |
+
"tooltip": (
|
| 1099 |
+
"Reference register JSON: {'refs':[{'tag':'hero_face',"
|
| 1100 |
+
"'file':'face.png','subject':1,'retention':'fully_preserved'}]}. "
|
| 1101 |
+
"'file' is a picture in the reference folder, chosen in the panel. "
|
| 1102 |
+
"Beats refer to refs by @tag, resolved to the correct "
|
| 1103 |
+
"<Picture N> per hop, so removing or scheduling off a ref "
|
| 1104 |
+
"never renumbers the others. Refs sharing a 'subject' are "
|
| 1105 |
+
"the same person; different numbers stay different people. "
|
| 1106 |
+
"Blank = today's positional behaviour."
|
| 1107 |
+
),
|
| 1108 |
+
}),
|
| 1109 |
+
"cache_hops": (["off", "on"], {
|
| 1110 |
+
"default": "off",
|
| 1111 |
+
"tooltip": (
|
| 1112 |
+
"Store each hop losslessly on disk, keyed by a chained "
|
| 1113 |
+
"content hash. Unchanged hops load instead of re-rendering, "
|
| 1114 |
+
"so editing only the last shot re-renders only that shot, "
|
| 1115 |
+
"and an interrupted chain resumes. Editing an early shot "
|
| 1116 |
+
"correctly invalidates every hop after it."
|
| 1117 |
+
),
|
| 1118 |
+
}),
|
| 1119 |
+
"cache_budget_gb": ("FLOAT", {
|
| 1120 |
+
"default": 20.0, "min": 1.0, "max": 500.0, "step": 1.0,
|
| 1121 |
+
"tooltip": "Least-recently-used hops are evicted above this size.",
|
| 1122 |
+
}),
|
| 1123 |
+
"audio_pin_frames": ("INT", {
|
| 1124 |
+
"default": 24, "min": 0, "max": 240, "step": 24,
|
| 1125 |
+
"tooltip": (
|
| 1126 |
+
"Audio context handed to the Motion-Context pin, in frames. "
|
| 1127 |
+
"24 is one second and lands on the model's 40 Hz audio grid; "
|
| 1128 |
+
"multiples of 24 keep whole seconds. Longer audio context "
|
| 1129 |
+
"costs conditioning rows but NO delivered frames, so it is "
|
| 1130 |
+
"the cheap lever on speech that breaks across a join -- try "
|
| 1131 |
+
"96 (4 s) for continuous dialogue. 0 follows the picture "
|
| 1132 |
+
"overlap. Video pin length is not adjustable here: it "
|
| 1133 |
+
"follows the overlap widget."
|
| 1134 |
+
),
|
| 1135 |
+
}),
|
| 1136 |
+
"pin_renorm": (["off", "on"], {
|
| 1137 |
+
"default": "off",
|
| 1138 |
+
"tooltip": (
|
| 1139 |
+
"Rescale each pinned latent so its spread matches hop 1's. "
|
| 1140 |
+
"The pin's own sigma climbs every hop and that inflated pin "
|
| 1141 |
+
"conditions the next one, so texture ratchets up along a "
|
| 1142 |
+
"long chain. A scalar rescale moves no structure, so it "
|
| 1143 |
+
"cannot blur detail. Off reproduces chain_00038 exactly; "
|
| 1144 |
+
"turn it on for chains of 3+ hops."
|
| 1145 |
+
),
|
| 1146 |
+
}),
|
| 1147 |
+
"pin_noise": ("FLOAT", {
|
| 1148 |
+
"default": 0.0, "min": 0.0, "max": 0.10, "step": 0.005,
|
| 1149 |
+
"tooltip": (
|
| 1150 |
+
"Mix seeded noise into the pinned latent before it "
|
| 1151 |
+
"conditions the next hop -- the other half of the texture "
|
| 1152 |
+
"ratchet fix. Small values only: measured gains fall off "
|
| 1153 |
+
"and reverse above 0.10, which is why the range stops "
|
| 1154 |
+
"there. 0.0 reproduces chain_00038 exactly; 0.05 is the "
|
| 1155 |
+
"documented starting point."
|
| 1156 |
+
),
|
| 1157 |
+
}),
|
| 1158 |
+
"tone_compensate": (_tone.MODES, {
|
| 1159 |
+
"default": "off",
|
| 1160 |
+
"tooltip": (
|
| 1161 |
+
"Undo the denoiser's tone bias on each hop, measured on the "
|
| 1162 |
+
"overlap that hop regenerated. The estimate needs both copies "
|
| 1163 |
+
"of the overlap, which only exist inside this node -- a "
|
| 1164 |
+
"downstream node cannot do this. frame_shift is the mode that "
|
| 1165 |
+
"suits regenerated content; gain_bias is more robust; lut "
|
| 1166 |
+
"overfits. Enabling any mode also clamps the master to 0..1."
|
| 1167 |
+
),
|
| 1168 |
+
}),
|
| 1169 |
+
# ---------------------------------------------------------
|
| 1170 |
+
# APPEND-ONLY ZONE. `widgets_values` in a saved workflow is a
|
| 1171 |
+
# POSITIONAL array -- ComfyUI restores value[i] into widget[i]
|
| 1172 |
+
# and never checks the name. A widget inserted anywhere but the
|
| 1173 |
+
# end renumbers everything after it, and every previously saved
|
| 1174 |
+
# workflow silently loads its values into the wrong widgets.
|
| 1175 |
+
#
|
| 1176 |
+
# These three were first added at the top of `optional`, which
|
| 1177 |
+
# shifted hop_script..tone_compensate by +3. `ref_plan` then
|
| 1178 |
+
# received audio_pin_frames' integer and the editor threw
|
| 1179 |
+
# `(text || "").trim is not a function` on load -- the only
|
| 1180 |
+
# visible symptom of a much wider silent corruption.
|
| 1181 |
+
#
|
| 1182 |
+
# Add new widgets HERE, at the bottom. Old workflows are then
|
| 1183 |
+
# short rather than misaligned, and the new widget takes its
|
| 1184 |
+
# default.
|
| 1185 |
+
# ---------------------------------------------------------
|
| 1186 |
+
"start_image_file": ("STRING", {
|
| 1187 |
+
"default": "",
|
| 1188 |
+
"tooltip": "First-frame pin for hop 1 only. Set in the panel.",
|
| 1189 |
+
}),
|
| 1190 |
+
"reference_video_file": ("STRING", {
|
| 1191 |
+
"default": "",
|
| 1192 |
+
"tooltip": "Motion/look plate. Not the previous hop. Set in the panel.",
|
| 1193 |
+
}),
|
| 1194 |
+
"voice_file": ("STRING", {
|
| 1195 |
+
"default": "",
|
| 1196 |
+
"tooltip": "Voice or timbre reference, riding every hop as <Audio 1>. Set in the panel.",
|
| 1197 |
+
}),
|
| 1198 |
+
# Appended here on 2026-08-29, per the note above: LAST, so
|
| 1199 |
+
# every saved workflow keeps its widget alignment and simply
|
| 1200 |
+
# takes the default.
|
| 1201 |
+
"establish": ("STRING", {
|
| 1202 |
+
# Single line on purpose. The editor hides only the widgets
|
| 1203 |
+
# it explicitly owns, so this one renders as a native widget
|
| 1204 |
+
# on the node body -- and one sentence in a one-line box is
|
| 1205 |
+
# discoverable where a multiline textarea would be a slab.
|
| 1206 |
+
"default": _d.ESTABLISH,
|
| 1207 |
+
"tooltip": (
|
| 1208 |
+
"Opening line prepended to hop 1 only, before the beat. "
|
| 1209 |
+
"The default asserts live action; clear it, or replace it "
|
| 1210 |
+
"with your own medium, for anything else. Dropped "
|
| 1211 |
+
"automatically when shot 1 already names a medium."
|
| 1212 |
+
),
|
| 1213 |
+
}),
|
| 1214 |
+
},
|
| 1215 |
+
"hidden": {
|
| 1216 |
+
"unique_id": "UNIQUE_ID",
|
| 1217 |
+
},
|
| 1218 |
+
}
|
| 1219 |
+
|
| 1220 |
+
RETURN_TYPES = ("IMAGE", "AUDIO", "STRING")
|
| 1221 |
+
RETURN_NAMES = ("images", "audio", "info")
|
| 1222 |
+
FUNCTION = "run"
|
| 1223 |
+
CATEGORY = "Hand Tie Clips"
|
| 1224 |
+
DESCRIPTION = (
|
| 1225 |
+
"MiniMax H3 Ref2VA chain. Hop 1 is a full generate; every later hop is a "
|
| 1226 |
+
"continuation pinned to the previous hop's sampler latent via "
|
| 1227 |
+
"Motion-Context, falling back to an AddGuide pixel pin when that is "
|
| 1228 |
+
"unavailable. Author with shot_plan + ref_plan; hop_script=next treats "
|
| 1229 |
+
"later blocks as what-happens-next; pin_to_qwen shows the incoming frame "
|
| 1230 |
+
"to the text encoder; continuity_state (from HTCContinuityState) carries "
|
| 1231 |
+
"locked/context setting text forward."
|
| 1232 |
+
)
|
| 1233 |
+
|
| 1234 |
+
@classmethod
|
| 1235 |
+
def IS_CHANGED(cls, ref_plan="", start_image_file="",
|
| 1236 |
+
reference_video_file="", voice_file="", **_):
|
| 1237 |
+
"""Re-run when a reference file changes underneath its name.
|
| 1238 |
+
|
| 1239 |
+
Every picture now arrives as a basename, and a basename is a stable
|
| 1240 |
+
input: overwrite `face.png` with a different face and ComfyUI would
|
| 1241 |
+
happily serve the previous render. Hashing path+mtime is the fix.
|
| 1242 |
+
|
| 1243 |
+
Deliberately NOT `float("nan")` -- that is the blunt version of this and
|
| 1244 |
+
would force a full re-render of an expensive node on every queue.
|
| 1245 |
+
"""
|
| 1246 |
+
names = [start_image_file, reference_video_file, voice_file]
|
| 1247 |
+
try:
|
| 1248 |
+
for r in (_refs.parse_ref_plan(ref_plan).get("refs") or []):
|
| 1249 |
+
if r.get("file"):
|
| 1250 |
+
names.append(r["file"])
|
| 1251 |
+
except Exception:
|
| 1252 |
+
# A malformed plan is run()'s error to report, with a message that
|
| 1253 |
+
# names the field. Raising here would surface as a cache failure.
|
| 1254 |
+
pass
|
| 1255 |
+
return _media.stamp(names)
|
| 1256 |
+
|
| 1257 |
+
def run(self, model, clip, vae, audio_vae, prompt,
|
| 1258 |
+
chains, resolution,
|
| 1259 |
+
aspect, duration, overlap, seed, seed_per_shot, steps,
|
| 1260 |
+
sampler_name, scheduler, shift_video, shift_audio, ref_image_size,
|
| 1261 |
+
start_image_file="", reference_video_file="", voice_file="",
|
| 1262 |
+
hop_script="verbatim", pin_to_qwen="last frame", continuity_state="",
|
| 1263 |
+
shot_plan="", ref_plan="", cache_hops="off", cache_budget_gb=20.0,
|
| 1264 |
+
audio_pin_frames=24, pin_renorm="off", pin_noise=0.0,
|
| 1265 |
+
tone_compensate="off", establish=None,
|
| 1266 |
+
unique_id=None):
|
| 1267 |
+
width, height = _canvas(resolution, aspect)
|
| 1268 |
+
length = align_frame_count(_duration_frames(duration))
|
| 1269 |
+
overlap_n = _overlap_frames(overlap)
|
| 1270 |
+
|
| 1271 |
+
# A shot plan is authoritative when present: its shot count is the hop
|
| 1272 |
+
# count, and every shot after the first is a beat, so `next` semantics
|
| 1273 |
+
# are the only correct reading of it.
|
| 1274 |
+
shots = _plan.parse_plan(shot_plan)
|
| 1275 |
+
if shots:
|
| 1276 |
+
if str(hop_script) != "next":
|
| 1277 |
+
print(f"[{TAG}] shot_plan present -> hop_script=next", flush=True)
|
| 1278 |
+
hop_script = "next"
|
| 1279 |
+
if int(chains) != len(shots):
|
| 1280 |
+
print(f"[{TAG}] shot_plan has {len(shots)} shot(s); "
|
| 1281 |
+
f"chains={chains} ignored", flush=True)
|
| 1282 |
+
# The place-handoff check needs to know which tags are places,
|
| 1283 |
+
# which only the ref plan says. Parsed defensively here: the
|
| 1284 |
+
# authoritative parse (and its error) is still the one below, so a
|
| 1285 |
+
# malformed ref_plan fails in the same place it always did.
|
| 1286 |
+
try:
|
| 1287 |
+
_rp_for_check = _refs.parse_ref_plan(ref_plan)
|
| 1288 |
+
except Exception:
|
| 1289 |
+
_rp_for_check = None
|
| 1290 |
+
blocks = _plan.compile_blocks(shots, establish, _rp_for_check)
|
| 1291 |
+
unique = len(shots)
|
| 1292 |
+
print(f"[{TAG}] shot plan:\n" + _plan.describe(shots), flush=True)
|
| 1293 |
+
for i, sh in enumerate(shots):
|
| 1294 |
+
if i > 0 and _d.is_full_h3_prompt((sh or {}).get("beat")):
|
| 1295 |
+
print(
|
| 1296 |
+
f"[{TAG}] hop {i + 1}: full H3 prompt flattened to a "
|
| 1297 |
+
"continuation beat (a complete Ref2VA block on hop 2+ "
|
| 1298 |
+
"starts a new scene)",
|
| 1299 |
+
flush=True,
|
| 1300 |
+
)
|
| 1301 |
+
else:
|
| 1302 |
+
shots = [None] * int(chains)
|
| 1303 |
+
blocks, unique = _expand_shots(
|
| 1304 |
+
_parse_shots(prompt), int(chains), hop_script=str(hop_script))
|
| 1305 |
+
n = len(blocks)
|
| 1306 |
+
|
| 1307 |
+
# Per-shot duration overrides, validated up front so a bad value fails
|
| 1308 |
+
# before any sampling happens rather than three hops in.
|
| 1309 |
+
lengths = []
|
| 1310 |
+
for i, sh in enumerate(shots):
|
| 1311 |
+
dur = (sh or {}).get("duration") if sh else None
|
| 1312 |
+
if dur and str(dur) not in DURATION_FRAMES:
|
| 1313 |
+
raise ValueError(
|
| 1314 |
+
f"{TAG}: shot {i + 1}: duration '{dur}' is not valid. "
|
| 1315 |
+
f"Use one of: {', '.join(DURATION_FRAMES)}"
|
| 1316 |
+
)
|
| 1317 |
+
lengths.append(align_frame_count(_duration_frames(dur or duration)))
|
| 1318 |
+
if str(hop_script) == "next":
|
| 1319 |
+
for i, sh in enumerate(shots):
|
| 1320 |
+
if i == 0:
|
| 1321 |
+
continue
|
| 1322 |
+
join = ((sh or {}).get("directives") or {}).get("join")
|
| 1323 |
+
dur = ((sh or {}).get("duration") if sh else None) or duration
|
| 1324 |
+
if join == "continuous" and str(dur) == "5 s":
|
| 1325 |
+
print(
|
| 1326 |
+
f"[{TAG}] note: shot {i + 1} is join=continuous at 5 s "
|
| 1327 |
+
"(124f). That budget drops the airlock; 8 s / 15 s is "
|
| 1328 |
+
"the join-validation canvas. A lucky seed can still "
|
| 1329 |
+
"join at 5 s.",
|
| 1330 |
+
flush=True,
|
| 1331 |
+
)
|
| 1332 |
+
for i, ln in enumerate(lengths):
|
| 1333 |
+
if overlap_n >= ln:
|
| 1334 |
+
raise ValueError(
|
| 1335 |
+
f"{TAG}: shot {i + 1}: overlap {overlap} ({overlap_n} frames) must "
|
| 1336 |
+
f"be smaller than duration ({ln} frames)"
|
| 1337 |
+
)
|
| 1338 |
+
state = _parse_state(continuity_state)
|
| 1339 |
+
# The three singles first, so a name that does not resolve is reported
|
| 1340 |
+
# before anything expensive starts. Each returns exactly what the socket
|
| 1341 |
+
# it replaced delivered, so everything downstream is unchanged.
|
| 1342 |
+
start_image = _media.load_image(start_image_file) if start_image_file else None
|
| 1343 |
+
reference_video = (_media.load_video(reference_video_file)
|
| 1344 |
+
if reference_video_file else None)
|
| 1345 |
+
voice = _media.load_audio(voice_file) if voice_file else None
|
| 1346 |
+
for _name, _got in (("start_image", start_image_file and start_image is None),
|
| 1347 |
+
("reference_video", reference_video_file and reference_video is None),
|
| 1348 |
+
("voice", voice_file and voice is None)):
|
| 1349 |
+
if _got:
|
| 1350 |
+
print(f"[{TAG}] note: {_name} file could not be read; continuing "
|
| 1351 |
+
f"without it", flush=True)
|
| 1352 |
+
|
| 1353 |
+
ref_plan_obj = _refs.parse_ref_plan(ref_plan)
|
| 1354 |
+
ref_plan_refs = ref_plan_obj["refs"]
|
| 1355 |
+
ref_subjects = ref_plan_obj["subjects"]
|
| 1356 |
+
# Derived slot -> tensor. The register still needs to know which picture
|
| 1357 |
+
# a @tag is pinned to; the slot is now the ref's position in the rail
|
| 1358 |
+
# rather than a socket number, and _collect_ref_images below dense-packs
|
| 1359 |
+
# and throws it away.
|
| 1360 |
+
slot_images = {}
|
| 1361 |
+
for _r in ref_plan_refs:
|
| 1362 |
+
if not _r["file"]:
|
| 1363 |
+
continue
|
| 1364 |
+
_im = _media.load_image(_r["file"])
|
| 1365 |
+
if _im is not None and _im.shape[0] > 0:
|
| 1366 |
+
slot_images[_r["slot"]] = _im[:1]
|
| 1367 |
+
if ref_plan_refs:
|
| 1368 |
+
print(f"[{TAG}] reference register:", flush=True)
|
| 1369 |
+
print(_refs.describe(ref_plan_obj), flush=True)
|
| 1370 |
+
# A named picture that is not on disk stops the queue. Warning and
|
| 1371 |
+
# continuing renders the whole chain with that reference silently
|
| 1372 |
+
# inactive, which is the failure the register exists to prevent --
|
| 1373 |
+
# and the docs have always promised a stop here.
|
| 1374 |
+
_absent = _refs.missing_files(ref_plan_obj, set(slot_images))
|
| 1375 |
+
if _absent:
|
| 1376 |
+
raise ValueError(
|
| 1377 |
+
f"[{TAG}] reference picture not found in "
|
| 1378 |
+
f"ComfyUI/input/h3_refs: "
|
| 1379 |
+
+ "; ".join(f"@{_t} names '{_f}'" for _t, _f in _absent)
|
| 1380 |
+
+ ". Drop the file onto that row in the REFERENCES rail, or "
|
| 1381 |
+
"clear the row's picture to render without it.")
|
| 1382 |
+
for _w in _refs.check(ref_plan_obj, set(slot_images)):
|
| 1383 |
+
print(f"[{TAG}] note: {_w}", flush=True)
|
| 1384 |
+
# ref_plan subjects and a `characters` block in continuity_state
|
| 1385 |
+
# both feed the prompt header, so filling in both injects identity
|
| 1386 |
+
# prose twice. HTCContinuityState no longer emits characters, so this
|
| 1387 |
+
# can only come from hand-authored JSON -- still worth warning about
|
| 1388 |
+
# rather than raising, since the setting half stays useful.
|
| 1389 |
+
_chars = sorted(
|
| 1390 |
+
cid for cid, c in (state.get("characters") or {}).items()
|
| 1391 |
+
if (c or {}).get("locked") or (c or {}).get("context")
|
| 1392 |
+
or (c or {}).get("mutable")
|
| 1393 |
+
)
|
| 1394 |
+
if ref_subjects and _chars:
|
| 1395 |
+
print(f"[{TAG}] note: ref_plan defines subject(s) "
|
| 1396 |
+
f"{sorted(ref_subjects)} and continuity_state also carries "
|
| 1397 |
+
f"character(s) {_chars}. Both inject identity text -- drop "
|
| 1398 |
+
f"the characters block and keep setting only.", flush=True)
|
| 1399 |
+
ref_images = _collect_ref_images(slot_images)
|
| 1400 |
+
base_videos = None if reference_video is None else {"ref_video_1": reference_video}
|
| 1401 |
+
ref_audios = None if voice is None else {"ref_audio_1": voice}
|
| 1402 |
+
|
| 1403 |
+
# Fingerprint the model as it arrives -- after whatever LoRA and
|
| 1404 |
+
# attention nodes are drawn upstream, before this node touches it.
|
| 1405 |
+
model_fp = _model_fingerprint(model)
|
| 1406 |
+
|
| 1407 |
+
model = _result(MiniMaxH3SigmaShift.execute(model, float(shift_video), float(shift_audio)))[0]
|
| 1408 |
+
sampler = _result(KSamplerSelect.execute(sampler_name))[0]
|
| 1409 |
+
base_sigmas = _result(BasicScheduler.execute(model, scheduler, int(steps), 1.0))[0]
|
| 1410 |
+
sigma_cache = {int(steps): base_sigmas}
|
| 1411 |
+
|
| 1412 |
+
print(
|
| 1413 |
+
f"[{TAG}] {n} hop(s), {length}f ({length / FPS:.1f}s) @ {width}x{height} "
|
| 1414 |
+
f"({resolution}, {aspect}), overlap {overlap_n}f, "
|
| 1415 |
+
f"hop_script={hop_script}, pin_to_qwen={pin_to_qwen}, "
|
| 1416 |
+
f"{unique} authored block(s), {steps} steps {sampler_name}/{scheduler}",
|
| 1417 |
+
flush=True,
|
| 1418 |
+
)
|
| 1419 |
+
|
| 1420 |
+
# Preallocate the master instead of growing it with torch.cat. cat
|
| 1421 |
+
# allocates a fresh full-size tensor every hop, so at hop N the old and
|
| 1422 |
+
# new masters are briefly live *together* alongside prev_imgs and imgs.
|
| 1423 |
+
# Total length is known up front, so one allocation plus slice-writes
|
| 1424 |
+
# removes that doubling.
|
| 1425 |
+
total_frames = sum(lengths) - overlap_n * (n - 1)
|
| 1426 |
+
master_imgs = torch.empty(
|
| 1427 |
+
(total_frames, int(height), int(width), 3), dtype=torch.float32)
|
| 1428 |
+
write_pos = 0
|
| 1429 |
+
master_wav = None
|
| 1430 |
+
sr = None
|
| 1431 |
+
prev_imgs = None
|
| 1432 |
+
prev_audio = None
|
| 1433 |
+
prev_sampled = None
|
| 1434 |
+
pbar = comfy.utils.ProgressBar(n)
|
| 1435 |
+
|
| 1436 |
+
hop_store = None
|
| 1437 |
+
pin_renorm_on = str(pin_renorm) == "on"
|
| 1438 |
+
pin_noise_v = max(0.0, min(0.10, float(pin_noise)))
|
| 1439 |
+
audio_ctx = int(audio_pin_frames) if int(audio_pin_frames) > 0 else int(overlap_n)
|
| 1440 |
+
pin_anchor_std = None # hop 2's pin sets the sigma hops 3+ match
|
| 1441 |
+
if pin_renorm_on or pin_noise_v > 0.0:
|
| 1442 |
+
print(f"[{TAG}] pin conditioning enabled: "
|
| 1443 |
+
f"renorm={'on' if pin_renorm_on else 'off'} "
|
| 1444 |
+
f"noise={pin_noise_v:.3f}", flush=True)
|
| 1445 |
+
tone_mode = str(tone_compensate)
|
| 1446 |
+
tone_on = tone_mode != "off" and tone_mode in _tone.MODES
|
| 1447 |
+
if tone_on:
|
| 1448 |
+
print(f"[{TAG}] tone compensation: {tone_mode} "
|
| 1449 |
+
f"(overlap {overlap_n}f)", flush=True)
|
| 1450 |
+
if str(cache_hops) == "on":
|
| 1451 |
+
import folder_paths
|
| 1452 |
+
hop_store = _store.HopStore(
|
| 1453 |
+
os.path.join(folder_paths.get_temp_directory(), "h3_ref_chain_hops"),
|
| 1454 |
+
budget_gb=float(cache_budget_gb), fps=FPS)
|
| 1455 |
+
print(f"[{TAG}] hop cache: {hop_store.root} "
|
| 1456 |
+
f"(budget {float(cache_budget_gb):.0f} GB)", flush=True)
|
| 1457 |
+
# Everything constant across the chain, mixed into every hop key so a
|
| 1458 |
+
# resolution or sampler change invalidates the whole cache.
|
| 1459 |
+
chain_salt = {
|
| 1460 |
+
"w": int(width), "h": int(height), "overlap": overlap_n,
|
| 1461 |
+
"sampler": str(sampler_name), "scheduler": str(scheduler),
|
| 1462 |
+
"shift_v": float(shift_video), "shift_a": float(shift_audio),
|
| 1463 |
+
"ref_size": str(ref_image_size), "pin": str(pin_to_qwen),
|
| 1464 |
+
# No "pin_mech" here: the mechanism is decided per hop at runtime
|
| 1465 |
+
# in _pin_continue (Motion-Context when a sampler latent exists,
|
| 1466 |
+
# AddGuide pixels otherwise), so it belongs in the per-hop key
|
| 1467 |
+
# below, not in the chain-wide salt.
|
| 1468 |
+
"refs": {s: _store.tensor_digest(t)
|
| 1469 |
+
for s, t in sorted(slot_images.items())},
|
| 1470 |
+
"voice": _store.audio_digest(voice),
|
| 1471 |
+
"refvid": _store.tensor_digest(reference_video),
|
| 1472 |
+
"start": _store.tensor_digest(start_image),
|
| 1473 |
+
# A cached hop rendered under different LoRAs or a different
|
| 1474 |
+
# attention path is not the same hop, so what has been patched onto
|
| 1475 |
+
# the incoming model is part of the key. See _model_fingerprint.
|
| 1476 |
+
"model": model_fp,
|
| 1477 |
+
}
|
| 1478 |
+
prev_key = None
|
| 1479 |
+
hop_keys = []
|
| 1480 |
+
|
| 1481 |
+
assembled = []
|
| 1482 |
+
for i, block in enumerate(blocks):
|
| 1483 |
+
mm.throw_exception_if_processing_interrupted()
|
| 1484 |
+
shot = shots[i] or {}
|
| 1485 |
+
hop_length = lengths[i]
|
| 1486 |
+
print(f"[{TAG}] hop {i + 1}/{n}...", flush=True)
|
| 1487 |
+
_push_preview(unique_id, f"hop {i + 1}/{n} sampling…", hop=i + 1, total=n,
|
| 1488 |
+
frac=(write_pos / float(total_frames)) if total_frames else None)
|
| 1489 |
+
|
| 1490 |
+
# With a register, this hop's refs are only the ones active on it,
|
| 1491 |
+
# re-packed in slot order. The image dict and the ordinals the prompt
|
| 1492 |
+
# cites are built from the same list, so they cannot drift apart.
|
| 1493 |
+
hop_active = []
|
| 1494 |
+
hop_subject_prose = ""
|
| 1495 |
+
if ref_plan_refs:
|
| 1496 |
+
hop_active = _refs.active_refs(ref_plan_refs, i, set(slot_images))
|
| 1497 |
+
# Continuation: omit shots[] = hop 1 only. Face/outfit plates
|
| 1498 |
+
# of a different room (chain_00034) beat the pin as Pictures 1–3
|
| 1499 |
+
# and hop 2 opened a new Ref2VA generate — commercial kitchen,
|
| 1500 |
+
# apron gone. List hop numbers on a ref to ride later hops.
|
| 1501 |
+
if i > 0 and str(hop_script) == "next":
|
| 1502 |
+
dropped = [r["tag"] for r in hop_active if r.get("shots") is None]
|
| 1503 |
+
hop_active = [r for r in hop_active if r.get("shots") is not None]
|
| 1504 |
+
if dropped:
|
| 1505 |
+
print(
|
| 1506 |
+
f"[{TAG}] hop {i + 1}: unscheduled stills stay off "
|
| 1507 |
+
f"this continue ({', '.join('@' + t for t in dropped)}); "
|
| 1508 |
+
f"pin carries wardrobe and room",
|
| 1509 |
+
flush=True,
|
| 1510 |
+
)
|
| 1511 |
+
base_images = {
|
| 1512 |
+
f"ref_image_{k + 1}": slot_images[r["slot"]]
|
| 1513 |
+
for k, r in enumerate(hop_active)
|
| 1514 |
+
} or None
|
| 1515 |
+
else:
|
| 1516 |
+
base_images = ref_images
|
| 1517 |
+
if i > 0 and str(hop_script) == "next":
|
| 1518 |
+
print(
|
| 1519 |
+
f"[{TAG}] hop {i + 1}: identity stills stay off this "
|
| 1520 |
+
"continue (no shots[] schedule); pin carries wardrobe "
|
| 1521 |
+
"and room",
|
| 1522 |
+
flush=True,
|
| 1523 |
+
)
|
| 1524 |
+
base_images = None
|
| 1525 |
+
|
| 1526 |
+
hop_images = base_images
|
| 1527 |
+
hop_videos = base_videos
|
| 1528 |
+
live_p = live_v = None
|
| 1529 |
+
still_shift = 0
|
| 1530 |
+
if i > 0:
|
| 1531 |
+
hop_images, live_p, hop_videos, live_v = _attach_pin_to_qwen(
|
| 1532 |
+
str(pin_to_qwen), base_images, base_videos,
|
| 1533 |
+
prev_imgs[-1:], prev_imgs[-overlap_n:],
|
| 1534 |
+
)
|
| 1535 |
+
if live_p == 1:
|
| 1536 |
+
still_shift = 1
|
| 1537 |
+
if not hop_images:
|
| 1538 |
+
hop_images = None
|
| 1539 |
+
if not hop_videos:
|
| 1540 |
+
hop_videos = None
|
| 1541 |
+
|
| 1542 |
+
# One ordinal map for this hop. The prompt's <Picture N> citations
|
| 1543 |
+
# and the identity lock's ordinals have to agree, and computing the
|
| 1544 |
+
# same shift twice is exactly how they drift apart. `p`, not `n` --
|
| 1545 |
+
# `n` is the hop count in the enclosing scope.
|
| 1546 |
+
hop_ords = _refs.ordinals(hop_active)
|
| 1547 |
+
if still_shift:
|
| 1548 |
+
hop_ords = {t: p + still_shift for t, p in hop_ords.items()}
|
| 1549 |
+
if ref_plan_refs:
|
| 1550 |
+
# Plan-wide subjects so @hero_face still resolves when that
|
| 1551 |
+
# photograph is off this hop.
|
| 1552 |
+
# Names, not ordinals, from hop 2 on. `subject_definitions:`
|
| 1553 |
+
# is hop-1 material, so a `<Subject N>` reaching hop 4 has no
|
| 1554 |
+
# antecedent in its own encode -- the dangling-token defect
|
| 1555 |
+
# that turned an undescribed "the bowl" into a steel one.
|
| 1556 |
+
# `continuity_line` rides every continuation hop and is what
|
| 1557 |
+
# the name binds to.
|
| 1558 |
+
block = _refs.resolve_tags(
|
| 1559 |
+
block, hop_ords, _refs.subjects(ref_plan_refs),
|
| 1560 |
+
where=f"shot {i + 1}",
|
| 1561 |
+
declared={r["tag"] for r in ref_plan_refs},
|
| 1562 |
+
subject_names=({k: (v or {}).get("name")
|
| 1563 |
+
for k, v in (ref_subjects or {}).items()}
|
| 1564 |
+
if i > 0 else None))
|
| 1565 |
+
if i == 0:
|
| 1566 |
+
# Hop 1 only: subject_prose derives its own ordinals with no
|
| 1567 |
+
# still_shift, correct only because hop 1 has no pin.
|
| 1568 |
+
# Computing it on hop 2+ invites an off-by-one against the
|
| 1569 |
+
# live frame.
|
| 1570 |
+
hop_subject_prose = _refs.subject_prose(hop_active, ref_subjects)
|
| 1571 |
+
if i == 0 and hop_subject_prose and not _d.is_full_h3_prompt(block):
|
| 1572 |
+
block = hop_subject_prose + "\n\n" + block
|
| 1573 |
+
if str(hop_script) == "next" and i > 0:
|
| 1574 |
+
n_stills = len(base_images or {})
|
| 1575 |
+
hop_state_header = _state_header(state, i)
|
| 1576 |
+
# With a register wired, only the subject-bearing refs are
|
| 1577 |
+
# identities; a setting or prop plate must not be declared one.
|
| 1578 |
+
id_ords = None
|
| 1579 |
+
n_subj = None
|
| 1580 |
+
if hop_active:
|
| 1581 |
+
id_ords = [hop_ords[r["tag"]] for r in hop_active
|
| 1582 |
+
if r["subject"] is not None]
|
| 1583 |
+
# Counted over this hop, not the whole plan. Counting
|
| 1584 |
+
# plan-wide while listing only this hop's ordinals is how a
|
| 1585 |
+
# single scheduled still produced "<Picture 2> are the only
|
| 1586 |
+
# identities" -- the plural that has rendered two people
|
| 1587 |
+
# from one reference.
|
| 1588 |
+
n_subj = len({r["subject"] for r in hop_active
|
| 1589 |
+
if r["subject"] is not None}) or None
|
| 1590 |
+
elif ref_plan_refs:
|
| 1591 |
+
# Pin-only hop: no identity to lock. _identity_lock returns
|
| 1592 |
+
# "" on an empty ordinal list whatever the count says.
|
| 1593 |
+
id_ords = []
|
| 1594 |
+
# Plan-wide, not per-hop: a character in the chain is in
|
| 1595 |
+
# the chain whether or not their photograph rides this hop.
|
| 1596 |
+
# This is the text that has to survive hop 5 of the showcase,
|
| 1597 |
+
# which schedules no references at all.
|
| 1598 |
+
hop_continuity = _refs.continuity_line(
|
| 1599 |
+
ref_subjects,
|
| 1600 |
+
{r["subject"] for r in ref_plan_refs
|
| 1601 |
+
if r["subject"] is not None}) if ref_plan_refs else ""
|
| 1602 |
+
block = _assemble_next(
|
| 1603 |
+
block,
|
| 1604 |
+
live_picture=live_p,
|
| 1605 |
+
live_video=live_v,
|
| 1606 |
+
n_stills=n_stills,
|
| 1607 |
+
state_header=hop_state_header,
|
| 1608 |
+
identity_ordinals=id_ords,
|
| 1609 |
+
n_subjects=n_subj,
|
| 1610 |
+
tail=(shot.get("directives") or {}).get("tail"),
|
| 1611 |
+
continuity=hop_continuity,
|
| 1612 |
+
)
|
| 1613 |
+
print(f"[{TAG}] hop {i + 1} next-beat assembled "
|
| 1614 |
+
f"(Picture {live_p}, Video {live_v}, "
|
| 1615 |
+
f"{n_stills} identity stills, "
|
| 1616 |
+
f"state_header {len(hop_state_header)} chars, "
|
| 1617 |
+
f"continuity {len(hop_continuity)} chars)", flush=True)
|
| 1618 |
+
|
| 1619 |
+
assembled.append((i + 1, block))
|
| 1620 |
+
|
| 1621 |
+
# Key this hop. prev_key makes the key chained, so editing shot 1
|
| 1622 |
+
# invalidates every hop after it -- correct, and the reason the UI
|
| 1623 |
+
# must show staleness before queuing or it reads as a bug.
|
| 1624 |
+
hop_key = None
|
| 1625 |
+
cached = None
|
| 1626 |
+
pin_mech_pred = _pin_mech_for(i, overlap_n, prev_sampled)
|
| 1627 |
+
pin_mech_used = pin_mech_pred
|
| 1628 |
+
if hop_store is not None:
|
| 1629 |
+
hop_key = _store.hop_key(prev_key, {
|
| 1630 |
+
"chain": chain_salt,
|
| 1631 |
+
"block": block,
|
| 1632 |
+
"len": hop_length,
|
| 1633 |
+
"steps": int(shot.get("steps") or steps),
|
| 1634 |
+
"seed": (int(shot["seed"]) if shot.get("seed") is not None
|
| 1635 |
+
else ((int(seed) + i) if seed_per_shot else int(seed))),
|
| 1636 |
+
"tags": [r["tag"] for r in hop_active],
|
| 1637 |
+
# Per hop, not chain-wide: hop 2 after a hop-1 cache hit
|
| 1638 |
+
# has no sampler latent and falls back to AddGuide, which
|
| 1639 |
+
# is a different render of the same inputs.
|
| 1640 |
+
"pin_mech": pin_mech_pred,
|
| 1641 |
+
"pin_cond": (pin_renorm_on, round(pin_noise_v, 4), audio_ctx),
|
| 1642 |
+
})
|
| 1643 |
+
# A locked shot reuses its last render even though its inputs
|
| 1644 |
+
# changed -- that is the point of locking. The content key would
|
| 1645 |
+
# have moved, so the pointer is what finds it.
|
| 1646 |
+
shot_name = str(shot.get("id") or f"shot{i + 1}")
|
| 1647 |
+
if shot.get("locked"):
|
| 1648 |
+
pinned = hop_store.get_pointer(shot_name)
|
| 1649 |
+
if pinned:
|
| 1650 |
+
if pinned != hop_key:
|
| 1651 |
+
print(f"[{TAG}] hop {i + 1} is locked: reusing its "
|
| 1652 |
+
f"earlier render (inputs changed)", flush=True)
|
| 1653 |
+
hop_key = pinned
|
| 1654 |
+
else:
|
| 1655 |
+
print(f"[{TAG}] hop {i + 1} is locked but has no cached "
|
| 1656 |
+
f"render yet; rendering it once", flush=True)
|
| 1657 |
+
hop_keys.append(hop_key)
|
| 1658 |
+
cached = hop_store.get(hop_key)
|
| 1659 |
+
|
| 1660 |
+
this_sampled = None
|
| 1661 |
+
if cached is not None:
|
| 1662 |
+
imgs, wav, sr, cached_latent = cached
|
| 1663 |
+
audio = {"waveform": wav, "sample_rate": sr}
|
| 1664 |
+
# Carry the stored sampler latent forward exactly as a render
|
| 1665 |
+
# would. Without this the next hop sees no latent, predicts the
|
| 1666 |
+
# AddGuide fallback, and its key stops matching what is on disk
|
| 1667 |
+
# -- so nothing past hop 1 could ever hit, and the hop after a
|
| 1668 |
+
# hit was joined by the weaker mechanism.
|
| 1669 |
+
this_sampled = cached_latent
|
| 1670 |
+
print(f"[{TAG}] hop {i + 1}: loaded from cache "
|
| 1671 |
+
f"({int(imgs.shape[0])}f, key {hop_key[:8]}"
|
| 1672 |
+
f"{'' if cached_latent is not None else ', no latent'})",
|
| 1673 |
+
flush=True)
|
| 1674 |
+
else:
|
| 1675 |
+
packed = MiniMaxH3ReferenceToVideo.execute(
|
| 1676 |
+
clip, vae, audio_vae, block, int(width), int(height), hop_length,
|
| 1677 |
+
ref_image_size=ref_image_size,
|
| 1678 |
+
ref_images=hop_images,
|
| 1679 |
+
ref_videos=hop_videos,
|
| 1680 |
+
ref_audios=ref_audios,
|
| 1681 |
+
)
|
| 1682 |
+
cond, latent = _result(packed)[0], _result(packed)[1]
|
| 1683 |
+
|
| 1684 |
+
if i == 0 and start_image is not None:
|
| 1685 |
+
cond = _result(MiniMaxH3AddGuide.execute(
|
| 1686 |
+
cond, latent, 0, vae=vae, audio_vae=None,
|
| 1687 |
+
image=start_image[:1], audio=None,
|
| 1688 |
+
))[0]
|
| 1689 |
+
elif i > 0:
|
| 1690 |
+
pin_latent = prev_sampled
|
| 1691 |
+
if pin_latent is not None:
|
| 1692 |
+
pin_latent, pin_anchor_std = _condition_pin_latent(
|
| 1693 |
+
pin_latent, pin_anchor_std,
|
| 1694 |
+
renorm=pin_renorm_on, noise=pin_noise_v,
|
| 1695 |
+
seed=(int(seed) + i))
|
| 1696 |
+
cond, pin_mech_used = _pin_continue(
|
| 1697 |
+
cond, latent, vae, audio_vae, overlap_n,
|
| 1698 |
+
pin_latent, prev_imgs, prev_audio,
|
| 1699 |
+
audio_ctx=audio_ctx,
|
| 1700 |
+
)
|
| 1701 |
+
|
| 1702 |
+
_offload_text_encoder(clip, model)
|
| 1703 |
+
|
| 1704 |
+
guider = _result(BasicGuider.execute(model, cond))[0]
|
| 1705 |
+
if shot.get("seed") is not None:
|
| 1706 |
+
shot_seed = int(shot["seed"])
|
| 1707 |
+
else:
|
| 1708 |
+
shot_seed = (int(seed) + i) if seed_per_shot else int(seed)
|
| 1709 |
+
hop_steps = int(shot.get("steps") or steps)
|
| 1710 |
+
if hop_steps not in sigma_cache:
|
| 1711 |
+
sigma_cache[hop_steps] = _result(
|
| 1712 |
+
BasicScheduler.execute(model, scheduler, hop_steps, 1.0))[0]
|
| 1713 |
+
hop_sigmas = sigma_cache[hop_steps]
|
| 1714 |
+
if hop_steps != int(steps) or shot.get("seed") is not None:
|
| 1715 |
+
print(f"[{TAG}] hop {i + 1} override: seed={shot_seed} "
|
| 1716 |
+
f"steps={hop_steps}", flush=True)
|
| 1717 |
+
noise = _result(RandomNoise.execute(shot_seed))[0]
|
| 1718 |
+
sampled = _result(SamplerCustomAdvanced.execute(
|
| 1719 |
+
noise, guider, sampler, hop_sigmas, latent
|
| 1720 |
+
))[0]
|
| 1721 |
+
|
| 1722 |
+
imgs, audio = _decode_av(vae, audio_vae, sampled)
|
| 1723 |
+
imgs = imgs.contiguous().cpu()
|
| 1724 |
+
wav = audio["waveform"].contiguous().cpu()
|
| 1725 |
+
sr = int(audio["sample_rate"])
|
| 1726 |
+
audio = {"waveform": wav, "sample_rate": sr}
|
| 1727 |
+
this_sampled = _latent_cpu(sampled)
|
| 1728 |
+
|
| 1729 |
+
del sampled, latent, cond, guider, noise
|
| 1730 |
+
mm.soft_empty_cache()
|
| 1731 |
+
|
| 1732 |
+
if (hop_store is not None and hop_key is not None
|
| 1733 |
+
and pin_mech_used != pin_mech_pred):
|
| 1734 |
+
print(f"[{TAG}] hop {i + 1} pinned by {pin_mech_used} but its "
|
| 1735 |
+
f"cache key says {pin_mech_pred}; not caching this hop",
|
| 1736 |
+
flush=True)
|
| 1737 |
+
elif hop_store is not None and hop_key is not None:
|
| 1738 |
+
hop_store.put(hop_key, imgs, wav, sr,
|
| 1739 |
+
{"hop": i + 1, "of": n, "block": block[:400],
|
| 1740 |
+
"pin_mech": pin_mech_used},
|
| 1741 |
+
latent=this_sampled)
|
| 1742 |
+
hop_store.set_pointer(
|
| 1743 |
+
str(shot.get("id") or f"shot{i + 1}"), hop_key)
|
| 1744 |
+
|
| 1745 |
+
# Tone compensation, at the one point both the render and the
|
| 1746 |
+
# cache-hit paths have converged.
|
| 1747 |
+
#
|
| 1748 |
+
# It sits AFTER hop_store.put on purpose, so the cache holds raw
|
| 1749 |
+
# hops and the mode stays out of the hop key -- switching modes
|
| 1750 |
+
# then costs nothing instead of invalidating ~285 MB an entry.
|
| 1751 |
+
# It sits BEFORE the master write and before `prev_imgs` is taken,
|
| 1752 |
+
# which is the half that matters: `prev_imgs` is what feeds the next
|
| 1753 |
+
# hop's Qwen <Picture 1> pin and the AddGuide guide image, so
|
| 1754 |
+
# correcting here is what stops the drift compounding rather than
|
| 1755 |
+
# merely repainting the master. Each hop is measured against the
|
| 1756 |
+
# previous hop's ALREADY CORRECTED tail, so the whole chain lands on
|
| 1757 |
+
# hop 1's tone.
|
| 1758 |
+
tone_note = ""
|
| 1759 |
+
if tone_on:
|
| 1760 |
+
if i > 0 and prev_imgs is not None:
|
| 1761 |
+
imgs, tone_note = _tone.compensate(
|
| 1762 |
+
prev_imgs, imgs, tone_mode, overlap_n)
|
| 1763 |
+
else:
|
| 1764 |
+
# Hop 1 has nothing to match against, but the corrected hops
|
| 1765 |
+
# come back clamped and an unclamped hop 1 beside them would
|
| 1766 |
+
# make the master inconsistent with itself.
|
| 1767 |
+
imgs = imgs.clamp(0.0, 1.0)
|
| 1768 |
+
if tone_note:
|
| 1769 |
+
print(f"[{TAG}] hop {i + 1} tone: {tone_note}", flush=True)
|
| 1770 |
+
|
| 1771 |
+
if i == 0:
|
| 1772 |
+
master_imgs[0:imgs.shape[0]] = imgs
|
| 1773 |
+
write_pos = int(imgs.shape[0])
|
| 1774 |
+
master_wav = wav
|
| 1775 |
+
else:
|
| 1776 |
+
if imgs.shape[0] <= overlap_n:
|
| 1777 |
+
raise ValueError(
|
| 1778 |
+
f"{TAG}: hop {i + 1} decoded {int(imgs.shape[0])} frames; "
|
| 1779 |
+
f"need more than overlap {overlap_n}"
|
| 1780 |
+
)
|
| 1781 |
+
keep_n = int(imgs.shape[0]) - overlap_n
|
| 1782 |
+
if write_pos + keep_n > total_frames:
|
| 1783 |
+
raise ValueError(
|
| 1784 |
+
f"{TAG}: hop {i + 1} overruns the preallocated master "
|
| 1785 |
+
f"({write_pos + keep_n} > {total_frames}). A hop decoded a "
|
| 1786 |
+
f"different length than planned.")
|
| 1787 |
+
master_imgs[write_pos:write_pos + keep_n] = imgs[overlap_n:]
|
| 1788 |
+
write_pos += keep_n
|
| 1789 |
+
trimmed, dropped = _trim_audio_head(audio, overlap_n)
|
| 1790 |
+
master_wav = _xfade_audio(master_wav, trimmed["waveform"], sr)
|
| 1791 |
+
print(
|
| 1792 |
+
f"[{TAG}] hop {i + 1}: dropped {overlap_n} frames / {dropped} audio samples",
|
| 1793 |
+
flush=True,
|
| 1794 |
+
)
|
| 1795 |
+
del trimmed
|
| 1796 |
+
|
| 1797 |
+
# The join, as two pictures: the previous hop's last delivered frame
|
| 1798 |
+
# and this hop's first. Sent together so the panel can show the
|
| 1799 |
+
# actual seam rather than one frame per hop.
|
| 1800 |
+
seam_frame = imgs[0] if i > 0 else None
|
| 1801 |
+
# Overlap tail for Qwen; full sampler latent for Motion-Context.
|
| 1802 |
+
tail_n = overlap_n if overlap_n else 1
|
| 1803 |
+
prev_imgs = imgs[-tail_n:].clone()
|
| 1804 |
+
prev_audio = {"waveform": _tail_audio(audio, overlap_n)["waveform"].clone(),
|
| 1805 |
+
"sample_rate": sr}
|
| 1806 |
+
prev_sampled = this_sampled
|
| 1807 |
+
prev_key = hop_key
|
| 1808 |
+
_push_preview(
|
| 1809 |
+
unique_id, f"hop {i + 1}/{n} done",
|
| 1810 |
+
frame=prev_imgs[-1], hop=i + 1, total=n,
|
| 1811 |
+
pin_mech=(pin_mech_used if i > 0 else None),
|
| 1812 |
+
frac=(write_pos / float(total_frames) if total_frames else None),
|
| 1813 |
+
seam_frame=seam_frame,
|
| 1814 |
+
meta={"cached": cached is not None,
|
| 1815 |
+
"key": (hop_key[:8] if hop_key else None),
|
| 1816 |
+
"frames": int(write_pos), "of_frames": int(total_frames),
|
| 1817 |
+
"seed": int(shot_seed) if cached is None else None,
|
| 1818 |
+
"steps": int(hop_steps) if cached is None else None,
|
| 1819 |
+
"tone": tone_note or None})
|
| 1820 |
+
del imgs, wav, audio
|
| 1821 |
+
pbar.update(1)
|
| 1822 |
+
|
| 1823 |
+
if write_pos != total_frames:
|
| 1824 |
+
print(f"[{TAG}] note: wrote {write_pos} of {total_frames} planned "
|
| 1825 |
+
f"frames; trimming", flush=True)
|
| 1826 |
+
master_imgs = master_imgs[:write_pos]
|
| 1827 |
+
if hop_store is not None:
|
| 1828 |
+
hop_store.sweep(keep=hop_keys)
|
| 1829 |
+
# lengths[0], not `length`: when every shot overrides duration to the
|
| 1830 |
+
# same value the set is still size 1, but `length` is the chain default.
|
| 1831 |
+
span = str(lengths[0]) if len(set(lengths)) == 1 else "/".join(str(v) for v in lengths)
|
| 1832 |
+
info = (
|
| 1833 |
+
f"{n} hops x {span}f overlap {overlap_n} -> "
|
| 1834 |
+
f"{int(master_imgs.shape[0])} frames ({master_imgs.shape[0] / FPS:.1f}s) "
|
| 1835 |
+
f"{int(master_imgs.shape[2])}x{int(master_imgs.shape[1])}"
|
| 1836 |
+
)
|
| 1837 |
+
print(f"[{TAG}] {info}", flush=True)
|
| 1838 |
+
# The assembled prompts being inspectable is the whole point of the
|
| 1839 |
+
# directive layer -- wire `info` to a Preview Text node to read exactly
|
| 1840 |
+
# what each hop sent to the text encoder.
|
| 1841 |
+
_sep = '\n\n'
|
| 1842 |
+
info = info + _sep + _sep.join('===== hop %d prompt =====\n%s' % (k, t) for k, t in assembled)
|
| 1843 |
+
# `info` is the third return (Preview Text). Never send it as the
|
| 1844 |
+
# preview status — that strip is ~22 px and will render the dump.
|
| 1845 |
+
_v_secs = float(master_imgs.shape[0]) / FPS
|
| 1846 |
+
_a_secs = float(master_wav.shape[-1]) / float(sr) if sr else 0.0
|
| 1847 |
+
_push_preview(
|
| 1848 |
+
unique_id,
|
| 1849 |
+
f"done · {int(master_imgs.shape[0])}f · {_v_secs:.1f}s",
|
| 1850 |
+
frame=master_imgs[-1], hop=n, total=n, frac=1.0,
|
| 1851 |
+
meta={"video_s": round(_v_secs, 3), "audio_s": round(_a_secs, 3),
|
| 1852 |
+
"drift_ms": round((_a_secs - _v_secs) * 1000.0, 1),
|
| 1853 |
+
"hops": int(n), "frames": int(master_imgs.shape[0]),
|
| 1854 |
+
"done": True})
|
| 1855 |
+
master_audio = {"waveform": master_wav, "sample_rate": sr}
|
| 1856 |
+
return (master_imgs, master_audio, info)
|
| 1857 |
+
|
| 1858 |
+
|
| 1859 |
+
class HTCContinuityState:
|
| 1860 |
+
"""Author locked/context/mutable *setting* text once; HandTieClips consumes it per hop.
|
| 1861 |
+
|
| 1862 |
+
locked and context ride every hop 2+ unchanged. mutable is --- delimited like the
|
| 1863 |
+
prompt field: one beat per hop, padded by repeating the last block if there are
|
| 1864 |
+
fewer blocks than hops.
|
| 1865 |
+
|
| 1866 |
+
Setting only. Characters live in `ref_plan`'s reference register, which is
|
| 1867 |
+
the only thing that knows a photograph is a face rather than a room. This
|
| 1868 |
+
node used to carry `characters_*` as well, so filling in both it and the
|
| 1869 |
+
register injected identity prose twice into every hop 2+ -- run() warned
|
| 1870 |
+
about that collision rather than preventing it. With the character half
|
| 1871 |
+
gone the collision is structurally impossible.
|
| 1872 |
+
"""
|
| 1873 |
+
|
| 1874 |
+
@classmethod
|
| 1875 |
+
def INPUT_TYPES(cls):
|
| 1876 |
+
return {
|
| 1877 |
+
"required": {},
|
| 1878 |
+
"optional": {
|
| 1879 |
+
"setting_locked": ("STRING", {
|
| 1880 |
+
"multiline": True, "default": "",
|
| 1881 |
+
"tooltip": "Verbatim setting text (location, lighting). Injected unchanged into every hop 2+.",
|
| 1882 |
+
}),
|
| 1883 |
+
"setting_context": ("STRING", {
|
| 1884 |
+
"multiline": True, "default": "",
|
| 1885 |
+
"tooltip": "Current-state setting text, less rigid than locked. Injected every hop 2+.",
|
| 1886 |
+
}),
|
| 1887 |
+
"setting_mutable": ("STRING", {
|
| 1888 |
+
"multiline": True, "default": "",
|
| 1889 |
+
"tooltip": "Per-hop setting beat text, --- delimited like characters_mutable.",
|
| 1890 |
+
}),
|
| 1891 |
+
},
|
| 1892 |
+
}
|
| 1893 |
+
|
| 1894 |
+
RETURN_TYPES = ("STRING",)
|
| 1895 |
+
RETURN_NAMES = ("continuity_state",)
|
| 1896 |
+
FUNCTION = "run"
|
| 1897 |
+
CATEGORY = "Hand Tie Clips"
|
| 1898 |
+
DESCRIPTION = (
|
| 1899 |
+
"Builds a JSON continuity-state blob (locked/context/mutable) for the "
|
| 1900 |
+
"*setting* only, feeding HandTieClips's continuity_state input. "
|
| 1901 |
+
"hop_script=next only. Characters belong in ref_plan's register."
|
| 1902 |
+
)
|
| 1903 |
+
|
| 1904 |
+
def run(self, setting_locked="", setting_context="", setting_mutable=""):
|
| 1905 |
+
state = {
|
| 1906 |
+
"setting": {
|
| 1907 |
+
"locked": setting_locked.strip(),
|
| 1908 |
+
"context": setting_context.strip(),
|
| 1909 |
+
"mutable": _parse_shots(setting_mutable) if setting_mutable.strip() else [],
|
| 1910 |
+
},
|
| 1911 |
+
}
|
| 1912 |
+
return (json.dumps(state),)
|
| 1913 |
+
|
| 1914 |
+
|
| 1915 |
+
|
| 1916 |
+
|
| 1917 |
+
# -- pre-rename ids ----------------------------------------------------------
|
| 1918 |
+
# A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
|
| 1919 |
+
# lists the node a second time in search: ComfyUI falls back to the mapping key
|
| 1920 |
+
# when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
|
| 1921 |
+
# DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
|
| 1922 |
+
# frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
|
| 1923 |
+
# while leaving it fully functional in workflows that name it.
|
| 1924 |
+
|
| 1925 |
+
|
| 1926 |
+
class _LegacyH3RefChain(HandTieClips):
|
| 1927 |
+
DEPRECATED = True
|
| 1928 |
+
|
| 1929 |
+
|
| 1930 |
+
class _LegacyH3ContinuityState(HTCContinuityState):
|
| 1931 |
+
DEPRECATED = True
|
| 1932 |
+
|
| 1933 |
+
|
| 1934 |
+
NODE_CLASS_MAPPINGS = {
|
| 1935 |
+
"HandTieClips": HandTieClips,
|
| 1936 |
+
"HTCContinuityState": HTCContinuityState,
|
| 1937 |
+
"H3RefChain": _LegacyH3RefChain,
|
| 1938 |
+
"H3ContinuityState": _LegacyH3ContinuityState,
|
| 1939 |
+
}
|
| 1940 |
+
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 1941 |
+
"HandTieClips": "H3 Ref2VA Chain",
|
| 1942 |
+
"HTCContinuityState": "H3 Continuity State",
|
| 1943 |
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Files in, without an IMAGE socket.
|
| 3 |
+
*
|
| 4 |
+
* A reference used to be a wire: nine `ref_image_N` sockets, nine `Load Image`
|
| 5 |
+
* nodes, and a sixteen-socket column eating a third of the node. A reference is
|
| 6 |
+
* now a *filename* under `<ComfyUI input>/h3_refs`, and this is the control that
|
| 7 |
+
* puts one there.
|
| 8 |
+
*
|
| 9 |
+
* Two rules, both learned by PromptMasterLD the expensive way:
|
| 10 |
+
*
|
| 11 |
+
* 1. **Pixels never enter a widget.** Only the basename is stored. Nine
|
| 12 |
+
* base64 thumbnails measured 1.68 MB of widget value and ComfyUI then
|
| 13 |
+
* failed to save the workflow at all. Previews are URLs, which cost
|
| 14 |
+
* nothing to rebuild and survive a reload.
|
| 15 |
+
* 2. **Reads go through ComfyUI's own `/view`.** It already serves the input
|
| 16 |
+
* directory with a guessed MIME type and Range support, which a `<video>`
|
| 17 |
+
* needs in order to seek. A second thumbnail route would buy nothing.
|
| 18 |
+
*
|
| 19 |
+
* The upload is multipart rather than JSON+base64 so a batch of stills is not
|
| 20 |
+
* inflated by a third and held in memory twice on the way through the browser.
|
| 21 |
+
*/
|
| 22 |
+
|
| 23 |
+
import { el, button } from "./widget_utils.js";
|
| 24 |
+
|
| 25 |
+
const UPLOAD_URL = "/h3_ref_chain/upload";
|
| 26 |
+
const FILES_URL = "/h3_ref_chain/files";
|
| 27 |
+
const SUBDIR = "h3_refs";
|
| 28 |
+
|
| 29 |
+
/** Accept attributes, by what a control is for. */
|
| 30 |
+
export const ACCEPT = {
|
| 31 |
+
image: "image/*",
|
| 32 |
+
video: "video/*",
|
| 33 |
+
audio: "audio/*",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
/* -- the file list, cached and shared ------------------------------------ */
|
| 37 |
+
|
| 38 |
+
let _files = null;
|
| 39 |
+
let _pending = null;
|
| 40 |
+
const _listeners = new Set();
|
| 41 |
+
|
| 42 |
+
/** Everything currently in the reference folder. Cached; refresh() invalidates. */
|
| 43 |
+
export function files() {
|
| 44 |
+
if (_files) return Promise.resolve(_files);
|
| 45 |
+
if (!_pending) {
|
| 46 |
+
_pending = fetch(FILES_URL)
|
| 47 |
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
| 48 |
+
.then((j) => {
|
| 49 |
+
_files = Array.isArray(j.files) ? j.files : [];
|
| 50 |
+
return _files;
|
| 51 |
+
})
|
| 52 |
+
.catch((err) => {
|
| 53 |
+
console.error("[HandTieClips] reference list unavailable:", err);
|
| 54 |
+
_files = [];
|
| 55 |
+
return _files;
|
| 56 |
+
})
|
| 57 |
+
.finally(() => { _pending = null; });
|
| 58 |
+
}
|
| 59 |
+
return _pending;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/** Re-read the folder and tell every mounted picker. */
|
| 63 |
+
export function refresh() {
|
| 64 |
+
_files = null;
|
| 65 |
+
return files().then((list) => {
|
| 66 |
+
for (const fn of _listeners) {
|
| 67 |
+
try {
|
| 68 |
+
fn(list);
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.error("[HandTieClips] picker refresh failed:", err);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
return list;
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export function onFilesChanged(fn) {
|
| 78 |
+
_listeners.add(fn);
|
| 79 |
+
return () => _listeners.delete(fn);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* The URL a preview is drawn from.
|
| 84 |
+
*
|
| 85 |
+
* Cache-busted because a filename can be reused: upload `face.png` twice and
|
| 86 |
+
* the second one collision-suffixes, but an *overwritten* file keeps its name
|
| 87 |
+
* and the browser would happily show the old bytes.
|
| 88 |
+
*/
|
| 89 |
+
export function viewUrl(name) {
|
| 90 |
+
const q = `filename=${encodeURIComponent(name)}`
|
| 91 |
+
+ `&subfolder=${encodeURIComponent(SUBDIR)}&type=input&t=${Date.now()}`;
|
| 92 |
+
return `/view?${q}`;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/** Push files at the upload route. -> [{name, kind, width, height}] */
|
| 96 |
+
export async function upload(fileList) {
|
| 97 |
+
const list = [...(fileList || [])];
|
| 98 |
+
if (!list.length) return [];
|
| 99 |
+
const fd = new FormData();
|
| 100 |
+
for (const f of list) fd.append("files", f, f.name);
|
| 101 |
+
const r = await fetch(UPLOAD_URL, { method: "POST", body: fd });
|
| 102 |
+
const j = await r.json().catch(() => ({}));
|
| 103 |
+
if (!r.ok || !j.ok) throw new Error(j.error || `upload failed (HTTP ${r.status})`);
|
| 104 |
+
for (const note of j.skipped || []) console.warn("[HandTieClips] upload skipped:", note);
|
| 105 |
+
await refresh();
|
| 106 |
+
return j.files || [];
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* -- one picker ---------------------------------------------------------- */
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* A thumbnail that takes a drop, plus a list of what is already on disk.
|
| 113 |
+
*
|
| 114 |
+
* `get()` returns the current basename, `set(name)` stores a new one. The
|
| 115 |
+
* control owns no state of its own -- same rendering-layer contract as the rest
|
| 116 |
+
* of the editor, so undo and the JSON tab keep working.
|
| 117 |
+
*/
|
| 118 |
+
export function createPicker({ kind = "image", get, set, onChange, title } = {}) {
|
| 119 |
+
const root = el("div", "h3e-pick");
|
| 120 |
+
|
| 121 |
+
const thumb = el("div", "h3e-thumb");
|
| 122 |
+
thumb.title = title || "Drop a file here, or click to browse.";
|
| 123 |
+
root.appendChild(thumb);
|
| 124 |
+
|
| 125 |
+
const input = el("input");
|
| 126 |
+
input.type = "file";
|
| 127 |
+
input.accept = ACCEPT[kind] || "";
|
| 128 |
+
input.style.display = "none";
|
| 129 |
+
root.appendChild(input);
|
| 130 |
+
|
| 131 |
+
const picker = el("select", "h3e-select h3e-pickfile");
|
| 132 |
+
root.appendChild(picker);
|
| 133 |
+
|
| 134 |
+
let busy = false;
|
| 135 |
+
|
| 136 |
+
function paint() {
|
| 137 |
+
const name = String(get() || "");
|
| 138 |
+
thumb.textContent = "";
|
| 139 |
+
thumb.classList.toggle("h3e-has", Boolean(name));
|
| 140 |
+
if (busy) {
|
| 141 |
+
thumb.appendChild(el("span", "h3e-thumb-hint", "uploading…"));
|
| 142 |
+
} else if (!name) {
|
| 143 |
+
thumb.appendChild(el("span", "h3e-thumb-hint", "drop\nor click"));
|
| 144 |
+
} else if (kind === "image") {
|
| 145 |
+
const img = el("img", "h3e-thumb-img");
|
| 146 |
+
img.src = viewUrl(name);
|
| 147 |
+
img.alt = name;
|
| 148 |
+
// A file deleted from the folder behind our back must not leave a
|
| 149 |
+
// broken-image glyph with no explanation.
|
| 150 |
+
img.addEventListener("error", () => {
|
| 151 |
+
thumb.textContent = "";
|
| 152 |
+
thumb.appendChild(el("span", "h3e-thumb-hint h3e-thumb-bad", "missing"));
|
| 153 |
+
});
|
| 154 |
+
thumb.appendChild(img);
|
| 155 |
+
} else {
|
| 156 |
+
thumb.appendChild(el("span", "h3e-thumb-hint",
|
| 157 |
+
kind === "video" ? "▶ clip" : "♪ audio"));
|
| 158 |
+
}
|
| 159 |
+
if (name) {
|
| 160 |
+
thumb.title = `${name}\nClick to replace, or drop a new file.`;
|
| 161 |
+
const x = button("×", "Clear this picture", (e) => {
|
| 162 |
+
e.stopPropagation();
|
| 163 |
+
set("");
|
| 164 |
+
paint();
|
| 165 |
+
onChange?.();
|
| 166 |
+
}, "h3e-btn h3e-thumb-x");
|
| 167 |
+
thumb.appendChild(x);
|
| 168 |
+
} else {
|
| 169 |
+
thumb.title = title || "Drop a file here, or click to browse.";
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function paintList(list) {
|
| 174 |
+
const name = String(get() || "");
|
| 175 |
+
picker.textContent = "";
|
| 176 |
+
const blank = el("option", null, "— none —");
|
| 177 |
+
blank.value = "";
|
| 178 |
+
picker.appendChild(blank);
|
| 179 |
+
let found = false;
|
| 180 |
+
for (const f of list || []) {
|
| 181 |
+
if (f.kind !== kind) continue;
|
| 182 |
+
const o = el("option", null, f.name);
|
| 183 |
+
o.value = f.name;
|
| 184 |
+
if (f.name === name) found = true;
|
| 185 |
+
picker.appendChild(o);
|
| 186 |
+
}
|
| 187 |
+
// A name that is no longer on disk still has to be selectable, or
|
| 188 |
+
// switching away from it would look like the value was never set.
|
| 189 |
+
if (name && !found) {
|
| 190 |
+
const o = el("option", null, `${name} (missing)`);
|
| 191 |
+
o.value = name;
|
| 192 |
+
picker.appendChild(o);
|
| 193 |
+
}
|
| 194 |
+
picker.value = name;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
async function take(fileList) {
|
| 198 |
+
const list = [...(fileList || [])];
|
| 199 |
+
if (!list.length) return;
|
| 200 |
+
busy = true;
|
| 201 |
+
paint();
|
| 202 |
+
try {
|
| 203 |
+
const got = await upload(list.slice(0, 1));
|
| 204 |
+
if (got.length) {
|
| 205 |
+
set(got[0].name);
|
| 206 |
+
onChange?.();
|
| 207 |
+
}
|
| 208 |
+
} catch (err) {
|
| 209 |
+
console.error("[HandTieClips] upload failed:", err);
|
| 210 |
+
alert(`Upload failed: ${err.message || err}`);
|
| 211 |
+
} finally {
|
| 212 |
+
busy = false;
|
| 213 |
+
paint();
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
thumb.addEventListener("click", (e) => {
|
| 218 |
+
if (e.target.closest(".h3e-thumb-x")) return;
|
| 219 |
+
input.click();
|
| 220 |
+
});
|
| 221 |
+
input.addEventListener("change", () => {
|
| 222 |
+
take(input.files);
|
| 223 |
+
input.value = ""; // so re-picking the same file still fires
|
| 224 |
+
});
|
| 225 |
+
thumb.addEventListener("dragover", (e) => {
|
| 226 |
+
// Only light up for an actual file drag. Without this check, dragging a
|
| 227 |
+
// node across the canvas highlights every picker it passes over.
|
| 228 |
+
if (!e.dataTransfer?.types?.includes("Files")) return;
|
| 229 |
+
e.preventDefault();
|
| 230 |
+
e.stopPropagation();
|
| 231 |
+
thumb.classList.add("h3e-hot");
|
| 232 |
+
});
|
| 233 |
+
thumb.addEventListener("dragleave", () => thumb.classList.remove("h3e-hot"));
|
| 234 |
+
thumb.addEventListener("drop", (e) => {
|
| 235 |
+
if (!e.dataTransfer?.files?.length) return;
|
| 236 |
+
e.preventDefault();
|
| 237 |
+
e.stopPropagation();
|
| 238 |
+
thumb.classList.remove("h3e-hot");
|
| 239 |
+
take(e.dataTransfer.files);
|
| 240 |
+
});
|
| 241 |
+
picker.addEventListener("change", () => {
|
| 242 |
+
set(picker.value);
|
| 243 |
+
paint();
|
| 244 |
+
onChange?.();
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
const stop = onFilesChanged(paintList);
|
| 248 |
+
files().then(paintList);
|
| 249 |
+
paint();
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
root,
|
| 253 |
+
render() {
|
| 254 |
+
paint();
|
| 255 |
+
files().then(paintList);
|
| 256 |
+
},
|
| 257 |
+
destroy: stop,
|
| 258 |
+
};
|
| 259 |
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The three single media inputs: first frame, reference clip, voice.
|
| 3 |
+
*
|
| 4 |
+
* These were `start_image`, `reference_video` and `voice` IMAGE/AUDIO sockets.
|
| 5 |
+
* Together with the nine `ref_image_N` slots they were twelve of a sixteen
|
| 6 |
+
* socket column that occupied a third of the node before the editor started.
|
| 7 |
+
* They are filenames now, in `start_image_file` / `reference_video_file` /
|
| 8 |
+
* `voice_file`, and this strip is what sets them.
|
| 9 |
+
*
|
| 10 |
+
* Same rendering-layer contract as the rest of the editor: the widget is the
|
| 11 |
+
* store, this only draws it and writes back through `widget.callback`.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { el, widgetByName } from "./widget_utils.js";
|
| 15 |
+
import { createPicker } from "./media_picker.js";
|
| 16 |
+
|
| 17 |
+
/* Widget name -> what it is, in the order they are drawn. */
|
| 18 |
+
const SLOTS = [
|
| 19 |
+
["start_image_file", "image", "first frame",
|
| 20 |
+
"Pins hop 1's opening frame. Ignored on later hops -- they are pinned by the join."],
|
| 21 |
+
["reference_video_file", "video", "reference clip",
|
| 22 |
+
"A motion or look plate the whole chain reads. NOT the previous hop; the join handles that."],
|
| 23 |
+
["voice_file", "audio", "voice",
|
| 24 |
+
"Voice or timbre reference. Rides every hop as <Audio 1>."],
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
/** The widget names this strip owns, so the caller hides exactly those. */
|
| 28 |
+
export const MEDIA_WIDGETS = SLOTS.map(([name]) => name);
|
| 29 |
+
|
| 30 |
+
function commit(node, w, value) {
|
| 31 |
+
w.value = value;
|
| 32 |
+
try {
|
| 33 |
+
// Same reason as the run panel: setting `.value` alone leaves anything
|
| 34 |
+
// bound to this widget unaware that it changed.
|
| 35 |
+
w.callback?.(value, node.graph?.canvas, node, undefined, undefined);
|
| 36 |
+
} catch (err) {
|
| 37 |
+
console.error(`[HandTieClips] widget callback for ${w.name} failed:`, err);
|
| 38 |
+
}
|
| 39 |
+
node.graph?.setDirtyCanvas?.(true, true);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function createMediaStrip(node, { onChange } = {}) {
|
| 43 |
+
const root = el("div", "h3e-section h3e-media");
|
| 44 |
+
|
| 45 |
+
const head = el("div", "h3e-head");
|
| 46 |
+
head.appendChild(el("span", "h3e-title", "MEDIA"));
|
| 47 |
+
const count = el("span", "h3e-count");
|
| 48 |
+
head.appendChild(count);
|
| 49 |
+
root.appendChild(head);
|
| 50 |
+
|
| 51 |
+
const grid = el("div", "h3e-media-grid");
|
| 52 |
+
root.appendChild(grid);
|
| 53 |
+
|
| 54 |
+
const pickers = [];
|
| 55 |
+
let missing = 0;
|
| 56 |
+
|
| 57 |
+
for (const [name, kind, label, tip] of SLOTS) {
|
| 58 |
+
const w = widgetByName(node, name);
|
| 59 |
+
if (!w) {
|
| 60 |
+
// A widget this build does not define is skipped, not warned about,
|
| 61 |
+
// so the strip survives an older Python side. Same rule as the run
|
| 62 |
+
// panel's group list.
|
| 63 |
+
missing += 1;
|
| 64 |
+
continue;
|
| 65 |
+
}
|
| 66 |
+
const cell = el("label", "h3e-media-cell");
|
| 67 |
+
cell.appendChild(el("span", "h3e-media-label", label));
|
| 68 |
+
const pick = createPicker({
|
| 69 |
+
kind,
|
| 70 |
+
get: () => String(w.value || ""),
|
| 71 |
+
set: (v) => commit(node, w, v),
|
| 72 |
+
onChange,
|
| 73 |
+
title: tip,
|
| 74 |
+
});
|
| 75 |
+
cell.title = tip;
|
| 76 |
+
cell.appendChild(pick.root);
|
| 77 |
+
grid.appendChild(cell);
|
| 78 |
+
pickers.push(pick);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
if (missing === SLOTS.length) {
|
| 82 |
+
grid.appendChild(el("div", "h3e-note h3e-note-error",
|
| 83 |
+
"No media widgets found — restart ComfyUI so the Python side matches."));
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function render() {
|
| 87 |
+
let set = 0;
|
| 88 |
+
for (const [name] of SLOTS) {
|
| 89 |
+
if (String(widgetByName(node, name)?.value || "")) set += 1;
|
| 90 |
+
}
|
| 91 |
+
count.textContent = set ? `${set} set` : "none set";
|
| 92 |
+
for (const p of pickers) p.render();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
render();
|
| 96 |
+
|
| 97 |
+
return { root, render, ownedNames: () => MEDIA_WIDGETS.slice() };
|
| 98 |
+
}
|
|
@@ -0,0 +1,580 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The shot plan editor.
|
| 3 |
+
*
|
| 4 |
+
* `shot_plan` (a hidden STRING widget) stays the ONLY source of truth. Every
|
| 5 |
+
* control here reads from it and writes straight back, so a workflow authored
|
| 6 |
+
* with the editor and one typed by hand are the same file, and anything the
|
| 7 |
+
* editor cannot express is still reachable through the JSON tab.
|
| 8 |
+
*
|
| 9 |
+
* Structure follows ComfyUI-MiniMaxH3-Contex-Loop's h3_chain_plan_editor.js,
|
| 10 |
+
* which solved this shape already. What is ours: the directive axes come from
|
| 11 |
+
* the server (see routes.py) rather than a second copy, the coherence rule is
|
| 12 |
+
* mirrored from plan.check_coherence, and the timing summary uses the real
|
| 13 |
+
* overlap arithmetic instead of a nominal per-shot duration.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import {
|
| 17 |
+
el, button, select, vocab, widgetByName, refreshVocab, onVocabRefresh,
|
| 18 |
+
} from "./widget_utils.js";
|
| 19 |
+
import { createTemplatePanel } from "./templates.js";
|
| 20 |
+
|
| 21 |
+
const MODE_PROP = "h3_editor_mode"; // "simple" | "shots"
|
| 22 |
+
const FOLD_PROP = "h3_plan_folded"; // shot id -> collapsed
|
| 23 |
+
const BACKUP_PROP = "h3_plan_backup"; // shot_plan stashed while in Simple
|
| 24 |
+
|
| 25 |
+
/* -- plan model ---------------------------------------------------------- */
|
| 26 |
+
|
| 27 |
+
export function parsePlan(text) {
|
| 28 |
+
// A widget value is not guaranteed to be a string. ComfyUI restores
|
| 29 |
+
// `widgets_values` POSITIONALLY, so a node whose widget list changed can
|
| 30 |
+
// hand this an int or a bool from a neighbouring widget. Coerce rather
|
| 31 |
+
// than throw: a wrong-typed plan should show the JSON tab, not abort the
|
| 32 |
+
// whole workflow load.
|
| 33 |
+
const t = String(text ?? "").trim();
|
| 34 |
+
if (!t) return [];
|
| 35 |
+
let data;
|
| 36 |
+
try {
|
| 37 |
+
data = JSON.parse(t);
|
| 38 |
+
} catch (_) {
|
| 39 |
+
return null; // signals "unparseable"; the JSON tab takes over
|
| 40 |
+
}
|
| 41 |
+
// Valid JSON that is not a plan (a bare `null`, a stray int from a
|
| 42 |
+
// misaligned widget) would otherwise be dereferenced below.
|
| 43 |
+
if (data === null || typeof data !== "object") return null;
|
| 44 |
+
const shots = Array.isArray(data) ? data : (data.shots || []);
|
| 45 |
+
if (!Array.isArray(shots)) return null;
|
| 46 |
+
return shots.map((s, i) => ({
|
| 47 |
+
id: String(s.id || `s${i + 1}`),
|
| 48 |
+
beat: String(s.beat || ""),
|
| 49 |
+
directives: Object.assign({}, s.directives || {}),
|
| 50 |
+
prose: String(s.prose || ""),
|
| 51 |
+
seed: s.seed == null ? null : Number(s.seed),
|
| 52 |
+
steps: s.steps == null ? null : Number(s.steps),
|
| 53 |
+
duration: s.duration || null,
|
| 54 |
+
locked: Boolean(s.locked),
|
| 55 |
+
}));
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function planToJson(shots) {
|
| 59 |
+
if (!shots || !shots.length) return "";
|
| 60 |
+
const out = shots.map((s) => {
|
| 61 |
+
const o = { id: s.id, beat: s.beat };
|
| 62 |
+
const dirs = {};
|
| 63 |
+
for (const [k, v] of Object.entries(s.directives || {})) if (v) dirs[k] = v;
|
| 64 |
+
if (Object.keys(dirs).length) o.directives = dirs;
|
| 65 |
+
if (s.prose) o.prose = s.prose;
|
| 66 |
+
if (s.seed != null) o.seed = s.seed;
|
| 67 |
+
if (s.steps != null) o.steps = s.steps;
|
| 68 |
+
if (s.duration) o.duration = s.duration;
|
| 69 |
+
if (s.locked) o.locked = true;
|
| 70 |
+
return o;
|
| 71 |
+
});
|
| 72 |
+
return JSON.stringify({ shots: out }, null, 2);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Mirrors plan.check_coherence. A framing change asks the audience to be
|
| 77 |
+
* somewhere new; with a held camera the only way to get there is a cut, so
|
| 78 |
+
* `continuous` and the framing change want opposite things.
|
| 79 |
+
*/
|
| 80 |
+
function coherenceWarning(shot, index) {
|
| 81 |
+
if (index === 0) return null;
|
| 82 |
+
const d = shot.directives || {};
|
| 83 |
+
const framing = d.framing || "";
|
| 84 |
+
const camera = d.camera || "";
|
| 85 |
+
if (d.join === "continuous" && framing && framing !== "keep" && (!camera || camera === "hold")) {
|
| 86 |
+
return `join=continuous with framing=${framing} and a held camera implies a cut. `
|
| 87 |
+
+ "Use push_in / pull_back / pan_follow to reach that framing on the move, or framing=keep.";
|
| 88 |
+
}
|
| 89 |
+
return null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Mirrors the direction check in plan.check_coherence: push_in narrows the
|
| 94 |
+
* frame and pull_back opens it, so naming the opposite destination is a
|
| 95 |
+
* physical contradiction at any join value.
|
| 96 |
+
*/
|
| 97 |
+
function directionWarning(shot) {
|
| 98 |
+
const d = shot.directives || {};
|
| 99 |
+
const pair = `${d.camera || ""}|${d.framing || ""}`;
|
| 100 |
+
if (pair === "push_in|wide" || pair === "pull_back|close") {
|
| 101 |
+
return `camera=${d.camera} moves the opposite way from framing=${d.framing}. `
|
| 102 |
+
+ "Pick the framing the move actually lands on.";
|
| 103 |
+
}
|
| 104 |
+
return null;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* -- editor -------------------------------------------------------------- */
|
| 108 |
+
|
| 109 |
+
export function createPlanEditor(node, { onChange }) {
|
| 110 |
+
const planWidget = widgetByName(node, "shot_plan");
|
| 111 |
+
const promptWidget = widgetByName(node, "prompt");
|
| 112 |
+
const chainsWidget = widgetByName(node, "chains");
|
| 113 |
+
const durationWidget = widgetByName(node, "duration");
|
| 114 |
+
const overlapWidget = widgetByName(node, "overlap");
|
| 115 |
+
|
| 116 |
+
let V = null;
|
| 117 |
+
const initial = parsePlan(planWidget?.value);
|
| 118 |
+
let broken = initial === null;
|
| 119 |
+
let shots = initial || [];
|
| 120 |
+
|
| 121 |
+
const root = el("div", "h3e-section");
|
| 122 |
+
|
| 123 |
+
/* mode toggle */
|
| 124 |
+
const head = el("div", "h3e-head");
|
| 125 |
+
head.appendChild(el("span", "h3e-title", "SCRIPT"));
|
| 126 |
+
const summary = el("span", "h3e-count");
|
| 127 |
+
head.appendChild(summary);
|
| 128 |
+
head.appendChild(el("span", "h3e-spacer"));
|
| 129 |
+
|
| 130 |
+
const modeWrap = el("div", "h3e-modes");
|
| 131 |
+
const simpleBtn = button("Simple", "One prompt, repeated across N hops", () => setMode("simple"), "h3e-mode");
|
| 132 |
+
const shotsBtn = button("Shots", "One card per hop, with directives", () => setMode("shots"), "h3e-mode");
|
| 133 |
+
modeWrap.appendChild(simpleBtn);
|
| 134 |
+
modeWrap.appendChild(shotsBtn);
|
| 135 |
+
head.appendChild(modeWrap);
|
| 136 |
+
const blankShot = (i) => ({
|
| 137 |
+
id: `s${i + 1}`, beat: "", directives: {}, prose: "",
|
| 138 |
+
seed: null, steps: null, duration: null, locked: false,
|
| 139 |
+
});
|
| 140 |
+
const addBtn = button("+ shot", "Append a shot", () => {
|
| 141 |
+
shots.push(blankShot(shots.length));
|
| 142 |
+
commit();
|
| 143 |
+
});
|
| 144 |
+
head.appendChild(addBtn);
|
| 145 |
+
|
| 146 |
+
/* Templates append patterns rather than replacing the script. Replacing
|
| 147 |
+
* would be the one destructive button on the node, and appending is what
|
| 148 |
+
* you actually want -- a chain is built by stacking these. */
|
| 149 |
+
const templates = createTemplatePanel({
|
| 150 |
+
onPick: (picked) => {
|
| 151 |
+
if (mode() !== "shots") setMode("shots");
|
| 152 |
+
for (const sh of picked) shots.push({ ...sh, id: freeId() });
|
| 153 |
+
templates.hide();
|
| 154 |
+
tplBtn.classList.remove("h3e-on");
|
| 155 |
+
commit();
|
| 156 |
+
},
|
| 157 |
+
});
|
| 158 |
+
const tplBtn = button("Templates", "Insert a ready-made shot pattern", () => {
|
| 159 |
+
tplBtn.classList.toggle("h3e-on", templates.toggle());
|
| 160 |
+
});
|
| 161 |
+
head.appendChild(tplBtn);
|
| 162 |
+
root.appendChild(head);
|
| 163 |
+
root.appendChild(templates.root);
|
| 164 |
+
|
| 165 |
+
/** The lowest `sN` no shot is using. Appending a template onto a plan whose
|
| 166 |
+
* ids are already s1..s3 must not mint a second s1: `id` is the hop
|
| 167 |
+
* cache's pointer, and two shots sharing one would make `locked` reuse the
|
| 168 |
+
* wrong render. */
|
| 169 |
+
function freeId() {
|
| 170 |
+
const used = new Set(shots.map((sh) => sh.id).filter(Boolean));
|
| 171 |
+
for (let n = 1; ; n += 1) {
|
| 172 |
+
const id = `s${n}`;
|
| 173 |
+
if (!used.has(id)) return id;
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const simpleNote = el("div", "h3e-empty",
|
| 178 |
+
"Simple mode: the prompt widget below drives the run, repeated across `chains` hops "
|
| 179 |
+
+ "(set under RUN). Switch to Shots to write one beat per hop with camera and "
|
| 180 |
+
+ "join directives.");
|
| 181 |
+
root.appendChild(simpleNote);
|
| 182 |
+
|
| 183 |
+
const list = el("div", "h3e-shots");
|
| 184 |
+
root.appendChild(list);
|
| 185 |
+
|
| 186 |
+
/* JSON escape hatch */
|
| 187 |
+
const jsonWrap = el("details", "h3e-json-wrap");
|
| 188 |
+
const jsonSum = el("summary", null, "JSON");
|
| 189 |
+
jsonSum.title = "The literal shot_plan value. Editing here updates the cards.";
|
| 190 |
+
jsonWrap.appendChild(jsonSum);
|
| 191 |
+
const jsonArea = el("textarea", "h3e-json");
|
| 192 |
+
jsonArea.spellcheck = false;
|
| 193 |
+
jsonWrap.appendChild(jsonArea);
|
| 194 |
+
const jsonErr = el("div", "h3e-note");
|
| 195 |
+
jsonErr.style.display = "none";
|
| 196 |
+
jsonWrap.appendChild(jsonErr);
|
| 197 |
+
jsonArea.addEventListener("input", () => {
|
| 198 |
+
const parsed = parsePlan(jsonArea.value);
|
| 199 |
+
if (parsed === null) {
|
| 200 |
+
jsonErr.textContent = "Not valid JSON — the cards are showing the last good version.";
|
| 201 |
+
jsonErr.style.display = "";
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
jsonErr.style.display = "none";
|
| 205 |
+
shots = parsed;
|
| 206 |
+
writeWidget();
|
| 207 |
+
renderCards();
|
| 208 |
+
onChange?.();
|
| 209 |
+
});
|
| 210 |
+
root.appendChild(jsonWrap);
|
| 211 |
+
|
| 212 |
+
/* The vocabulary is fetched once per browser session. When it fails the
|
| 213 |
+
* editor used to render with no directive controls at all and no
|
| 214 |
+
* explanation -- `_failed` was set and never read. Surface it, with a
|
| 215 |
+
* retry, and re-render every mounted editor when the retry succeeds. */
|
| 216 |
+
const offline = el("div", "h3e-offline");
|
| 217 |
+
offline.style.display = "none";
|
| 218 |
+
root.insertBefore(offline, root.firstChild);
|
| 219 |
+
|
| 220 |
+
function loadVocab() {
|
| 221 |
+
return vocab().then((v) => {
|
| 222 |
+
V = v;
|
| 223 |
+
offline.textContent = "";
|
| 224 |
+
if (v?._failed) {
|
| 225 |
+
offline.style.display = "";
|
| 226 |
+
const note = el("div", "h3e-note h3e-note-error",
|
| 227 |
+
"Directive vocabulary unavailable"
|
| 228 |
+
+ (v._error ? ` (${v._error})` : "")
|
| 229 |
+
+ " — dropdowns are empty until it loads. Beats and JSON still work.");
|
| 230 |
+
offline.appendChild(note);
|
| 231 |
+
offline.appendChild(button("Retry", "Re-fetch /h3_ref_chain/vocab",
|
| 232 |
+
() => { refreshVocab(); loadVocab(); }));
|
| 233 |
+
} else {
|
| 234 |
+
offline.style.display = "none";
|
| 235 |
+
}
|
| 236 |
+
renderCards();
|
| 237 |
+
});
|
| 238 |
+
}
|
| 239 |
+
loadVocab();
|
| 240 |
+
onVocabRefresh(() => { loadVocab(); });
|
| 241 |
+
|
| 242 |
+
/* -- state plumbing -- */
|
| 243 |
+
|
| 244 |
+
/* Collapsed cards persist in node.properties so the state survives both a
|
| 245 |
+
* re-render and a workflow reload. This is presentation state only -- it
|
| 246 |
+
* never affects what run() sees, so it is not a second source of truth for
|
| 247 |
+
* the plan itself. */
|
| 248 |
+
function foldState() {
|
| 249 |
+
const v = node.properties?.[FOLD_PROP];
|
| 250 |
+
return (v && typeof v === "object") ? v : {};
|
| 251 |
+
}
|
| 252 |
+
function setFoldState(next) {
|
| 253 |
+
if (!node.properties) node.properties = {};
|
| 254 |
+
node.properties[FOLD_PROP] = next;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function mode() {
|
| 258 |
+
const stored = node.properties?.[MODE_PROP];
|
| 259 |
+
if (stored === "simple" || stored === "shots") return stored;
|
| 260 |
+
// No stored preference: a saved plan means Shots, otherwise Simple.
|
| 261 |
+
return shots.length ? "shots" : "simple";
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function setMode(next) {
|
| 265 |
+
node.properties ??= {};
|
| 266 |
+
if (next === "simple" && shots.length) {
|
| 267 |
+
// Simple mode has to clear shot_plan, or run() would ignore the
|
| 268 |
+
// prompt and use a stale plan. Stash it first -- silently losing an
|
| 269 |
+
// authored script to a toggle would be indefensible.
|
| 270 |
+
node.properties[BACKUP_PROP] = planToJson(shots);
|
| 271 |
+
}
|
| 272 |
+
if (next === "shots" && !shots.length && node.properties[BACKUP_PROP]) {
|
| 273 |
+
const restored = parsePlan(node.properties[BACKUP_PROP]);
|
| 274 |
+
if (restored && restored.length) {
|
| 275 |
+
shots = restored;
|
| 276 |
+
node.properties[MODE_PROP] = next;
|
| 277 |
+
commit();
|
| 278 |
+
return;
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
if (next === "shots" && !shots.length) {
|
| 282 |
+
// Offer the migration plan.py already implements rather than
|
| 283 |
+
// writing a second one here: one block + chains -> N shots.
|
| 284 |
+
const text = String(promptWidget?.value || "").trim();
|
| 285 |
+
const n = Math.max(1, parseInt(chainsWidget?.value, 10) || 1);
|
| 286 |
+
if (text) {
|
| 287 |
+
const blocks = text.split(/^\s*---\s*$/m).map((b) => b.trim()).filter(Boolean);
|
| 288 |
+
const use = blocks.length > 1 ? blocks.slice(0, n) : [text];
|
| 289 |
+
while (use.length < n) use.push("");
|
| 290 |
+
shots = use.map((b, i) => ({
|
| 291 |
+
id: `s${i + 1}`, beat: b, directives: {}, prose: "",
|
| 292 |
+
seed: null, steps: null, duration: null, locked: false,
|
| 293 |
+
}));
|
| 294 |
+
} else {
|
| 295 |
+
shots = [{ id: "s1", beat: "", directives: {}, prose: "",
|
| 296 |
+
seed: null, steps: null, duration: null, locked: false }];
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
node.properties[MODE_PROP] = next;
|
| 300 |
+
commit();
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function writeWidget() {
|
| 304 |
+
if (!planWidget) return;
|
| 305 |
+
// In Simple mode the plan must be empty, or run() would ignore `prompt`
|
| 306 |
+
// and silently use a stale plan — exactly the confusion this replaces.
|
| 307 |
+
planWidget.value = mode() === "shots" ? planToJson(shots) : "";
|
| 308 |
+
if (document.activeElement !== jsonArea) jsonArea.value = planWidget.value;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
function commit() {
|
| 312 |
+
writeWidget();
|
| 313 |
+
renderCards();
|
| 314 |
+
onChange?.();
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
/* -- timing -- */
|
| 318 |
+
|
| 319 |
+
function timing() {
|
| 320 |
+
const durs = V?.durations || {};
|
| 321 |
+
const overs = V?.overlaps || {};
|
| 322 |
+
const fps = V?.fps || 24;
|
| 323 |
+
const baseDur = durationWidget?.value;
|
| 324 |
+
const ov = overs[overlapWidget?.value] ?? 0;
|
| 325 |
+
const lengths = shots.map((s) => durs[s.duration || baseDur] ?? durs[baseDur] ?? 0);
|
| 326 |
+
const total = lengths.reduce((a, b) => a + b, 0) - ov * Math.max(0, shots.length - 1);
|
| 327 |
+
return { lengths, ov, fps, total: Math.max(0, total) };
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function clock(sec) {
|
| 331 |
+
if (!Number.isFinite(sec) || sec <= 0) return "—";
|
| 332 |
+
return `${sec.toFixed(1)}s`;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* -- rendering -- */
|
| 336 |
+
|
| 337 |
+
function renderCard(shot, index) {
|
| 338 |
+
const card = el("div", "h3e-card");
|
| 339 |
+
card.dataset.index = String(index);
|
| 340 |
+
|
| 341 |
+
const bar = el("div", "h3e-card-head");
|
| 342 |
+
const drag = el("span", "h3e-drag", "⠿");
|
| 343 |
+
drag.title = "Drag to reorder";
|
| 344 |
+
drag.draggable = true;
|
| 345 |
+
drag.addEventListener("dragstart", (e) => {
|
| 346 |
+
e.dataTransfer.setData("text/plain", String(index));
|
| 347 |
+
e.dataTransfer.effectAllowed = "move";
|
| 348 |
+
});
|
| 349 |
+
bar.appendChild(drag);
|
| 350 |
+
|
| 351 |
+
// Collapse state is keyed by shot id, not index, so it survives a
|
| 352 |
+
// reorder as well as a re-render.
|
| 353 |
+
const key = shot.id || `s${index + 1}`;
|
| 354 |
+
const folded = Boolean(foldState()[key]);
|
| 355 |
+
if (folded) card.classList.add("h3e-collapsed");
|
| 356 |
+
const fold = button("", folded ? "Expand this shot" : "Collapse this shot", () => {
|
| 357 |
+
const st = foldState();
|
| 358 |
+
st[key] = !st[key];
|
| 359 |
+
setFoldState(st);
|
| 360 |
+
card.classList.toggle("h3e-collapsed");
|
| 361 |
+
fold.title = card.classList.contains("h3e-collapsed")
|
| 362 |
+
? "Expand this shot" : "Collapse this shot";
|
| 363 |
+
}, "h3e-btn h3e-fold");
|
| 364 |
+
fold.appendChild(el("span", "h3e-fold-mark", "▾"));
|
| 365 |
+
bar.appendChild(fold);
|
| 366 |
+
|
| 367 |
+
bar.appendChild(el("span", "h3e-num", String(index + 1)));
|
| 368 |
+
|
| 369 |
+
const t = timing();
|
| 370 |
+
const frames = t.lengths[index] || 0;
|
| 371 |
+
const delivered = index === 0 ? frames : Math.max(0, frames - t.ov);
|
| 372 |
+
const badge = el("span", "h3e-timing", `${clock(delivered / t.fps)}`);
|
| 373 |
+
badge.title = index === 0
|
| 374 |
+
? `${frames} frames.`
|
| 375 |
+
: `${frames} frames, ${t.ov} dropped into the previous hop's tail.`;
|
| 376 |
+
bar.appendChild(badge);
|
| 377 |
+
|
| 378 |
+
// Collapsed cards still need to say which shot they are.
|
| 379 |
+
const peekText = (shot.beat || "").replace(/\s+/g, " ").trim();
|
| 380 |
+
const peek = el("span", "h3e-peek", peekText.length > 70
|
| 381 |
+
? peekText.slice(0, 67) + "…" : (peekText || "(continues)"));
|
| 382 |
+
peek.title = peekText;
|
| 383 |
+
bar.appendChild(peek);
|
| 384 |
+
|
| 385 |
+
if (shot.locked) {
|
| 386 |
+
const lock = el("span", "h3e-lock", "locked");
|
| 387 |
+
lock.title = "Reuses this shot's cached render even when its inputs changed. Needs cache_hops=on.";
|
| 388 |
+
bar.appendChild(lock);
|
| 389 |
+
}
|
| 390 |
+
bar.appendChild(button("×", "Delete this shot", () => {
|
| 391 |
+
shots.splice(index, 1);
|
| 392 |
+
commit();
|
| 393 |
+
}, "h3e-btn h3e-x"));
|
| 394 |
+
card.appendChild(bar);
|
| 395 |
+
|
| 396 |
+
card.addEventListener("dragover", (e) => { e.preventDefault(); card.classList.add("h3e-drop"); });
|
| 397 |
+
card.addEventListener("dragleave", () => card.classList.remove("h3e-drop"));
|
| 398 |
+
card.addEventListener("drop", (e) => {
|
| 399 |
+
e.preventDefault();
|
| 400 |
+
card.classList.remove("h3e-drop");
|
| 401 |
+
const from = parseInt(e.dataTransfer.getData("text/plain"), 10);
|
| 402 |
+
if (!Number.isFinite(from) || from === index) return;
|
| 403 |
+
const [moved] = shots.splice(from, 1);
|
| 404 |
+
shots.splice(index, 0, moved);
|
| 405 |
+
commit();
|
| 406 |
+
});
|
| 407 |
+
|
| 408 |
+
const body = el("div", "h3e-card-body");
|
| 409 |
+
const beat = el("textarea", "h3e-beat");
|
| 410 |
+
beat.value = shot.beat || "";
|
| 411 |
+
beat.rows = 3;
|
| 412 |
+
beat.placeholder = index === 0
|
| 413 |
+
? "The whole opening: who, where, what they are doing."
|
| 414 |
+
: "Only what is NEW this hop. The identity lock and the join are added for you.";
|
| 415 |
+
beat.title = index === 0
|
| 416 |
+
? "Shot 1 is the full establishing prompt."
|
| 417 |
+
: "Shots after the first are the new beat only — do not re-describe the face or replay the scene.";
|
| 418 |
+
// Write on every keystroke, not on "change". A textarea only fires
|
| 419 |
+
// "change" on blur, so saving the workflow with the caret still in the
|
| 420 |
+
// box silently discarded everything typed since the last blur.
|
| 421 |
+
beat.addEventListener("input", () => { shot.beat = beat.value; writeWidget(); });
|
| 422 |
+
beat.addEventListener("change", () => { writeWidget(); onChange?.(); });
|
| 423 |
+
body.appendChild(beat);
|
| 424 |
+
|
| 425 |
+
// directive row
|
| 426 |
+
const dirs = el("div", "h3e-dirs");
|
| 427 |
+
for (const axis of (V?.axes || [])) {
|
| 428 |
+
// join has nothing to attach to on hop 1 — directive_prose skips it.
|
| 429 |
+
if (axis === "join" && index === 0) continue;
|
| 430 |
+
const opts = Object.keys(V.vocab[axis] || {});
|
| 431 |
+
const wrap = el("label", "h3e-dir");
|
| 432 |
+
wrap.appendChild(el("span", "h3e-dir-label", axis));
|
| 433 |
+
const def = V.defaults?.[axis];
|
| 434 |
+
const sel = select(opts, shot.directives[axis] || "", (v) => {
|
| 435 |
+
shot.directives[axis] = v;
|
| 436 |
+
commit();
|
| 437 |
+
}, {
|
| 438 |
+
blankLabel: def ? `${def.replace(/_/g, " ")} (default)` : "—",
|
| 439 |
+
titles: V.vocab[axis] || {},
|
| 440 |
+
});
|
| 441 |
+
sel.title = `Hover an option to read the exact sentence it puts in the prompt.`;
|
| 442 |
+
wrap.appendChild(sel);
|
| 443 |
+
dirs.appendChild(wrap);
|
| 444 |
+
}
|
| 445 |
+
body.appendChild(dirs);
|
| 446 |
+
|
| 447 |
+
for (const [text, cls] of [[coherenceWarning(shot, index), "h3e-note h3e-note-hint"],
|
| 448 |
+
[directionWarning(shot), "h3e-note"]]) {
|
| 449 |
+
if (text) body.appendChild(el("div", cls, text));
|
| 450 |
+
}
|
| 451 |
+
if (directionWarning(shot)) card.classList.add("h3e-invalid");
|
| 452 |
+
|
| 453 |
+
// advanced
|
| 454 |
+
const adv = el("details", "h3e-adv");
|
| 455 |
+
adv.appendChild(el("summary", null, "advanced"));
|
| 456 |
+
const grid = el("div", "h3e-grid");
|
| 457 |
+
|
| 458 |
+
const num = (label, key, placeholder, hint) => {
|
| 459 |
+
const l = el("label", "h3e-field");
|
| 460 |
+
l.appendChild(el("span", null, label));
|
| 461 |
+
const i = el("input", "h3e-input");
|
| 462 |
+
i.type = "number";
|
| 463 |
+
i.value = shot[key] == null ? "" : String(shot[key]);
|
| 464 |
+
i.placeholder = placeholder;
|
| 465 |
+
i.title = hint;
|
| 466 |
+
i.addEventListener("input", () => {
|
| 467 |
+
shot[key] = i.value === "" ? null : Number(i.value);
|
| 468 |
+
writeWidget();
|
| 469 |
+
});
|
| 470 |
+
i.addEventListener("change", () => {
|
| 471 |
+
shot[key] = i.value === "" ? null : Number(i.value);
|
| 472 |
+
commit();
|
| 473 |
+
});
|
| 474 |
+
l.appendChild(i);
|
| 475 |
+
return l;
|
| 476 |
+
};
|
| 477 |
+
grid.appendChild(num("seed", "seed", "chain seed", "Override the seed for this hop only."));
|
| 478 |
+
grid.appendChild(num("steps", "steps", "chain steps", "Override the step count for this hop only."));
|
| 479 |
+
|
| 480 |
+
const durL = el("label", "h3e-field");
|
| 481 |
+
durL.appendChild(el("span", null, "duration"));
|
| 482 |
+
const durSel = select(Object.keys(V?.durations || {}), shot.duration || "", (v) => {
|
| 483 |
+
shot.duration = v || null;
|
| 484 |
+
commit();
|
| 485 |
+
}, { blankLabel: `${durationWidget?.value || "chain"} (default)` });
|
| 486 |
+
durSel.title = "Override this hop's length. Must be longer than the overlap.";
|
| 487 |
+
durL.appendChild(durSel);
|
| 488 |
+
grid.appendChild(durL);
|
| 489 |
+
|
| 490 |
+
const lockL = el("label", "h3e-field h3e-check");
|
| 491 |
+
const cb = el("input");
|
| 492 |
+
cb.type = "checkbox";
|
| 493 |
+
cb.checked = Boolean(shot.locked);
|
| 494 |
+
cb.title = "Pin this hop to its cached render regardless of what changed. Needs cache_hops=on.";
|
| 495 |
+
cb.addEventListener("change", () => { shot.locked = cb.checked; commit(); });
|
| 496 |
+
lockL.appendChild(cb);
|
| 497 |
+
lockL.appendChild(el("span", null, "locked"));
|
| 498 |
+
grid.appendChild(lockL);
|
| 499 |
+
|
| 500 |
+
const idL = el("label", "h3e-field");
|
| 501 |
+
idL.appendChild(el("span", null, "id"));
|
| 502 |
+
const idI = el("input", "h3e-input");
|
| 503 |
+
idI.type = "text";
|
| 504 |
+
idI.value = shot.id || "";
|
| 505 |
+
idI.title = "Stable name, used as the cache pointer for `locked`.";
|
| 506 |
+
idI.addEventListener("input", () => { shot.id = idI.value.trim() || `s${index + 1}`; writeWidget(); });
|
| 507 |
+
idI.addEventListener("change", () => { shot.id = idI.value.trim() || `s${index + 1}`; commit(); });
|
| 508 |
+
idL.appendChild(idI);
|
| 509 |
+
grid.appendChild(idL);
|
| 510 |
+
adv.appendChild(grid);
|
| 511 |
+
|
| 512 |
+
const prose = el("textarea", "h3e-beat");
|
| 513 |
+
prose.rows = 2;
|
| 514 |
+
prose.value = shot.prose || "";
|
| 515 |
+
prose.placeholder = "prose appended verbatim (for anything the directives lack)";
|
| 516 |
+
prose.title = "Added to the end of this hop's body, unmodified. Affirmative phrasing only — "
|
| 517 |
+
+ "sampling runs at cfg 1.0, so anything named is added, never subtracted.";
|
| 518 |
+
prose.addEventListener("input", () => { shot.prose = prose.value; writeWidget(); });
|
| 519 |
+
prose.addEventListener("change", () => { writeWidget(); onChange?.(); });
|
| 520 |
+
adv.appendChild(prose);
|
| 521 |
+
body.appendChild(adv);
|
| 522 |
+
card.appendChild(body);
|
| 523 |
+
|
| 524 |
+
return card;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
function renderCards() {
|
| 528 |
+
const m = mode();
|
| 529 |
+
simpleBtn.classList.toggle("h3e-on", m === "simple");
|
| 530 |
+
shotsBtn.classList.toggle("h3e-on", m === "shots");
|
| 531 |
+
simpleNote.style.display = m === "simple" ? "" : "none";
|
| 532 |
+
list.style.display = m === "simple" ? "none" : "";
|
| 533 |
+
jsonWrap.style.display = m === "simple" ? "none" : "";
|
| 534 |
+
addBtn.style.display = m === "simple" ? "none" : "";
|
| 535 |
+
tplBtn.style.display = m === "simple" ? "none" : "";
|
| 536 |
+
if (m === "simple") {
|
| 537 |
+
templates.hide();
|
| 538 |
+
tplBtn.classList.remove("h3e-on");
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
if (m === "simple") {
|
| 542 |
+
summary.textContent = `${chainsWidget?.value || 1} hop(s)`;
|
| 543 |
+
return;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
list.textContent = "";
|
| 547 |
+
if (broken) {
|
| 548 |
+
const note = el("div", "h3e-note h3e-note-error",
|
| 549 |
+
"shot_plan is not valid JSON. Fix it in the JSON section below and the cards will come back.");
|
| 550 |
+
list.appendChild(note);
|
| 551 |
+
return;
|
| 552 |
+
}
|
| 553 |
+
if (!shots.length) {
|
| 554 |
+
const empty = el("div", "h3e-empty",
|
| 555 |
+
"No shots yet. Shot 1 is the whole opening; every shot after it is only what happens next.");
|
| 556 |
+
empty.appendChild(button("+ Add shot 1", "Add the opening shot", () => {
|
| 557 |
+
shots.push(blankShot(shots.length));
|
| 558 |
+
commit();
|
| 559 |
+
}, "h3e-btn h3e-empty-cta"));
|
| 560 |
+
list.appendChild(empty);
|
| 561 |
+
}
|
| 562 |
+
shots.forEach((s, i) => list.appendChild(renderCard(s, i)));
|
| 563 |
+
|
| 564 |
+
const t = timing();
|
| 565 |
+
summary.textContent = shots.length
|
| 566 |
+
? `${shots.length} shots · ${clock(t.total / t.fps)} master`
|
| 567 |
+
: "empty";
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
/** Re-read the widget after an external change (workflow load, undo). */
|
| 571 |
+
function reload() {
|
| 572 |
+
const parsed = parsePlan(planWidget?.value);
|
| 573 |
+
broken = parsed === null;
|
| 574 |
+
if (!broken) shots = parsed;
|
| 575 |
+
if (document.activeElement !== jsonArea) jsonArea.value = planWidget?.value || "";
|
| 576 |
+
renderCards();
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
return { root, render: renderCards, reload, mode };
|
| 580 |
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The reference rail: what each wired still is, and what it is for.
|
| 3 |
+
*
|
| 4 |
+
* Mirrors `refs.py`. Two things it surfaces that the console only told you
|
| 5 |
+
* about after you queued:
|
| 6 |
+
*
|
| 7 |
+
* - a ref whose picture is missing or never chosen (refs.check)
|
| 8 |
+
* - duplicate tags (parse_ref_plan raises on these, so a typo used to cost
|
| 9 |
+
* you a failed run instead of a red row)
|
| 10 |
+
* - a ref carried over from when references were wired to ref_image_N
|
| 11 |
+
* sockets, which cannot be migrated automatically: the socket held a
|
| 12 |
+
* tensor and there is no filename to recover, so the row says which
|
| 13 |
+
* slot it used to be and asks for the picture.
|
| 14 |
+
*
|
| 15 |
+
* The ordinal badge is the point of the whole module. Core assigns
|
| 16 |
+
* `<Picture N>` by position, so pulling a Load Image out renumbers everything
|
| 17 |
+
* after it; here the ordinal is derived and shown live, and prose refers to the
|
| 18 |
+
* stable @tag instead.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
import { el, button, select, vocab, onVocabRefresh } from "./widget_utils.js";
|
| 22 |
+
import { createPicker } from "./media_picker.js";
|
| 23 |
+
|
| 24 |
+
const MAX_SLOTS = 9;
|
| 25 |
+
|
| 26 |
+
function blankRef(existing) {
|
| 27 |
+
let n = existing.length + 1;
|
| 28 |
+
const tags = new Set(existing.map((r) => r.tag));
|
| 29 |
+
while (tags.has(`ref_${n}`)) n += 1;
|
| 30 |
+
// No slot: the ordinal is the row's position now, and the picture is
|
| 31 |
+
// chosen in the row itself.
|
| 32 |
+
return { tag: `ref_${n}`, file: "", subject: null, retention: "", desc: "" };
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function parseRefPlan(text) {
|
| 36 |
+
// A widget value is not guaranteed to be a string. ComfyUI restores
|
| 37 |
+
// `widgets_values` POSITIONALLY, so a node whose widget list changed can
|
| 38 |
+
// hand this an int or a bool from a neighbouring widget. Coerce rather
|
| 39 |
+
// than throw: a wrong-typed plan should show the JSON tab, not abort the
|
| 40 |
+
// whole workflow load.
|
| 41 |
+
const t = String(text ?? "").trim();
|
| 42 |
+
if (!t) return { refs: [], subjects: {} };
|
| 43 |
+
let data;
|
| 44 |
+
try {
|
| 45 |
+
data = JSON.parse(t);
|
| 46 |
+
} catch (_) {
|
| 47 |
+
return null; // caller keeps the raw text and shows the JSON tab
|
| 48 |
+
}
|
| 49 |
+
// Same guard as plan_editor: valid JSON that is not a plan.
|
| 50 |
+
if (data === null || typeof data !== "object") return null;
|
| 51 |
+
const refs = Array.isArray(data) ? data : (data.refs || []);
|
| 52 |
+
const subjects = (Array.isArray(data) ? {} : (data.subjects || {})) || {};
|
| 53 |
+
return {
|
| 54 |
+
refs: refs.map((r, i) => ({
|
| 55 |
+
tag: String(r.tag || "").replace(/^@/, ""),
|
| 56 |
+
file: String(r.file || ""),
|
| 57 |
+
// A plan authored against the sockets carries a slot and no file.
|
| 58 |
+
// Kept only so the row can say what it used to be plugged into.
|
| 59 |
+
legacy_slot: (!r.file && r.slot != null) ? Number(r.slot) : null,
|
| 60 |
+
// Derived, never authored -- mirrors refs.py.
|
| 61 |
+
slot: i + 1,
|
| 62 |
+
subject: r.subject == null ? null : Number(r.subject),
|
| 63 |
+
retention: r.retention || "",
|
| 64 |
+
desc: r.desc || "",
|
| 65 |
+
shots: Array.isArray(r.shots) ? r.shots.slice() : null,
|
| 66 |
+
})),
|
| 67 |
+
subjects,
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function refPlanToJson(plan) {
|
| 72 |
+
if (!plan.refs.length && !Object.keys(plan.subjects).length) return "";
|
| 73 |
+
const refs = plan.refs.map((r) => {
|
| 74 |
+
// `slot` is deliberately not written back: it is derived from row
|
| 75 |
+
// order on both sides, and persisting it would resurrect the socket
|
| 76 |
+
// number a legacy plan is trying to leave behind.
|
| 77 |
+
const out = { tag: r.tag };
|
| 78 |
+
if (r.file) out.file = r.file;
|
| 79 |
+
else if (r.legacy_slot) out.slot = r.legacy_slot;
|
| 80 |
+
if (r.subject != null) out.subject = r.subject;
|
| 81 |
+
if (r.retention) out.retention = r.retention;
|
| 82 |
+
if (r.desc) out.desc = r.desc;
|
| 83 |
+
if (r.shots && r.shots.length) out.shots = r.shots;
|
| 84 |
+
return out;
|
| 85 |
+
});
|
| 86 |
+
const body = { refs };
|
| 87 |
+
if (Object.keys(plan.subjects).length) body.subjects = plan.subjects;
|
| 88 |
+
return JSON.stringify(body, null, 2);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/** Rows that actually name a picture. Only these take a <Picture N> ordinal. */
|
| 92 |
+
function wiredSlots(plan) {
|
| 93 |
+
const out = new Set();
|
| 94 |
+
(plan.refs || []).forEach((r, i) => {
|
| 95 |
+
if (r.file) out.add(i + 1);
|
| 96 |
+
});
|
| 97 |
+
return out;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* Per-hop ordinals, exactly as `refs.active_refs` + `refs.ordinals` compute
|
| 102 |
+
* them: slot order, restricted to slots with an image.
|
| 103 |
+
*/
|
| 104 |
+
function ordinalsFor(plan, wired) {
|
| 105 |
+
const active = plan.refs
|
| 106 |
+
.filter((r) => wired.has(r.slot))
|
| 107 |
+
.sort((a, b) => a.slot - b.slot);
|
| 108 |
+
const ords = new Map();
|
| 109 |
+
active.forEach((r, i) => ords.set(r.tag, i + 1));
|
| 110 |
+
return ords;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function validate(plan, wired) {
|
| 114 |
+
const problems = new Map(); // tag -> message
|
| 115 |
+
const seenTag = new Map();
|
| 116 |
+
const seenSlot = new Map();
|
| 117 |
+
for (const r of plan.refs) {
|
| 118 |
+
if (!r.tag) {
|
| 119 |
+
problems.set(r, "a tag is required — prose refers to it as @tag");
|
| 120 |
+
continue;
|
| 121 |
+
}
|
| 122 |
+
if (seenTag.has(r.tag)) {
|
| 123 |
+
problems.set(r, `@${r.tag} is already used — tags must be unique`);
|
| 124 |
+
}
|
| 125 |
+
seenTag.set(r.tag, r);
|
| 126 |
+
if (seenSlot.has(r.slot)) {
|
| 127 |
+
problems.set(r, `slot ${r.slot} is already taken by @${seenSlot.get(r.slot).tag}`);
|
| 128 |
+
}
|
| 129 |
+
seenSlot.set(r.slot, r);
|
| 130 |
+
if (!wired.has(r.slot)) {
|
| 131 |
+
problems.set(r, r.legacy_slot
|
| 132 |
+
? `was wired to ref_image_${r.legacy_slot}, which no longer exists `
|
| 133 |
+
+ `— pick its picture to bring this ref back`
|
| 134 |
+
: "no picture chosen — this ref is inactive");
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
// A subject block nothing points at is continuity prose for someone who
|
| 138 |
+
// never appears; parse_ref_plan rejects it outright.
|
| 139 |
+
const used = new Set(plan.refs.filter((r) => r.subject != null).map((r) => String(r.subject)));
|
| 140 |
+
const orphans = Object.keys(plan.subjects).filter((k) => !used.has(String(k)));
|
| 141 |
+
return { problems, orphans };
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
export function createRefRail(node, { getPlan, setPlan, onChange, hopCount,
|
| 145 |
+
getRaw, isBad }) {
|
| 146 |
+
const root = el("div", "h3e-section");
|
| 147 |
+
const head = el("div", "h3e-head");
|
| 148 |
+
const title = el("span", "h3e-title", "REFERENCES");
|
| 149 |
+
const count = el("span", "h3e-count");
|
| 150 |
+
head.appendChild(title);
|
| 151 |
+
head.appendChild(count);
|
| 152 |
+
head.appendChild(el("span", "h3e-spacer"));
|
| 153 |
+
head.appendChild(button("+ ref", "Add a reference row", () => {
|
| 154 |
+
const plan = getPlan();
|
| 155 |
+
plan.refs.push(blankRef(plan.refs));
|
| 156 |
+
setPlan(plan);
|
| 157 |
+
render();
|
| 158 |
+
onChange?.();
|
| 159 |
+
}));
|
| 160 |
+
root.appendChild(head);
|
| 161 |
+
|
| 162 |
+
const list = el("div", "h3e-refs");
|
| 163 |
+
root.appendChild(list);
|
| 164 |
+
|
| 165 |
+
/* JSON escape hatch, mirroring the one in the SCRIPT section.
|
| 166 |
+
*
|
| 167 |
+
* Without it the rail was the ONLY editor for `ref_plan`, which made a
|
| 168 |
+
* model-authored register unpastable: prompt_pack hands the author two
|
| 169 |
+
* JSON documents and only `shot_plan` had a box to paste into.
|
| 170 |
+
*
|
| 171 |
+
* It also closes a quieter bug. `parseRefPlan` returns null on text it
|
| 172 |
+
* cannot read, and the caller swapped in an empty plan -- so the next rail
|
| 173 |
+
* edit wrote "" over the very text the author was trying to repair. The
|
| 174 |
+
* paste was destroyed by the act of fixing it. Now the raw value stays in
|
| 175 |
+
* the widget until something parses, and this box is where it shows.
|
| 176 |
+
*/
|
| 177 |
+
const jsonWrap = el("details", "h3e-json-wrap");
|
| 178 |
+
const jsonSum = el("summary", null, "JSON");
|
| 179 |
+
jsonSum.title = "The literal ref_plan value. Editing here updates the rows.";
|
| 180 |
+
jsonWrap.appendChild(jsonSum);
|
| 181 |
+
const jsonArea = el("textarea", "h3e-json");
|
| 182 |
+
jsonArea.spellcheck = false;
|
| 183 |
+
jsonArea.placeholder = '{"refs": [{"tag": "hero_face", "file": "face.jpg", '
|
| 184 |
+
+ '"subject": 1, "retention": "fully_preserved", "shots": [1, 2]}], '
|
| 185 |
+
+ '"subjects": {"1": {"name": "the cook", "locked": "..."}}}';
|
| 186 |
+
jsonWrap.appendChild(jsonArea);
|
| 187 |
+
const jsonErr = el("div", "h3e-note");
|
| 188 |
+
jsonErr.style.display = "none";
|
| 189 |
+
jsonWrap.appendChild(jsonErr);
|
| 190 |
+
jsonArea.addEventListener("input", () => {
|
| 191 |
+
const parsed = parseRefPlan(jsonArea.value);
|
| 192 |
+
if (parsed === null) {
|
| 193 |
+
jsonErr.textContent =
|
| 194 |
+
"Not valid JSON -- the rows are showing the last good version.";
|
| 195 |
+
jsonErr.classList.add("h3e-note-error");
|
| 196 |
+
jsonErr.style.display = "";
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
jsonErr.style.display = "none";
|
| 200 |
+
jsonErr.classList.remove("h3e-note-error");
|
| 201 |
+
setPlan(parsed);
|
| 202 |
+
onChange?.();
|
| 203 |
+
render();
|
| 204 |
+
});
|
| 205 |
+
root.appendChild(jsonWrap);
|
| 206 |
+
|
| 207 |
+
let V = null;
|
| 208 |
+
vocab().then((v) => { V = v; render(); });
|
| 209 |
+
// Re-render when a retry in the plan editor reloads the vocabulary.
|
| 210 |
+
onVocabRefresh(() => vocab().then((v) => { V = v; render(); }));
|
| 211 |
+
|
| 212 |
+
function commit() {
|
| 213 |
+
setPlan(getPlan());
|
| 214 |
+
onChange?.();
|
| 215 |
+
render();
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function renderSubject(num) {
|
| 219 |
+
const plan = getPlan();
|
| 220 |
+
const s = plan.subjects[num] || (plan.subjects[num] = { name: "", locked: "", context: "" });
|
| 221 |
+
const box = el("div", "h3e-subject");
|
| 222 |
+
box.appendChild(el("span", "h3e-subj-badge", `Subject ${num}`));
|
| 223 |
+
|
| 224 |
+
const mk = (key, placeholder, hint) => {
|
| 225 |
+
const i = el("input", "h3e-input");
|
| 226 |
+
i.type = "text";
|
| 227 |
+
i.value = s[key] || "";
|
| 228 |
+
i.placeholder = placeholder;
|
| 229 |
+
i.title = hint;
|
| 230 |
+
i.addEventListener("input", () => { s[key] = i.value; setPlan(plan); onChange?.(); });
|
| 231 |
+
return i;
|
| 232 |
+
};
|
| 233 |
+
box.appendChild(mk("name", "what to call them",
|
| 234 |
+
"Used as “<Subject N> is <name>, the person in <Picture …>”. A role works better than a proper name."));
|
| 235 |
+
box.appendChild(mk("locked", "traits that must not drift",
|
| 236 |
+
"Injected verbatim on every hop. Only put things that are actually in the photograph here — at cfg 1.0 every word is additive."));
|
| 237 |
+
box.appendChild(mk("context", "current standing state",
|
| 238 |
+
"Same as locked but less rigid: what they are wearing right now, current mood."));
|
| 239 |
+
return box;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function render() {
|
| 243 |
+
list.textContent = "";
|
| 244 |
+
// Never while focused: it would reformat mid-keystroke and steal the caret.
|
| 245 |
+
if (document.activeElement !== jsonArea) jsonArea.value = getRaw?.() ?? "";
|
| 246 |
+
const plan = getPlan();
|
| 247 |
+
if (!plan) return;
|
| 248 |
+
if (isBad?.()) {
|
| 249 |
+
jsonWrap.open = true;
|
| 250 |
+
list.appendChild(el("div", "h3e-note h3e-note-error",
|
| 251 |
+
"ref_plan is not valid JSON. Fix it in the JSON section below "
|
| 252 |
+
+ "and the rows will come back."));
|
| 253 |
+
}
|
| 254 |
+
const wired = wiredSlots(plan);
|
| 255 |
+
const ords = ordinalsFor(plan, wired);
|
| 256 |
+
const { problems, orphans } = validate(plan, wired);
|
| 257 |
+
count.textContent = `${plan.refs.length} · ${wired.size} with a picture`;
|
| 258 |
+
|
| 259 |
+
if (!plan.refs.length) {
|
| 260 |
+
const empty = el("div", "h3e-empty",
|
| 261 |
+
"No references. Add one per picture, then write @tag in your beats.");
|
| 262 |
+
list.appendChild(empty);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
const bySubject = new Map();
|
| 266 |
+
for (const r of plan.refs) {
|
| 267 |
+
const row = el("div", "h3e-ref");
|
| 268 |
+
const bad = problems.get(r);
|
| 269 |
+
if (bad) row.classList.add("h3e-bad");
|
| 270 |
+
if (!wired.has(r.slot)) row.classList.add("h3e-inactive");
|
| 271 |
+
|
| 272 |
+
const ord = ords.get(r.tag);
|
| 273 |
+
const badge = el("span", "h3e-ord", ord ? String(ord) : "–");
|
| 274 |
+
badge.title = ord
|
| 275 |
+
? `Resolves to <Picture ${ord}> on every hop this ref is active.`
|
| 276 |
+
: "No picture, so it takes no ordinal this run.";
|
| 277 |
+
row.appendChild(badge);
|
| 278 |
+
|
| 279 |
+
const tag = el("input", "h3e-input h3e-tag");
|
| 280 |
+
tag.type = "text";
|
| 281 |
+
tag.value = "@" + r.tag;
|
| 282 |
+
tag.title = "Stable name. Write this in a beat and it resolves to the right picture per hop.";
|
| 283 |
+
// Push to the widget per keystroke; commit() re-renders and would
|
| 284 |
+
// steal the caret, so only the blur handler may call it.
|
| 285 |
+
tag.addEventListener("input", () => {
|
| 286 |
+
r.tag = tag.value.trim().replace(/^@/, "").replace(/[^A-Za-z0-9_]/g, "_");
|
| 287 |
+
setPlan(plan);
|
| 288 |
+
});
|
| 289 |
+
tag.addEventListener("change", commit);
|
| 290 |
+
row.appendChild(tag);
|
| 291 |
+
|
| 292 |
+
// Was a `slot N` dropdown naming one of nine IMAGE sockets. The
|
| 293 |
+
// picture itself lives here now: drop a file on it, click to
|
| 294 |
+
// browse, or choose one already in the reference folder.
|
| 295 |
+
const pick = createPicker({
|
| 296 |
+
kind: "image",
|
| 297 |
+
get: () => r.file || "",
|
| 298 |
+
set: (v) => {
|
| 299 |
+
r.file = v;
|
| 300 |
+
// Choosing a picture is what retires a legacy row, so the
|
| 301 |
+
// "was wired to slot N" note has to go with it.
|
| 302 |
+
if (v) r.legacy_slot = null;
|
| 303 |
+
},
|
| 304 |
+
onChange: () => { commit(); render(); },
|
| 305 |
+
title: "The picture this reference stands for. Drop a file, or click to browse.",
|
| 306 |
+
});
|
| 307 |
+
pick.root.classList.add("h3e-slot");
|
| 308 |
+
row.appendChild(pick.root);
|
| 309 |
+
|
| 310 |
+
const subj = el("select", "h3e-select h3e-subj");
|
| 311 |
+
const none = el("option", null, "setting/prop");
|
| 312 |
+
none.value = "";
|
| 313 |
+
subj.appendChild(none);
|
| 314 |
+
for (let i = 1; i <= 4; i += 1) {
|
| 315 |
+
const o = el("option", null, `Subject ${i}`);
|
| 316 |
+
o.value = String(i);
|
| 317 |
+
subj.appendChild(o);
|
| 318 |
+
}
|
| 319 |
+
subj.value = r.subject == null ? "" : String(r.subject);
|
| 320 |
+
subj.title = "Group pictures of the SAME person under one subject number. "
|
| 321 |
+
+ "Two different people sharing a number makes the model render the average of their faces.";
|
| 322 |
+
subj.addEventListener("change", () => {
|
| 323 |
+
r.subject = subj.value === "" ? null : Number(subj.value);
|
| 324 |
+
if (!r.retention) r.retention = r.subject ? "fully_preserved" : "reference";
|
| 325 |
+
commit();
|
| 326 |
+
});
|
| 327 |
+
row.appendChild(subj);
|
| 328 |
+
|
| 329 |
+
const retOpts = Object.keys(V?.retention || {});
|
| 330 |
+
const ret = select(retOpts, r.retention, (v) => { r.retention = v; commit(); }, {
|
| 331 |
+
blankLabel: r.subject ? "fully preserved" : "reference",
|
| 332 |
+
titles: V?.retention || {},
|
| 333 |
+
});
|
| 334 |
+
ret.classList.add("h3e-ret");
|
| 335 |
+
ret.title = "How much of this picture carries over. Hover an option for the exact sentence.";
|
| 336 |
+
row.appendChild(ret);
|
| 337 |
+
|
| 338 |
+
const desc = el("input", "h3e-input h3e-desc");
|
| 339 |
+
desc.type = "text";
|
| 340 |
+
desc.value = r.desc || "";
|
| 341 |
+
desc.placeholder = "describe this photo";
|
| 342 |
+
desc.title = "Goes into retention_analysis verbatim. Describe the picture you actually wired — "
|
| 343 |
+
+ "at cfg 1.0 a detail that is not there is asked for, not ignored.";
|
| 344 |
+
desc.addEventListener("input", () => { r.desc = desc.value; setPlan(plan); });
|
| 345 |
+
desc.addEventListener("change", commit);
|
| 346 |
+
row.appendChild(desc);
|
| 347 |
+
|
| 348 |
+
row.appendChild(button("×", "Remove this reference", () => {
|
| 349 |
+
const p = getPlan();
|
| 350 |
+
p.refs.splice(p.refs.indexOf(r), 1);
|
| 351 |
+
setPlan(p);
|
| 352 |
+
onChange?.();
|
| 353 |
+
render();
|
| 354 |
+
}, "h3e-btn h3e-x"));
|
| 355 |
+
|
| 356 |
+
/* Which hops this photograph rides. An empty schedule means hop 1
|
| 357 |
+
* only on a `next` chain -- that is the pin-only recipe
|
| 358 |
+
* chain_00037/00038 were rendered with, and until now it could
|
| 359 |
+
* only be set by hand in the JSON. */
|
| 360 |
+
const hops = Math.max(1, Number(hopCount?.() || 1));
|
| 361 |
+
const chips = el("div", "h3e-chips");
|
| 362 |
+
chips.appendChild(el("span", "h3e-chips-label", "rides hops"));
|
| 363 |
+
for (let h = 1; h <= hops; h += 1) {
|
| 364 |
+
const on = Array.isArray(r.shots) && r.shots.includes(h);
|
| 365 |
+
const chip = el("span", "h3e-chip" + (on ? " h3e-chip-on" : ""), String(h));
|
| 366 |
+
chip.title = on
|
| 367 |
+
? `This photograph is sent to the encoder on hop ${h}. Click to remove it.`
|
| 368 |
+
: `Click to also send this photograph on hop ${h}. `
|
| 369 |
+
+ "Leave every hop off to let the motion pin carry wardrobe and room.";
|
| 370 |
+
chip.addEventListener("click", () => {
|
| 371 |
+
const cur = Array.isArray(r.shots) ? r.shots.slice() : [];
|
| 372 |
+
const at = cur.indexOf(h);
|
| 373 |
+
if (at >= 0) cur.splice(at, 1); else cur.push(h);
|
| 374 |
+
cur.sort((a, b) => a - b);
|
| 375 |
+
r.shots = cur.length ? cur : null;
|
| 376 |
+
commit();
|
| 377 |
+
render();
|
| 378 |
+
});
|
| 379 |
+
chips.appendChild(chip);
|
| 380 |
+
}
|
| 381 |
+
if (!Array.isArray(r.shots) || !r.shots.length) {
|
| 382 |
+
const note = el("span", "h3e-chips-label", "— unscheduled: hop 1 only");
|
| 383 |
+
note.title = "With no hops selected this still stays off hop 2+ of a `next` chain, "
|
| 384 |
+
+ "so the motion pin carries continuity instead of the photograph.";
|
| 385 |
+
chips.appendChild(note);
|
| 386 |
+
}
|
| 387 |
+
row.appendChild(chips);
|
| 388 |
+
|
| 389 |
+
list.appendChild(row);
|
| 390 |
+
if (bad) {
|
| 391 |
+
const note = el("div", "h3e-note h3e-note-error", bad);
|
| 392 |
+
list.appendChild(note);
|
| 393 |
+
}
|
| 394 |
+
if (r.subject != null) bySubject.set(r.subject, true);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
for (const num of [...bySubject.keys()].sort((a, b) => a - b)) {
|
| 398 |
+
list.appendChild(renderSubject(num));
|
| 399 |
+
}
|
| 400 |
+
for (const num of orphans) {
|
| 401 |
+
const warn = el("div", "h3e-note",
|
| 402 |
+
`Subject ${num} has continuity text but no ref claims it. Give a ref that subject number, or clear the text — the run will refuse otherwise.`);
|
| 403 |
+
list.appendChild(warn);
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
return { root, render };
|
| 408 |
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The run panel: every dial that is not per-shot, grouped and labelled.
|
| 3 |
+
*
|
| 4 |
+
* The shot cards made the *script* legible and left the other twenty-odd
|
| 5 |
+
* widgets stacked underneath as a wall of raw controls, in definition order,
|
| 6 |
+
* with no grouping -- so the node read as "a polished editor, then a heap".
|
| 7 |
+
* The dials with the least obvious semantics (`pin_renorm`, `pin_noise`,
|
| 8 |
+
* `cache_budget_gb`) were indistinguishable from the ones nobody touches.
|
| 9 |
+
*
|
| 10 |
+
* Same contract as the shot editor: this is a RENDERING LAYER over the native
|
| 11 |
+
* widgets, never a second source of truth. Every control writes straight back
|
| 12 |
+
* to `widget.value` and fires the widget's own callback, so the workflow JSON
|
| 13 |
+
* is byte-identical to one produced by the stock UI, and anything this panel
|
| 14 |
+
* cannot express is still reachable by dropping it from the group list.
|
| 15 |
+
*
|
| 16 |
+
* Help text is read from `widget.options.tooltip`, i.e. the `tooltip` written in
|
| 17 |
+
* INPUT_TYPES. Retyping those sentences here would be a second copy that goes
|
| 18 |
+
* stale, for exactly the reason routes.py exists.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
import { el, widgetByName, widgetType, widgetOptions } from "./widget_utils.js";
|
| 22 |
+
|
| 23 |
+
const OPEN_PROP = "h3_run_open";
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Groups, in draw order; names are native widget names.
|
| 27 |
+
*
|
| 28 |
+
* A name this build does not define is skipped rather than warned about, so the
|
| 29 |
+
* list can carry a widget that only exists on a newer Python side. The flip
|
| 30 |
+
* side is that a *renamed* widget silently drops out of the panel and reappears
|
| 31 |
+
* as a raw dial below it -- the safe direction to fail, and visible the moment
|
| 32 |
+
* you look at the node.
|
| 33 |
+
*/
|
| 34 |
+
const GROUPS = [
|
| 35 |
+
["output", ["resolution", "aspect", "duration", "overlap", "chains"]],
|
| 36 |
+
["sampling", ["steps", "sampler_name", "scheduler", "seed",
|
| 37 |
+
"control_after_generate", "seed_per_shot",
|
| 38 |
+
"shift_video", "shift_audio"]],
|
| 39 |
+
["join & pin", ["hop_script", "pin_to_qwen", "ref_image_size",
|
| 40 |
+
"audio_pin_frames", "pin_renorm", "pin_noise",
|
| 41 |
+
"tone_compensate"]],
|
| 42 |
+
["cache", ["cache_hops", "cache_budget_gb"]],
|
| 43 |
+
];
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Why a dial is not drawn in the current mode.
|
| 47 |
+
*
|
| 48 |
+
* `run()` ignores `chains` and forces `hop_script=next` the moment a shot plan
|
| 49 |
+
* is present (h3_ref_chain.py:1187-1192, which prints both overrides). Drawing
|
| 50 |
+
* them anyway would offer two controls that do nothing, which is worse than not
|
| 51 |
+
* offering them -- so the panel drops them in Shots mode and says why.
|
| 52 |
+
*/
|
| 53 |
+
/**
|
| 54 |
+
* Dials that need two grid cells.
|
| 55 |
+
*
|
| 56 |
+
* A seed is up to 19 digits and a truncated one cannot be read back to
|
| 57 |
+
* reproduce a render, which is the only reason to look at it.
|
| 58 |
+
*/
|
| 59 |
+
const WIDE = new Set(["seed"]);
|
| 60 |
+
|
| 61 |
+
const SUPPRESSED_WHY = {
|
| 62 |
+
chains: "the shot list sets the hop count",
|
| 63 |
+
hop_script: "a shot plan forces it to `next`",
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
/* -- one field per native widget ----------------------------------------- */
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Push a value back through the widget.
|
| 70 |
+
*
|
| 71 |
+
* `callback` is how a ComfyUI widget tells the rest of the app it changed --
|
| 72 |
+
* `control_after_generate` binds to the seed through it, and setting `.value`
|
| 73 |
+
* alone would leave that link dead.
|
| 74 |
+
*/
|
| 75 |
+
function commitWidget(node, w, value, onChange) {
|
| 76 |
+
w.value = value;
|
| 77 |
+
try {
|
| 78 |
+
w.callback?.(value, node.graph?.canvas, node, undefined, undefined);
|
| 79 |
+
} catch (err) {
|
| 80 |
+
console.error(`[HandTieClips] widget callback for ${w.name} failed:`, err);
|
| 81 |
+
}
|
| 82 |
+
node.graph?.setDirtyCanvas?.(true, true);
|
| 83 |
+
onChange?.();
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function labelFor(w) {
|
| 87 |
+
return String(w.name || "").replace(/_/g, " ");
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function comboField(node, w, onChange) {
|
| 91 |
+
const values = widgetOptions(w).values;
|
| 92 |
+
const opts = typeof values === "function" ? values(w, node) : values;
|
| 93 |
+
if (!Array.isArray(opts)) return null;
|
| 94 |
+
const s = el("select", "h3e-select");
|
| 95 |
+
for (const opt of opts) {
|
| 96 |
+
const o = el("option", null, String(opt));
|
| 97 |
+
o.value = String(opt);
|
| 98 |
+
s.appendChild(o);
|
| 99 |
+
}
|
| 100 |
+
s.value = String(w.value ?? "");
|
| 101 |
+
s.addEventListener("change", () => commitWidget(node, w, s.value, onChange));
|
| 102 |
+
return { input: s, read: () => { s.value = String(w.value ?? ""); } };
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function toggleField(node, w, onChange) {
|
| 106 |
+
const wrap = el("span", "h3e-run-toggle");
|
| 107 |
+
const cb = el("input");
|
| 108 |
+
cb.type = "checkbox";
|
| 109 |
+
cb.checked = Boolean(w.value);
|
| 110 |
+
const text = el("span", "h3e-run-togglabel");
|
| 111 |
+
const paint = () => {
|
| 112 |
+
// A BOOLEAN with label_on/label_off says what each state MEANS
|
| 113 |
+
// ("vary per hop" / "same seed every hop"); a bare on/off throws that away.
|
| 114 |
+
const o = widgetOptions(w);
|
| 115 |
+
text.textContent = cb.checked
|
| 116 |
+
? (o.label_on || "on")
|
| 117 |
+
: (o.label_off || "off");
|
| 118 |
+
};
|
| 119 |
+
paint();
|
| 120 |
+
cb.addEventListener("change", () => {
|
| 121 |
+
paint();
|
| 122 |
+
commitWidget(node, w, cb.checked, onChange);
|
| 123 |
+
});
|
| 124 |
+
wrap.appendChild(cb);
|
| 125 |
+
wrap.appendChild(text);
|
| 126 |
+
return { input: wrap, read: () => { cb.checked = Boolean(w.value); paint(); } };
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function numberField(node, w, onChange) {
|
| 130 |
+
const i = el("input", "h3e-input");
|
| 131 |
+
i.type = "number";
|
| 132 |
+
const o = widgetOptions(w);
|
| 133 |
+
if (o.min != null) i.min = String(o.min);
|
| 134 |
+
if (o.max != null) i.max = String(o.max);
|
| 135 |
+
// LiteGraph's `options.step` is ten times the author's step -- a legacy of
|
| 136 |
+
// the canvas drag increment. `step2` carries the real one when the frontend
|
| 137 |
+
// is new enough; divide as the fallback rather than showing a 10x arrow.
|
| 138 |
+
const step = o.step2 ?? (o.step != null ? o.step / 10 : null);
|
| 139 |
+
if (step != null) i.step = String(step);
|
| 140 |
+
// Whether to round on commit. Testing the step alone is not enough: if a
|
| 141 |
+
// frontend ever reports the true step in `step`, the /10 fallback turns an
|
| 142 |
+
// INT's step into 0.1 and `steps` would happily accept 7.5. Requiring the
|
| 143 |
+
// bounds and the current value to be integral too keeps pin_noise (max
|
| 144 |
+
// 0.10) and the shifts (min 0.01) out of it. `cache_budget_gb` is integral
|
| 145 |
+
// on every count and so gets rounded -- a whole-GB budget, which is what
|
| 146 |
+
// its 1.0 step already asks for.
|
| 147 |
+
const isInt = [o.min, o.max, w.value, step]
|
| 148 |
+
.every((v) => v == null || Number.isInteger(Number(v)));
|
| 149 |
+
i.value = String(w.value ?? "");
|
| 150 |
+
const push = () => {
|
| 151 |
+
if (i.value === "") return; // mid-edit; wait for change
|
| 152 |
+
let v = Number(i.value);
|
| 153 |
+
if (!Number.isFinite(v)) return;
|
| 154 |
+
if (o.min != null) v = Math.max(o.min, v);
|
| 155 |
+
if (o.max != null) v = Math.min(o.max, v);
|
| 156 |
+
if (isInt) v = Math.round(v);
|
| 157 |
+
commitWidget(node, w, v, onChange);
|
| 158 |
+
};
|
| 159 |
+
// Commit on input as well as change: the shot editor learned the hard way
|
| 160 |
+
// that a field committing only on blur loses whatever was typed when the
|
| 161 |
+
// workflow is saved with the caret still in the box.
|
| 162 |
+
i.addEventListener("input", push);
|
| 163 |
+
i.addEventListener("change", () => {
|
| 164 |
+
push();
|
| 165 |
+
i.value = String(w.value ?? ""); // show the clamped value back
|
| 166 |
+
});
|
| 167 |
+
return { input: i, read: () => { i.value = String(w.value ?? ""); } };
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function textField(node, w, onChange) {
|
| 171 |
+
const i = el("input", "h3e-input");
|
| 172 |
+
i.type = "text";
|
| 173 |
+
i.value = String(w.value ?? "");
|
| 174 |
+
i.addEventListener("input", () => commitWidget(node, w, i.value, onChange));
|
| 175 |
+
return { input: i, read: () => { i.value = String(w.value ?? ""); } };
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/** null when this widget's type is not one the panel can draw. */
|
| 179 |
+
function fieldFor(node, w, onChange) {
|
| 180 |
+
const t = widgetType(w);
|
| 181 |
+
if (t === "combo") return comboField(node, w, onChange);
|
| 182 |
+
if (t === "toggle" || t === "boolean") return toggleField(node, w, onChange);
|
| 183 |
+
if (t === "number" || t === "int" || t === "float") return numberField(node, w, onChange);
|
| 184 |
+
if (t === "string" || t === "text") {
|
| 185 |
+
// Multiline STRINGs are documents, not dials -- they belong to the shot
|
| 186 |
+
// editor or to their own widget, never squeezed into a 104px grid cell.
|
| 187 |
+
if (widgetOptions(w).multiline) return null;
|
| 188 |
+
return textField(node, w, onChange);
|
| 189 |
+
}
|
| 190 |
+
return null;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* -- panel ---------------------------------------------------------------- */
|
| 194 |
+
|
| 195 |
+
export function createRunPanel(node, { onChange, suppressed, hopCount } = {}) {
|
| 196 |
+
const root = el("details", "h3e-section h3e-run");
|
| 197 |
+
const sum = el("summary", "h3e-run-sum");
|
| 198 |
+
sum.appendChild(el("span", "h3e-title", "RUN"));
|
| 199 |
+
const digest = el("span", "h3e-count h3e-run-digest");
|
| 200 |
+
sum.appendChild(digest);
|
| 201 |
+
root.appendChild(sum);
|
| 202 |
+
|
| 203 |
+
const body = el("div", "h3e-run-body");
|
| 204 |
+
root.appendChild(body);
|
| 205 |
+
|
| 206 |
+
/** Widgets this panel actually drew, so the caller hides exactly those. */
|
| 207 |
+
const owned = [];
|
| 208 |
+
const readers = [];
|
| 209 |
+
let builtFor = null; // the suppression set the current DOM was built for
|
| 210 |
+
|
| 211 |
+
function suppressedSet() {
|
| 212 |
+
try {
|
| 213 |
+
return new Set(suppressed?.() || []);
|
| 214 |
+
} catch (err) {
|
| 215 |
+
console.error("[HandTieClips] run panel suppression check failed:", err);
|
| 216 |
+
return new Set();
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function build() {
|
| 221 |
+
body.textContent = "";
|
| 222 |
+
owned.length = 0;
|
| 223 |
+
readers.length = 0;
|
| 224 |
+
// Widgets found by name but whose type fieldFor could not draw. A
|
| 225 |
+
// frontend that renames widget types would empty this panel silently,
|
| 226 |
+
// and "silent" is exactly what made the last mount bug hard to find.
|
| 227 |
+
const rejected = [];
|
| 228 |
+
|
| 229 |
+
const skip = suppressedSet();
|
| 230 |
+
builtFor = [...skip].sort().join(",");
|
| 231 |
+
|
| 232 |
+
for (const [title, names] of GROUPS) {
|
| 233 |
+
const grid = el("div", "h3e-grid");
|
| 234 |
+
let n = 0;
|
| 235 |
+
for (const name of names) {
|
| 236 |
+
if (skip.has(name)) continue;
|
| 237 |
+
const w = widgetByName(node, name);
|
| 238 |
+
if (!w) continue;
|
| 239 |
+
const f = fieldFor(node, w, () => { paintDigest(); onChange?.(); });
|
| 240 |
+
if (!f) { // stays a native dial below
|
| 241 |
+
rejected.push(`${name}:${widgetType(w) || "?"}`);
|
| 242 |
+
continue;
|
| 243 |
+
}
|
| 244 |
+
const l = el("label",
|
| 245 |
+
WIDE.has(name) ? "h3e-field h3e-run-wide" : "h3e-field");
|
| 246 |
+
l.appendChild(el("span", null, labelFor(w)));
|
| 247 |
+
l.appendChild(f.input);
|
| 248 |
+
// The tooltip is the widget's own, straight from INPUT_TYPES.
|
| 249 |
+
const tip = widgetOptions(w).tooltip;
|
| 250 |
+
if (tip) l.title = String(tip);
|
| 251 |
+
grid.appendChild(l);
|
| 252 |
+
owned.push(name);
|
| 253 |
+
readers.push(f.read);
|
| 254 |
+
n += 1;
|
| 255 |
+
}
|
| 256 |
+
if (!n) continue; // nothing found: draw nothing
|
| 257 |
+
const group = el("div", "h3e-run-group");
|
| 258 |
+
group.appendChild(el("div", "h3e-run-label", title));
|
| 259 |
+
group.appendChild(grid);
|
| 260 |
+
body.appendChild(group);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
const why = [...skip]
|
| 264 |
+
.filter((name) => SUPPRESSED_WHY[name])
|
| 265 |
+
.map((name) => `${name} — ${SUPPRESSED_WHY[name]}`);
|
| 266 |
+
if (why.length) {
|
| 267 |
+
body.appendChild(el("div", "h3e-note h3e-note-hint",
|
| 268 |
+
`Not shown here: ${why.join("; ")}.`));
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
if (!owned.length) {
|
| 272 |
+
// Two very different failures, and the message has to say which:
|
| 273 |
+
// nothing found by name means a stale Python side; found but not
|
| 274 |
+
// drawable means this frontend reports widget types fieldFor does
|
| 275 |
+
// not know, which is a bug here rather than a version mismatch.
|
| 276 |
+
if (rejected.length) {
|
| 277 |
+
console.warn("[HandTieClips] run panel cannot draw these widget "
|
| 278 |
+
+ "types:", rejected.join(", "));
|
| 279 |
+
}
|
| 280 |
+
body.appendChild(el("div", "h3e-note h3e-note-error",
|
| 281 |
+
rejected.length
|
| 282 |
+
? `No drawable run widgets: this frontend reports types the `
|
| 283 |
+
+ `panel does not handle (${rejected.slice(0, 4).join(", ")}`
|
| 284 |
+
+ `${rejected.length > 4 ? ", …" : ""}). Native dials left `
|
| 285 |
+
+ `visible below; see the console.`
|
| 286 |
+
: "No run widgets found — the Python side is probably a "
|
| 287 |
+
+ "different version. The native dials are left visible below."));
|
| 288 |
+
} else if (rejected.length) {
|
| 289 |
+
console.warn("[HandTieClips] run panel left these as native dials:",
|
| 290 |
+
rejected.join(", "));
|
| 291 |
+
}
|
| 292 |
+
// An empty build must not stick: builtFor is what stops sync() from
|
| 293 |
+
// rebuilding, so caching a failure would make it permanent for the
|
| 294 |
+
// life of the node.
|
| 295 |
+
if (!owned.length) builtFor = null;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
/* The one-line summary, readable with the panel shut. Deliberately no seed:
|
| 299 |
+
* `control_after_generate` rewrites it after every queue without telling
|
| 300 |
+
* us, so a seed shown here would be wrong more often than right. */
|
| 301 |
+
function paintDigest() {
|
| 302 |
+
const v = (n) => widgetByName(node, n)?.value;
|
| 303 |
+
const bits = [];
|
| 304 |
+
const res = v("resolution");
|
| 305 |
+
const asp = String(v("aspect") || "").split(" ")[0];
|
| 306 |
+
if (res) bits.push(asp ? `${res} ${asp}` : String(res));
|
| 307 |
+
const dur = v("duration");
|
| 308 |
+
// The hop count, not `chains`: with a shot plan loaded the two disagree
|
| 309 |
+
// and the plan is the one run() obeys.
|
| 310 |
+
const ch = hopCount?.() || v("chains");
|
| 311 |
+
if (dur && ch) bits.push(`${String(dur).replace(/\s+/g, "")} ×${ch}`);
|
| 312 |
+
const steps = v("steps");
|
| 313 |
+
if (steps) bits.push(`${steps} steps ${v("sampler_name") || ""}`.trim());
|
| 314 |
+
const cache = v("cache_hops");
|
| 315 |
+
if (cache) bits.push(`cache ${cache}`);
|
| 316 |
+
digest.textContent = bits.join(" · ");
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/** Re-read every field from its widget. */
|
| 320 |
+
function render() {
|
| 321 |
+
for (const read of readers) {
|
| 322 |
+
try {
|
| 323 |
+
read();
|
| 324 |
+
} catch (err) {
|
| 325 |
+
console.error("[HandTieClips] run field refresh failed:", err);
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
paintDigest();
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
/**
|
| 332 |
+
* Rebuild only when the suppression set actually changed.
|
| 333 |
+
*
|
| 334 |
+
* Called from the same place that recomputes widget visibility, and it has
|
| 335 |
+
* to run BEFORE that: `ownedNames()` is what decides which native dials get
|
| 336 |
+
* hidden, so a stale build would hide a dial the panel no longer draws.
|
| 337 |
+
*/
|
| 338 |
+
function sync() {
|
| 339 |
+
const key = [...suppressedSet()].sort().join(",");
|
| 340 |
+
if (key !== builtFor) build();
|
| 341 |
+
render();
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
build();
|
| 345 |
+
render();
|
| 346 |
+
|
| 347 |
+
// Always open. Collapsing made sense while RUN was the last child of the
|
| 348 |
+
// scroller and cost a screenful; pinned to the bottom it is the one
|
| 349 |
+
// section you touch on every queue, so a toggle only ever hid it.
|
| 350 |
+
// `<details>` is kept for the summary bar's markup, forced open and with
|
| 351 |
+
// its disclosure suppressed -- see the CSS.
|
| 352 |
+
root.open = true;
|
| 353 |
+
root.addEventListener("toggle", () => { root.open = true; });
|
| 354 |
+
// A click on the summary would otherwise close it before that fires.
|
| 355 |
+
sum.addEventListener("click", (e) => e.preventDefault());
|
| 356 |
+
// Values change behind the panel's back -- `control_after_generate` bumps
|
| 357 |
+
// the seed on every queue, and undo rewrites widgets wholesale -- so what
|
| 358 |
+
// used to be re-read on open is now re-read whenever the node is drawn
|
| 359 |
+
// through sync(). node.properties[OPEN_PROP] is left alone: old workflows
|
| 360 |
+
// carry it and it costs nothing.
|
| 361 |
+
|
| 362 |
+
return {
|
| 363 |
+
root,
|
| 364 |
+
render,
|
| 365 |
+
sync,
|
| 366 |
+
/** Names the panel drew; the caller hides exactly these. */
|
| 367 |
+
ownedNames: () => owned.slice(),
|
| 368 |
+
};
|
| 369 |
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Skeleton shot patterns, appended into the card list.
|
| 2 |
+
*
|
| 3 |
+
* These are not "example scenes" -- each one exists to demonstrate a rule from
|
| 4 |
+
* PROMPTING.md that a first-time author reliably gets wrong, in a form they can
|
| 5 |
+
* edit rather than read:
|
| 6 |
+
*
|
| 7 |
+
* - an ending written as a pose plus a sound, never as "stops talking";
|
| 8 |
+
* - a state change landing at the END of the shot before the one that needs
|
| 9 |
+
* it, because every hop opens holding the frames it was handed;
|
| 10 |
+
* - `tail` set to settle/hold wherever a clip actually finishes;
|
| 11 |
+
* - a framing change earned on a camera move, so `join: continuous` and the
|
| 12 |
+
* framing are not asking for opposite things;
|
| 13 |
+
* - a spoken line landing MID-hop with a non-verbal action running into the
|
| 14 |
+
* seam, and a named sound on every hop without dialogue, because the audio
|
| 15 |
+
* pin otherwise carries speech down the whole chain (chain_00057);
|
| 16 |
+
* - `match_cut` on a hop that changes location, because a continuous join
|
| 17 |
+
* across two rooms morphs one into the other (chain_00057).
|
| 18 |
+
*
|
| 19 |
+
* No beat carries an `@tag`. A template that referred to a reference the user
|
| 20 |
+
* has not created would turn a helpful click into a hard error at run time.
|
| 21 |
+
* The `note` on each pattern is where reference scheduling is explained
|
| 22 |
+
* instead.
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
import { el, button } from "./widget_utils.js";
|
| 26 |
+
|
| 27 |
+
function shot(beat, directives) {
|
| 28 |
+
return {
|
| 29 |
+
id: "", beat, directives,
|
| 30 |
+
prose: "", seed: null, steps: null, duration: null, locked: false,
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export const TEMPLATES = [
|
| 35 |
+
{
|
| 36 |
+
name: "Dialogue, held",
|
| 37 |
+
hint: "Someone speaks, then finishes. Two hops.",
|
| 38 |
+
note: "The line lands mid-hop and a non-verbal action runs into the "
|
| 39 |
+
+ "seam, so the audio pin does not carry speech into the next hop.",
|
| 40 |
+
shots: [
|
| 41 |
+
shot("A person stands in the room, looks up at someone off-frame "
|
| 42 |
+
+ "and says, 'You are earlier than I expected.' They set both "
|
| 43 |
+
+ "hands on the counter, the refrigerator humming behind them.",
|
| 44 |
+
{ camera: "hold", framing: "medium", pace: "steady", tail: "ongoing" }),
|
| 45 |
+
shot("Their weight shifts onto one hip and their eyes move slowly "
|
| 46 |
+
+ "across the room. The refrigerator hums and a car passes "
|
| 47 |
+
+ "outside.",
|
| 48 |
+
{ join: "continuous", camera: "hold", framing: "keep", pace: "slow", tail: "settle" }),
|
| 49 |
+
],
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
name: "Cross the room",
|
| 53 |
+
hint: "Movement across the space, camera following. Two hops.",
|
| 54 |
+
note: "The framing change is earned on the move -- a framing change "
|
| 55 |
+
+ "with a held camera and a continuous join implies a cut.",
|
| 56 |
+
shots: [
|
| 57 |
+
shot("They set down what they are holding and walk the length of "
|
| 58 |
+
+ "the room to the window, looking out at the street. Their "
|
| 59 |
+
+ "steps are soft on the floor.",
|
| 60 |
+
{ join: "continuous", camera: "pan_follow", framing: "medium", pace: "steady", tail: "ongoing" }),
|
| 61 |
+
shot("They arrive at the window and rest one hand on the frame, "
|
| 62 |
+
+ "their weight settling onto one hip. Traffic passes faintly "
|
| 63 |
+
+ "on the other side of the glass.",
|
| 64 |
+
{ join: "continuous", camera: "push_in", framing: "close", pace: "slow", tail: "settle" }),
|
| 65 |
+
],
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
name: "Leave for an unseen space",
|
| 69 |
+
hint: "Exit the referenced room into somewhere with no picture. Two hops.",
|
| 70 |
+
note: "Take the room reference OFF these hops in the register. A plate "
|
| 71 |
+
+ "of the room riding them drags the character back into it.",
|
| 72 |
+
shots: [
|
| 73 |
+
shot("They turn from the window, cross the room and push through "
|
| 74 |
+
+ "the doorway into the corridor beyond, the room falling "
|
| 75 |
+
+ "away behind them, their footsteps carrying on the floor.",
|
| 76 |
+
{ join: "continuous", camera: "pan_follow", framing: "wide", pace: "brisk", tail: "ongoing" }),
|
| 77 |
+
shot("They walk down a narrow corridor hung with coats, one hand "
|
| 78 |
+
+ "trailing along the wall, their footsteps muffled on the "
|
| 79 |
+
+ "runner.",
|
| 80 |
+
{ join: "continuous", camera: "handheld", framing: "medium", pace: "steady", tail: "ongoing" }),
|
| 81 |
+
],
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
name: "Return to the room",
|
| 85 |
+
hint: "Come back to an established space. One hop.",
|
| 86 |
+
note: "Put the room reference back on this hop, and join on "
|
| 87 |
+
+ "match_cut -- a continuous join across a real location "
|
| 88 |
+
+ "change morphs one room into the other.",
|
| 89 |
+
shots: [
|
| 90 |
+
shot("They walk back through the doorway to where they started, "
|
| 91 |
+
+ "picking up what they set down. The room is quiet apart "
|
| 92 |
+
+ "from the hum of the refrigerator.",
|
| 93 |
+
{ join: "match_cut", camera: "pull_back", framing: "wide", pace: "steady", tail: "hold" }),
|
| 94 |
+
],
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
name: "Quiet close",
|
| 98 |
+
hint: "End a chain without a stray gesture. One hop.",
|
| 99 |
+
note: "A discrete sound rather than a continuous bed, and `tail: hold` "
|
| 100 |
+
+ "so the model is not told action is still underway at the last "
|
| 101 |
+
+ "frame.",
|
| 102 |
+
shots: [
|
| 103 |
+
shot("They lean back with their lips closed and let their eyes "
|
| 104 |
+
+ "move slowly across the room. A single click from the "
|
| 105 |
+
+ "refrigerator, then stillness.",
|
| 106 |
+
{ join: "continuous", camera: "hold", framing: "keep", pace: "slow", tail: "hold" }),
|
| 107 |
+
],
|
| 108 |
+
},
|
| 109 |
+
];
|
| 110 |
+
|
| 111 |
+
/* A panel of one row per pattern. Hidden until the SCRIPT header's button asks
|
| 112 |
+
* for it, so it costs nothing on a node the author already knows their way
|
| 113 |
+
* around. `onPick` receives a fresh deep copy -- the caller renumbers ids, and
|
| 114 |
+
* handing out the module-level object would let one insertion mutate the
|
| 115 |
+
* template for the rest of the session. */
|
| 116 |
+
export function createTemplatePanel({ onPick }) {
|
| 117 |
+
const root = el("div", "h3e-tpl");
|
| 118 |
+
root.style.display = "none";
|
| 119 |
+
|
| 120 |
+
root.appendChild(el("div", "h3e-note",
|
| 121 |
+
"Appended to the end of your script. Every beat is a starting point to "
|
| 122 |
+
+ "rewrite, not a finished shot."));
|
| 123 |
+
|
| 124 |
+
for (const t of TEMPLATES) {
|
| 125 |
+
const row = el("div", "h3e-tpl-row");
|
| 126 |
+
const text = el("div", "h3e-tpl-text");
|
| 127 |
+
text.appendChild(el("div", "h3e-tpl-name", t.name));
|
| 128 |
+
text.appendChild(el("div", "h3e-tpl-hint", t.hint));
|
| 129 |
+
text.appendChild(el("div", "h3e-tpl-note", t.note));
|
| 130 |
+
row.appendChild(text);
|
| 131 |
+
row.appendChild(button(
|
| 132 |
+
`+ ${t.shots.length}`,
|
| 133 |
+
`Append ${t.shots.length} shot${t.shots.length > 1 ? "s" : ""}: ${t.note}`,
|
| 134 |
+
() => onPick(JSON.parse(JSON.stringify(t.shots))),
|
| 135 |
+
));
|
| 136 |
+
root.appendChild(row);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
return {
|
| 140 |
+
root,
|
| 141 |
+
toggle() {
|
| 142 |
+
root.style.display = root.style.display === "none" ? "" : "none";
|
| 143 |
+
return root.style.display !== "none";
|
| 144 |
+
},
|
| 145 |
+
hide() { root.style.display = "none"; },
|
| 146 |
+
};
|
| 147 |
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Foundations for the H3 Ref Chain node editor.
|
| 3 |
+
*
|
| 4 |
+
* Two things here are load-bearing and neither is obvious, so both are
|
| 5 |
+
* documented at the point of use rather than in a README nobody opens:
|
| 6 |
+
*
|
| 7 |
+
* hideWidget() - the four-flag recipe. Classic LiteGraph only needed
|
| 8 |
+
* computeSize = [0, -4]; Vue Nodes 2.0 filters on
|
| 9 |
+
* options.hidden | hideInPanel | canvasOnly, and without
|
| 10 |
+
* those flags every hidden dial reappears as a raw form.
|
| 11 |
+
* Invisible in packs with three widgets, unmissable at 21.
|
| 12 |
+
*
|
| 13 |
+
* installHeightGuard() - the _h fixpoint. LiteGraph's _arrangeWidgets runs
|
| 14 |
+
* every frame; report a height derived from node.size[1] and
|
| 15 |
+
* the node grows forever.
|
| 16 |
+
*
|
| 17 |
+
* Both are ported from PromptMasterLD/js/claude_prompt.js, which paid for them.
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
import { app } from "../../../scripts/app.js";
|
| 21 |
+
|
| 22 |
+
const VOCAB_URL = "/h3_ref_chain/vocab";
|
| 23 |
+
|
| 24 |
+
/* -- vocabulary ---------------------------------------------------------- */
|
| 25 |
+
|
| 26 |
+
let _vocabPromise = null;
|
| 27 |
+
const _vocabListeners = new Set();
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* The directive vocabulary, fetched once per browser session.
|
| 31 |
+
*
|
| 32 |
+
* Deliberately NOT bundled into this file: `directives.py` is the single source
|
| 33 |
+
* of truth so that improving one sentence improves every plan already written.
|
| 34 |
+
* A copy here would go stale on the first edit.
|
| 35 |
+
*/
|
| 36 |
+
/** Drop the cached vocabulary so the next vocab() call refetches. */
|
| 37 |
+
export function refreshVocab() {
|
| 38 |
+
_vocabPromise = null;
|
| 39 |
+
for (const fn of _vocabListeners) {
|
| 40 |
+
try { fn(); } catch (err) { console.error("[HandTieClips] vocab listener failed:", err); }
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/** Register a mounted editor to be re-rendered when the vocabulary reloads. */
|
| 45 |
+
export function onVocabRefresh(fn) {
|
| 46 |
+
_vocabListeners.add(fn);
|
| 47 |
+
return () => _vocabListeners.delete(fn);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function vocab() {
|
| 51 |
+
if (!_vocabPromise) {
|
| 52 |
+
_vocabPromise = fetch(VOCAB_URL)
|
| 53 |
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
| 54 |
+
.catch((err) => {
|
| 55 |
+
console.error("[HandTieClips] vocabulary unavailable:", err);
|
| 56 |
+
// Degrade to free-text directives rather than an empty editor.
|
| 57 |
+
// `_failed` is read by the editors, which show a retry strip --
|
| 58 |
+
// it used to be set here and never looked at again, so a failed
|
| 59 |
+
// fetch just produced a mysteriously featureless panel.
|
| 60 |
+
return { axes: [], vocab: {}, defaults: {}, retention: {},
|
| 61 |
+
durations: {}, overlaps: {}, max_ref_images: 9, fps: 24,
|
| 62 |
+
_failed: true, _error: String(err && err.message || err) };
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
return _vocabPromise;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/* -- widget visibility --------------------------------------------------- */
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Hide a native widget without losing its value.
|
| 72 |
+
*
|
| 73 |
+
* The widget still serializes -- that is the entire point. The DOM editor is a
|
| 74 |
+
* rendering layer over `shot_plan` / `ref_plan`, and those widgets remain the
|
| 75 |
+
* only source of truth, so the workflow JSON is byte-identical in shape to one
|
| 76 |
+
* authored by hand.
|
| 77 |
+
*/
|
| 78 |
+
/* `widget.inputEl` is a deprecated alias on frontend 1.49.6: merely reading it
|
| 79 |
+
* logs a deprecation warning to the console. Prefer `element`, and only reach
|
| 80 |
+
* for the alias when it is absent, i.e. on an older frontend. */
|
| 81 |
+
function widgetElements(w) {
|
| 82 |
+
if (w.element) return [w.element];
|
| 83 |
+
return w.inputEl ? [w.inputEl] : [];
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* The widget's type as the FRONTEND defined it, seeing past our own hiding.
|
| 88 |
+
*
|
| 89 |
+
* `hideWidget` overwrites `w.type` with "hidden" and stashes the real one in
|
| 90 |
+
* `_h3Saved`. Anything that renders a widget therefore has to ask for the type
|
| 91 |
+
* this way, or it reads back the hiding rather than the widget -- which is
|
| 92 |
+
* exactly how the run panel emptied itself: it hid the dials it owned on the
|
| 93 |
+
* first build, then found nothing but "hidden" on the rebuild.
|
| 94 |
+
*/
|
| 95 |
+
export function widgetType(w) {
|
| 96 |
+
const t = (w && w._h3Hidden) ? w._h3Saved?.type : w?.type;
|
| 97 |
+
return String(t ?? "").toLowerCase();
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** Ditto for options: `hideWidget` replaces the object with a flagged copy. */
|
| 101 |
+
export function widgetOptions(w) {
|
| 102 |
+
const o = (w && w._h3Hidden) ? w._h3Saved?.options : w?.options;
|
| 103 |
+
return o || w?.options || {};
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export function hideWidget(w) {
|
| 107 |
+
if (!w || w._h3Hidden) return;
|
| 108 |
+
w._h3Hidden = true;
|
| 109 |
+
w._h3Saved = {
|
| 110 |
+
type: w.type,
|
| 111 |
+
computeSize: w.computeSize,
|
| 112 |
+
computeLayoutSize: w.computeLayoutSize,
|
| 113 |
+
draw: w.draw,
|
| 114 |
+
options: w.options,
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
w.type = "hidden";
|
| 118 |
+
w.hidden = true;
|
| 119 |
+
w.options = Object.assign({}, w.options || {}, {
|
| 120 |
+
hidden: true,
|
| 121 |
+
hideInPanel: true,
|
| 122 |
+
// canvasOnly also drops it from the Vue widget list (shouldRenderAsVue).
|
| 123 |
+
canvasOnly: true,
|
| 124 |
+
// Never flip serialization off: the value IS the saved document.
|
| 125 |
+
serialize: w.options?.serialize !== false,
|
| 126 |
+
});
|
| 127 |
+
w.computeSize = () => [0, -4];
|
| 128 |
+
if (typeof w.computeLayoutSize === "function") {
|
| 129 |
+
w.computeLayoutSize = () => ({ minHeight: 0, maxHeight: 0, minWidth: 0 });
|
| 130 |
+
}
|
| 131 |
+
w.draw = () => {};
|
| 132 |
+
|
| 133 |
+
// A multiline STRING widget is a real DOM textarea. Collapsing only the
|
| 134 |
+
// LiteGraph geometry leaves it floating at its old coordinates, on top of
|
| 135 |
+
// the editor.
|
| 136 |
+
for (const el of widgetElements(w)) {
|
| 137 |
+
if (!el?.style) continue;
|
| 138 |
+
el.style.setProperty("display", "none", "important");
|
| 139 |
+
el.setAttribute?.("aria-hidden", "true");
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/** Undo hideWidget. Used when the Simple/Shots toggle flips. */
|
| 144 |
+
export function showWidget(w) {
|
| 145 |
+
if (!w?._h3Hidden) return;
|
| 146 |
+
const saved = w._h3Saved || {};
|
| 147 |
+
w.type = saved.type;
|
| 148 |
+
w.hidden = false;
|
| 149 |
+
w.options = saved.options || {};
|
| 150 |
+
if (saved.computeSize) w.computeSize = saved.computeSize;
|
| 151 |
+
else delete w.computeSize;
|
| 152 |
+
if (saved.computeLayoutSize) w.computeLayoutSize = saved.computeLayoutSize;
|
| 153 |
+
if (saved.draw) w.draw = saved.draw;
|
| 154 |
+
else delete w.draw;
|
| 155 |
+
for (const el of widgetElements(w)) {
|
| 156 |
+
if (!el?.style) continue;
|
| 157 |
+
el.style.removeProperty("display");
|
| 158 |
+
el.removeAttribute?.("aria-hidden");
|
| 159 |
+
}
|
| 160 |
+
w._h3Hidden = false;
|
| 161 |
+
delete w._h3Saved;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
export function widgetByName(node, name) {
|
| 165 |
+
return node.widgets?.find((w) => w.name === name) || null;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/** Hide every widget in `names`, show the rest of `names`' complement set. */
|
| 169 |
+
export function setWidgetVisibility(node, hiddenNames) {
|
| 170 |
+
const hide = new Set(hiddenNames);
|
| 171 |
+
for (const w of node.widgets || []) {
|
| 172 |
+
if (hide.has(w.name)) hideWidget(w);
|
| 173 |
+
else showWidget(w);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* -- height guard -------------------------------------------------------- */
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* Stop the node growing a little every frame.
|
| 181 |
+
*
|
| 182 |
+
* LiteGraph's _arrangeWidgets does, once per frame:
|
| 183 |
+
*
|
| 184 |
+
* l = widgetsTop; for (w of widgets) l += w.computeSize()[1] + 4;
|
| 185 |
+
* if (l > node.size[1]) node.setSize([node.size[0], l]);
|
| 186 |
+
*
|
| 187 |
+
* so a panel that reports `node.size[1]` makes l = top + size[1] + 4 > size[1]
|
| 188 |
+
* on EVERY frame. Observed at ~130px of growth per frame -- the workflow
|
| 189 |
+
* scrolls away from you while you watch it.
|
| 190 |
+
*
|
| 191 |
+
* Reporting `size[1] - chrome() - SLACK` makes l <= size[1] on every frame, so
|
| 192 |
+
* the arrange pass never asks to grow and the loop settles at the node's own
|
| 193 |
+
* height. SLACK is what buys the inequality: panelTop is chrome minus the
|
| 194 |
+
* node's bottom padding, so a panel of exactly `size - chrome` still asks for
|
| 195 |
+
* up to 4px more than the node has -- every frame, forever.
|
| 196 |
+
*
|
| 197 |
+
* This replaced an independent stored `_h`, updated through an onResize hook.
|
| 198 |
+
* The hook fired (57 times in one measured drag) but its `Array.isArray(size)`
|
| 199 |
+
* gate rejected every call -- this frontend's `node.size` is not a plain Array
|
| 200 |
+
* -- so `_h` kept the height the node had at install time and the panel stayed
|
| 201 |
+
* 742px inside a 1911px node. Reading the node directly has no such gate to get
|
| 202 |
+
* wrong.
|
| 203 |
+
*
|
| 204 |
+
* The second floor is the resize drag, which clamps up to
|
| 205 |
+
* LGraphNode.computeSize()[1]. Rather than re-derive that formula (and have it
|
| 206 |
+
* rot the next time the frontend changes), measure it: with `_measuring` set,
|
| 207 |
+
* panelHeight() reports 0, so computeSize() returns pure chrome. The flag also
|
| 208 |
+
* breaks the recursion, since computeSize() calls back into us.
|
| 209 |
+
*/
|
| 210 |
+
export function installHeightGuard(node, domWidget, { minHeight = 420, minWidth = 520 } = {}) {
|
| 211 |
+
if (node._h3HeightGuard) return node._h3HeightGuard;
|
| 212 |
+
|
| 213 |
+
let measuring = false;
|
| 214 |
+
let chromeVal = null;
|
| 215 |
+
let chromeKey = "";
|
| 216 |
+
|
| 217 |
+
const chrome = () => {
|
| 218 |
+
// The hidden count is part of the key because hiding a widget changes
|
| 219 |
+
// what computeSize() measures while leaving widgets.length alone --
|
| 220 |
+
// so without it the cached chrome height goes stale the moment
|
| 221 |
+
// applyVisibility runs, and every panel height after that is wrong.
|
| 222 |
+
const hidden = (node.widgets || []).reduce((n, w) => n + (w._h3Hidden ? 1 : 0), 0);
|
| 223 |
+
const key = `${node.inputs?.length}|${node.outputs?.length}|${node.widgets?.length}|${hidden}`;
|
| 224 |
+
if (key === chromeKey && chromeVal != null) return chromeVal;
|
| 225 |
+
measuring = true;
|
| 226 |
+
try {
|
| 227 |
+
chromeVal = +node.computeSize()[1] || 0;
|
| 228 |
+
chromeKey = key;
|
| 229 |
+
} catch (_) {
|
| 230 |
+
chromeVal = 0;
|
| 231 |
+
} finally {
|
| 232 |
+
measuring = false;
|
| 233 |
+
}
|
| 234 |
+
return chromeVal;
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
// The margin that keeps the arrange pass from asking for one more pixel
|
| 238 |
+
// than the node has. See the note above. Costs an invisible strip at the
|
| 239 |
+
// bottom of the panel; 4 would do, 8 leaves room for the frontend to
|
| 240 |
+
// change its mind about padding.
|
| 241 |
+
const SLACK = 8;
|
| 242 |
+
const nodeHeight = () => Math.max(minHeight, +(node.size?.[1]) || minHeight);
|
| 243 |
+
const nodeWidth = () => Math.max(minWidth, +(node.size?.[0]) || minWidth);
|
| 244 |
+
|
| 245 |
+
const guard = {
|
| 246 |
+
get minHeight() { return minHeight; },
|
| 247 |
+
/** The node's own height is the authority; nothing mirrors it. */
|
| 248 |
+
get _h() { return nodeHeight(); },
|
| 249 |
+
panelHeight() {
|
| 250 |
+
if (measuring) return 0;
|
| 251 |
+
return Math.max(minHeight, nodeHeight() - chrome() - SLACK);
|
| 252 |
+
},
|
| 253 |
+
/** Grow the node if it is too short to hold a minimum panel. Never
|
| 254 |
+
* shrinks it -- the node's height is the user's to choose. */
|
| 255 |
+
sync() {
|
| 256 |
+
const need = chrome() + minHeight + SLACK;
|
| 257 |
+
if (nodeHeight() < need - 1) node.setSize([nodeWidth(), need]);
|
| 258 |
+
node.graph?.setDirtyCanvas?.(true, true);
|
| 259 |
+
},
|
| 260 |
+
/** Ask for a taller panel, e.g. after adding a card. */
|
| 261 |
+
grow(px) {
|
| 262 |
+
node.setSize([nodeWidth(), Math.max(minHeight, nodeHeight() + px)]);
|
| 263 |
+
node.graph?.setDirtyCanvas?.(true, true);
|
| 264 |
+
},
|
| 265 |
+
};
|
| 266 |
+
node._h3HeightGuard = guard;
|
| 267 |
+
|
| 268 |
+
if (domWidget) {
|
| 269 |
+
// The two callers want different answers and, helpfully, pass
|
| 270 |
+
// different arguments -- which is the only way to tell them apart:
|
| 271 |
+
//
|
| 272 |
+
// LGraphNode.computeSize() calls computeSize(size[0]), WITH a width.
|
| 273 |
+
// Its result is used purely as a FLOOR (the resize-drag clamp,
|
| 274 |
+
// expandToFitContent). Reporting the live height there pins the
|
| 275 |
+
// floor to the current height, so the node can only ever grow --
|
| 276 |
+
// drag it smaller and nothing happens. Report the MINIMUM.
|
| 277 |
+
//
|
| 278 |
+
// _arrangeWidgets() calls computeSize(), no argument.
|
| 279 |
+
// The real layout pass. Report the live height.
|
| 280 |
+
domWidget.computeSize = (width) => [
|
| 281 |
+
Math.max(minWidth, +(node.size?.[0]) || minWidth),
|
| 282 |
+
measuring ? 0 : (width === undefined ? guard.panelHeight() : minHeight),
|
| 283 |
+
];
|
| 284 |
+
if (typeof domWidget.computeLayoutSize === "function") {
|
| 285 |
+
// TRACK, DO NOT FREEZE. Reporting a constant `minWidth` here is
|
| 286 |
+
// what collapses the node: the layout pass re-reads it on every
|
| 287 |
+
// recompute -- selecting the node is enough -- and faithfully
|
| 288 |
+
// re-declares the node at its stated minimum, so a wide node
|
| 289 |
+
// becomes a 560px node on the first click and stays there.
|
| 290 |
+
// Safe against feedback: max() against a constant is a fixpoint,
|
| 291 |
+
// not an accumulator like the height chain, and width never feeds
|
| 292 |
+
// height.
|
| 293 |
+
domWidget.computeLayoutSize = () => ({
|
| 294 |
+
minHeight: measuring ? 0 : guard.panelHeight(),
|
| 295 |
+
maxHeight: measuring ? 0 : guard.panelHeight(),
|
| 296 |
+
minWidth: Math.max(minWidth, +(node.size?.[0]) || minWidth),
|
| 297 |
+
});
|
| 298 |
+
}
|
| 299 |
+
// The other half of the collapse. ComfyUI's DOM-widget position
|
| 300 |
+
// updater computes `size = [(widget.width ?? node.width) - margin*2, ...]`.
|
| 301 |
+
// Once anything writes a stale number onto widget.width, node.width is
|
| 302 |
+
// never consulted again -- which is why fixing only the layout size is
|
| 303 |
+
// not enough. A live getter makes the stale value unrepresentable:
|
| 304 |
+
// width always IS the node's width, and writes are dropped.
|
| 305 |
+
try {
|
| 306 |
+
Object.defineProperty(domWidget, "width", {
|
| 307 |
+
get: () => Math.max(minWidth, +(node.size?.[0]) || minWidth),
|
| 308 |
+
set: () => {},
|
| 309 |
+
configurable: true,
|
| 310 |
+
});
|
| 311 |
+
} catch (err) {
|
| 312 |
+
console.warn("[HandTieClips] width getter failed:", err);
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// With no widget declaring a width, LiteGraph falls back to
|
| 317 |
+
// NODE_WIDTH * 1.5 = 210, and every resize command is then free to crush
|
| 318 |
+
// the panel. Floor it.
|
| 319 |
+
const prevComputeSize = node.computeSize;
|
| 320 |
+
if (typeof prevComputeSize === "function") {
|
| 321 |
+
node.computeSize = function (...args) {
|
| 322 |
+
const r = prevComputeSize.apply(this, args) || [0, 0];
|
| 323 |
+
if (r[0] < minWidth) r[0] = minWidth;
|
| 324 |
+
return r;
|
| 325 |
+
};
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// No onResize hook any more. There is nothing to mirror: panelHeight()
|
| 329 |
+
// reads node.size[1] at the moment it is asked, so a resize is reflected
|
| 330 |
+
// by the very next arrange pass with nothing to keep in step.
|
| 331 |
+
|
| 332 |
+
return guard;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* -- tiny DOM helpers ---------------------------------------------------- */
|
| 336 |
+
|
| 337 |
+
export function el(tag, className, text) {
|
| 338 |
+
const node = document.createElement(tag);
|
| 339 |
+
if (className) node.className = className;
|
| 340 |
+
if (text != null) node.textContent = text;
|
| 341 |
+
return node;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
export function button(label, title, onClick, className = "h3e-btn") {
|
| 345 |
+
const b = el("button", className, label);
|
| 346 |
+
b.type = "button";
|
| 347 |
+
if (title) b.title = title;
|
| 348 |
+
b.addEventListener("click", (e) => {
|
| 349 |
+
e.preventDefault();
|
| 350 |
+
e.stopPropagation();
|
| 351 |
+
onClick(e);
|
| 352 |
+
});
|
| 353 |
+
return b;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
export function select(options, value, onChange, { blankLabel = "—", titles = {} } = {}) {
|
| 357 |
+
const s = el("select", "h3e-select");
|
| 358 |
+
const blank = el("option", null, blankLabel);
|
| 359 |
+
blank.value = "";
|
| 360 |
+
s.appendChild(blank);
|
| 361 |
+
for (const opt of options) {
|
| 362 |
+
const o = el("option", null, opt.replace(/_/g, " "));
|
| 363 |
+
o.value = opt;
|
| 364 |
+
if (titles[opt]) o.title = titles[opt];
|
| 365 |
+
s.appendChild(o);
|
| 366 |
+
}
|
| 367 |
+
s.value = value || "";
|
| 368 |
+
s.addEventListener("change", () => onChange(s.value));
|
| 369 |
+
return s;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
/**
|
| 373 |
+
* Keep canvas gestures out of the panel.
|
| 374 |
+
*
|
| 375 |
+
* Without this a click inside a textarea also starts a node drag, and a scroll
|
| 376 |
+
* inside the shot list zooms the graph instead.
|
| 377 |
+
*/
|
| 378 |
+
export function isolateEvents(root) {
|
| 379 |
+
for (const name of ["pointerdown", "pointerup", "mousedown", "mouseup",
|
| 380 |
+
"click", "dblclick", "contextmenu"]) {
|
| 381 |
+
root.addEventListener(name, (e) => e.stopPropagation());
|
| 382 |
+
}
|
| 383 |
+
root.addEventListener("keydown", (e) => e.stopPropagation());
|
| 384 |
+
|
| 385 |
+
// Wheel is NOT simply swallowed. Stopping it outright meant the graph could
|
| 386 |
+
// not be zoomed while the pointer was anywhere over the panel -- and the
|
| 387 |
+
// panel is most of the node. Scroll the nearest scrollable ancestor if
|
| 388 |
+
// there is somewhere left to scroll; otherwise hand the gesture to the
|
| 389 |
+
// canvas so zoom keeps working. Same approach as KJNodes' wheel passthrough.
|
| 390 |
+
root.addEventListener("wheel", (e) => {
|
| 391 |
+
let n = e.target;
|
| 392 |
+
while (n && n !== root.parentNode) {
|
| 393 |
+
if (n.scrollHeight > n.clientHeight + 1) {
|
| 394 |
+
const style = getComputedStyle(n).overflowY;
|
| 395 |
+
if (style === "auto" || style === "scroll") {
|
| 396 |
+
const atTop = n.scrollTop <= 0;
|
| 397 |
+
const atEnd = n.scrollTop + n.clientHeight >= n.scrollHeight - 1;
|
| 398 |
+
if (!((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atEnd))) {
|
| 399 |
+
e.stopPropagation();
|
| 400 |
+
return;
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
n = n.parentNode;
|
| 405 |
+
}
|
| 406 |
+
const canvasEl = document.querySelector("#graph-canvas")
|
| 407 |
+
|| app?.canvas?.canvas;
|
| 408 |
+
if (!canvasEl) return;
|
| 409 |
+
e.preventDefault();
|
| 410 |
+
e.stopPropagation();
|
| 411 |
+
canvasEl.dispatchEvent(new WheelEvent(e.type, e));
|
| 412 |
+
}, { passive: false });
|
| 413 |
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* H3 Chain Preview -- the chain's progress panel, on its own node.
|
| 3 |
+
*
|
| 4 |
+
* Structure follows ComfyUI-KJNodes' preview_override panel (image area that
|
| 5 |
+
* absorbs slack, a fixed-height stats panel, a drag grip between them). The
|
| 6 |
+
* grip itself follows the sibling MiniMaxH3-Contex-Loop pack's review node
|
| 7 |
+
* instead: it uses pointer capture and reads the UNSCALED offsetHeight, because
|
| 8 |
+
* getBoundingClientRect() includes ComfyUI's canvas zoom and every release then
|
| 9 |
+
* compounds a smaller screen-space height into the saved value.
|
| 10 |
+
*
|
| 11 |
+
* Deliberately NOT overriding computeSize/computeLayoutSize the way the editor
|
| 12 |
+
* panel does. Frontend 1.49.6 distributes a node's spare vertical space across
|
| 13 |
+
* its widgets by growing each from minHeight toward maxHeight; pinning both to
|
| 14 |
+
* the same value opts the widget out of that entirely, which is why the editor
|
| 15 |
+
* panel does not respond to node resize. Here we let the frontend size it and
|
| 16 |
+
* do the internal layout in CSS.
|
| 17 |
+
*
|
| 18 |
+
* There is no JS runtime on the machine this was written on, so this file is
|
| 19 |
+
* static-checked only.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
import { app } from "../../scripts/app.js";
|
| 23 |
+
import { api } from "../../scripts/api.js";
|
| 24 |
+
|
| 25 |
+
/* Both ids. The pack registers the pre-rename id as a deprecated subclass so
|
| 26 |
+
* workflows saved before 2026-08-29 still load. If this check knew only the
|
| 27 |
+
* new id those nodes would come up with NO editor at all, which looks exactly
|
| 28 |
+
* like the rename having broken the pack. */
|
| 29 |
+
const NODE_TYPES = new Set(["HTCChainPreview", "H3ChainPreview"]);
|
| 30 |
+
const CHAIN_TYPES = new Set(["HandTieClips", "H3RefChain"]);
|
| 31 |
+
const STYLE_ID = "h3rc-style";
|
| 32 |
+
const PANEL_PROP = "h3rcPanelH";
|
| 33 |
+
const _cssUrl = new URL("./h3_ref_chain.css", import.meta.url).href;
|
| 34 |
+
|
| 35 |
+
function ensureStyles() {
|
| 36 |
+
if (document.getElementById(STYLE_ID)) return;
|
| 37 |
+
const link = document.createElement("link");
|
| 38 |
+
link.id = STYLE_ID;
|
| 39 |
+
link.rel = "stylesheet";
|
| 40 |
+
link.href = _cssUrl;
|
| 41 |
+
document.head.appendChild(link);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function el(tag, className, text) {
|
| 45 |
+
const n = document.createElement(tag);
|
| 46 |
+
if (className) n.className = className;
|
| 47 |
+
if (text != null) n.textContent = text;
|
| 48 |
+
return n;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** Mirrors getNodeByExecutionId (not exported); subgraph-aware. */
|
| 52 |
+
function findNodeByQualifiedId(rootGraph, qid) {
|
| 53 |
+
if (!rootGraph || qid == null) return null;
|
| 54 |
+
const parts = String(qid).split(":");
|
| 55 |
+
let graph = rootGraph;
|
| 56 |
+
for (let i = 0; i < parts.length - 1; i++) {
|
| 57 |
+
const parentId = parseInt(parts[i], 10);
|
| 58 |
+
if (!Number.isFinite(parentId)) return null;
|
| 59 |
+
const parentNode = graph?.getNodeById?.(parentId);
|
| 60 |
+
if (!parentNode?.subgraph) return null;
|
| 61 |
+
graph = parentNode.subgraph;
|
| 62 |
+
}
|
| 63 |
+
const leafId = parseInt(parts[parts.length - 1], 10);
|
| 64 |
+
if (!Number.isFinite(leafId)) return null;
|
| 65 |
+
return graph?.getNodeById?.(leafId) || null;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Walk back from this node's `images` input to the chain node feeding it.
|
| 70 |
+
*
|
| 71 |
+
* Live progress events carry the *chain's* node id, so a graph with two chains
|
| 72 |
+
* needs to know which one belongs to this panel. Returns null when the input is
|
| 73 |
+
* unwired or the upstream is something else, in which case the panel falls back
|
| 74 |
+
* to accepting any chain's events -- correct for the common single-chain graph,
|
| 75 |
+
* and labelled as such so it cannot silently mislead.
|
| 76 |
+
*/
|
| 77 |
+
function upstreamChainId(node) {
|
| 78 |
+
let hops = 0;
|
| 79 |
+
let current = node;
|
| 80 |
+
let slotName = "images";
|
| 81 |
+
while (current && hops < 12) {
|
| 82 |
+
const input = (current.inputs || []).find((s) => s.name === slotName);
|
| 83 |
+
if (!input || input.link == null) return null;
|
| 84 |
+
const link = current.graph?.links?.[input.link];
|
| 85 |
+
if (!link) return null;
|
| 86 |
+
const src = current.graph.getNodeById?.(link.origin_id);
|
| 87 |
+
if (!src) return null;
|
| 88 |
+
if (CHAIN_TYPES.has(src.type)) return String(src.id);
|
| 89 |
+
// Pass through anything that forwards images (reroutes, other previews).
|
| 90 |
+
const forwarded = (src.inputs || []).find((s) => s.type === "IMAGE");
|
| 91 |
+
if (!forwarded) return null;
|
| 92 |
+
current = src;
|
| 93 |
+
slotName = forwarded.name;
|
| 94 |
+
hops += 1;
|
| 95 |
+
}
|
| 96 |
+
return null;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function clock(seconds) {
|
| 100 |
+
if (!Number.isFinite(seconds)) return "–";
|
| 101 |
+
const m = Math.floor(seconds / 60);
|
| 102 |
+
const s = seconds - m * 60;
|
| 103 |
+
return m ? `${m}:${s.toFixed(1).padStart(4, "0")}` : `${s.toFixed(1)}s`;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function mountPanel(node) {
|
| 107 |
+
ensureStyles();
|
| 108 |
+
|
| 109 |
+
const root = el("div", "h3rc-preview");
|
| 110 |
+
|
| 111 |
+
const frame = el("div", "h3rc-frame");
|
| 112 |
+
const imgA = el("img", "h3rc-img");
|
| 113 |
+
const imgB = el("img", "h3rc-img h3rc-seam-b");
|
| 114 |
+
const placeholder = el("div", "h3rc-placeholder", "waiting for sample…");
|
| 115 |
+
const seamLine = el("div", "h3rc-seam-line");
|
| 116 |
+
const tagA = el("div", "h3rc-seam-tag h3rc-tag-a", "prev");
|
| 117 |
+
const tagB = el("div", "h3rc-seam-tag h3rc-tag-b", "this hop");
|
| 118 |
+
frame.append(imgA, imgB, seamLine, tagA, tagB, placeholder);
|
| 119 |
+
root.appendChild(frame);
|
| 120 |
+
|
| 121 |
+
const bar = el("div", "h3rc-bar");
|
| 122 |
+
const fill = el("div", "h3rc-bar-fill");
|
| 123 |
+
bar.appendChild(fill);
|
| 124 |
+
root.appendChild(bar);
|
| 125 |
+
|
| 126 |
+
const grip = el("div", "h3rc-grip");
|
| 127 |
+
grip.setAttribute("role", "separator");
|
| 128 |
+
grip.setAttribute("aria-orientation", "horizontal");
|
| 129 |
+
grip.title = "Drag to resize · double-click to reset";
|
| 130 |
+
root.appendChild(grip);
|
| 131 |
+
|
| 132 |
+
const panel = el("div", "h3rc-panel");
|
| 133 |
+
const line = el("div", "h3rc-line");
|
| 134 |
+
line.appendChild(el("span", "h3rc-status-label", "status"));
|
| 135 |
+
const statusValue = el("span", "h3rc-status-value", "idle");
|
| 136 |
+
line.appendChild(statusValue);
|
| 137 |
+
const mech = el("span", "h3rc-mech");
|
| 138 |
+
mech.style.display = "none";
|
| 139 |
+
line.appendChild(mech);
|
| 140 |
+
panel.appendChild(line);
|
| 141 |
+
const meta = el("div", "h3rc-meta");
|
| 142 |
+
panel.appendChild(meta);
|
| 143 |
+
root.appendChild(panel);
|
| 144 |
+
|
| 145 |
+
/* -- resize grip ----------------------------------------------------- */
|
| 146 |
+
|
| 147 |
+
const DEFAULT_PANEL_H = 62;
|
| 148 |
+
const applyPanelH = (px) => { panel.style.height = `${Math.max(40, px)}px`; };
|
| 149 |
+
if (typeof node.properties?.[PANEL_PROP] === "number") {
|
| 150 |
+
applyPanelH(node.properties[PANEL_PROP]);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
let dragFrom = null;
|
| 154 |
+
grip.addEventListener("pointerdown", (e) => {
|
| 155 |
+
if (e.button !== 0) return;
|
| 156 |
+
e.preventDefault();
|
| 157 |
+
e.stopPropagation();
|
| 158 |
+
grip.setPointerCapture(e.pointerId);
|
| 159 |
+
dragFrom = { y: e.clientY, h: panel.offsetHeight };
|
| 160 |
+
});
|
| 161 |
+
grip.addEventListener("pointermove", (e) => {
|
| 162 |
+
if (!dragFrom) return;
|
| 163 |
+
// Divide by canvas zoom: clientY is screen space, offsetHeight is not.
|
| 164 |
+
const scale = app.canvas?.ds?.scale || 1;
|
| 165 |
+
const dy = (e.clientY - dragFrom.y) / scale;
|
| 166 |
+
const max = Math.max(40, root.clientHeight - 90);
|
| 167 |
+
applyPanelH(Math.min(max, dragFrom.h - dy));
|
| 168 |
+
});
|
| 169 |
+
const endDrag = (e) => {
|
| 170 |
+
if (!dragFrom) return;
|
| 171 |
+
dragFrom = null;
|
| 172 |
+
try { grip.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
|
| 173 |
+
// offsetHeight, not getBoundingClientRect().height -- the latter is
|
| 174 |
+
// zoom-scaled and would shrink the saved height on every release.
|
| 175 |
+
if (!node.properties) node.properties = {};
|
| 176 |
+
node.properties[PANEL_PROP] = panel.offsetHeight;
|
| 177 |
+
node.graph?.change?.();
|
| 178 |
+
};
|
| 179 |
+
grip.addEventListener("pointerup", endDrag);
|
| 180 |
+
grip.addEventListener("pointercancel", endDrag);
|
| 181 |
+
grip.addEventListener("dblclick", (e) => {
|
| 182 |
+
e.preventDefault();
|
| 183 |
+
e.stopPropagation();
|
| 184 |
+
applyPanelH(DEFAULT_PANEL_H);
|
| 185 |
+
if (!node.properties) node.properties = {};
|
| 186 |
+
node.properties[PANEL_PROP] = DEFAULT_PANEL_H;
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
// Keep canvas gestures out of the panel, but let the wheel through so the
|
| 190 |
+
// graph still zooms with the pointer over the preview.
|
| 191 |
+
for (const name of ["pointerdown", "mousedown", "click", "dblclick", "contextmenu"]) {
|
| 192 |
+
root.addEventListener(name, (e) => e.stopPropagation());
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* -- state ------------------------------------------------------------ */
|
| 196 |
+
|
| 197 |
+
const setImage = (target, b64) => {
|
| 198 |
+
target.src = `data:image/jpeg;base64,${b64}`;
|
| 199 |
+
target.style.display = "block";
|
| 200 |
+
placeholder.style.display = "none";
|
| 201 |
+
};
|
| 202 |
+
|
| 203 |
+
const api_ = {
|
| 204 |
+
set(data) {
|
| 205 |
+
if (data?.status != null) {
|
| 206 |
+
const s = String(data.status).replace(/\s+/g, " ").trim();
|
| 207 |
+
// Hard clip: this strip is one line, and passing the prompt
|
| 208 |
+
// dump through it once already turned the panel into the prompt.
|
| 209 |
+
statusValue.textContent = s.length > 80 ? `${s.slice(0, 77)}…` : s;
|
| 210 |
+
statusValue.title = s;
|
| 211 |
+
}
|
| 212 |
+
if (typeof data?.image === "string") setImage(imgA, data.image);
|
| 213 |
+
if (typeof data?.seam_image === "string") {
|
| 214 |
+
setImage(imgB, data.seam_image);
|
| 215 |
+
frame.classList.add("h3rc-showing-seam");
|
| 216 |
+
} else if (data?.image) {
|
| 217 |
+
imgB.style.display = "none";
|
| 218 |
+
frame.classList.remove("h3rc-showing-seam");
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
if (typeof data?.frac === "number") {
|
| 222 |
+
fill.style.width = `${Math.round(data.frac * 100)}%`;
|
| 223 |
+
fill.classList.toggle("h3rc-done", data.frac >= 1);
|
| 224 |
+
} else if (data?.hop && data?.total) {
|
| 225 |
+
fill.style.width = `${Math.round((data.hop / data.total) * 100)}%`;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
if (data?.pin_mech) {
|
| 229 |
+
mech.style.display = "";
|
| 230 |
+
mech.className = `h3rc-mech h3rc-mech-${data.pin_mech}`;
|
| 231 |
+
mech.textContent = data.pin_mech === "motion_context"
|
| 232 |
+
? "latent pin" : "pixel pin";
|
| 233 |
+
mech.title = data.pin_mech === "motion_context"
|
| 234 |
+
? "Motion-Context pinned the previous hop's sampler latent — the working join."
|
| 235 |
+
: "AddGuide pinned decoded pixels. This is the fallback: Motion-Context was "
|
| 236 |
+
+ "unavailable, the overlap is not one of its context lengths, or the previous "
|
| 237 |
+
+ "hop was cached by a build older than the latent sidecar.";
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const bits = [];
|
| 241 |
+
if (data?.hop && data?.total) bits.push(["hop", `${data.hop}/${data.total}`]);
|
| 242 |
+
if (data?.frames != null && data?.of_frames) bits.push(["frames", `${data.frames}/${data.of_frames}`]);
|
| 243 |
+
else if (data?.frames != null) bits.push(["frames", String(data.frames)]);
|
| 244 |
+
if (data?.cached) bits.push(["cache", `hit ${data.key || ""}`.trim()]);
|
| 245 |
+
if (data?.seed != null) bits.push(["seed", String(data.seed)]);
|
| 246 |
+
if (data?.steps != null) bits.push(["steps", String(data.steps)]);
|
| 247 |
+
if (data?.tone) bits.push(["tone", String(data.tone)]);
|
| 248 |
+
if (data?.video_s != null) bits.push(["video", clock(data.video_s)]);
|
| 249 |
+
if (data?.audio_s != null) bits.push(["audio", clock(data.audio_s)]);
|
| 250 |
+
if (data?.drift_ms != null) {
|
| 251 |
+
bits.push(["A/V drift", `${data.drift_ms > 0 ? "+" : ""}${data.drift_ms} ms`]);
|
| 252 |
+
}
|
| 253 |
+
if (data?.width && data?.height) bits.push(["size", `${data.width}x${data.height}`]);
|
| 254 |
+
if (bits.length) {
|
| 255 |
+
meta.textContent = "";
|
| 256 |
+
for (const [k, v] of bits) {
|
| 257 |
+
const cell = el("span", null, `${k} `);
|
| 258 |
+
cell.appendChild(el("b", null, v));
|
| 259 |
+
meta.appendChild(cell);
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
},
|
| 263 |
+
reset() {
|
| 264 |
+
imgA.style.display = "none";
|
| 265 |
+
imgB.style.display = "none";
|
| 266 |
+
frame.classList.remove("h3rc-showing-seam");
|
| 267 |
+
placeholder.style.display = "";
|
| 268 |
+
placeholder.textContent = "waiting for sample…";
|
| 269 |
+
statusValue.textContent = "queued";
|
| 270 |
+
statusValue.title = "";
|
| 271 |
+
mech.style.display = "none";
|
| 272 |
+
meta.textContent = "";
|
| 273 |
+
fill.style.width = "0";
|
| 274 |
+
fill.classList.remove("h3rc-done");
|
| 275 |
+
},
|
| 276 |
+
error(msg) {
|
| 277 |
+
placeholder.style.display = "";
|
| 278 |
+
placeholder.textContent = msg;
|
| 279 |
+
},
|
| 280 |
+
};
|
| 281 |
+
|
| 282 |
+
node._h3ChainPreview = api_;
|
| 283 |
+
const widget = node.addDOMWidget("preview", "h3rc_chain_preview", root, {
|
| 284 |
+
serialize: false,
|
| 285 |
+
hideOnZoom: false,
|
| 286 |
+
getMinHeight: () => 180,
|
| 287 |
+
getMaxHeight: () => 4000,
|
| 288 |
+
});
|
| 289 |
+
return widget;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
app.registerExtension({
|
| 293 |
+
name: "HandTieClips.ChainPreview",
|
| 294 |
+
|
| 295 |
+
async beforeRegisterNodeDef(nodeType, nodeData) {
|
| 296 |
+
if (!NODE_TYPES.has(nodeData?.name)) return;
|
| 297 |
+
|
| 298 |
+
const onCreated = nodeType.prototype.onNodeCreated;
|
| 299 |
+
nodeType.prototype.onNodeCreated = function () {
|
| 300 |
+
const r = onCreated?.apply(this, arguments);
|
| 301 |
+
mountPanel(this);
|
| 302 |
+
this.setSize([
|
| 303 |
+
Math.max(this.size?.[0] ?? 360, 360),
|
| 304 |
+
Math.max(this.size?.[1] ?? 420, 420),
|
| 305 |
+
]);
|
| 306 |
+
return r;
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
const onRemoved = nodeType.prototype.onRemoved;
|
| 310 |
+
nodeType.prototype.onRemoved = function () {
|
| 311 |
+
delete this._h3ChainPreview;
|
| 312 |
+
return onRemoved?.apply(this, arguments);
|
| 313 |
+
};
|
| 314 |
+
},
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
/* Live events from the chain node carry the CHAIN's node id, so each panel takes
|
| 318 |
+
* the ones from the chain feeding it. With no resolvable upstream a lone panel
|
| 319 |
+
* still shows the only chain running rather than sitting blank. */
|
| 320 |
+
function previewNodes(graph, out) {
|
| 321 |
+
for (const n of graph?._nodes || []) {
|
| 322 |
+
if (NODE_TYPES.has(n.type) && n._h3ChainPreview) out.push(n);
|
| 323 |
+
if (n.subgraph) previewNodes(n.subgraph, out);
|
| 324 |
+
}
|
| 325 |
+
return out;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
api.addEventListener("h3_refchain_preview", (e) => {
|
| 329 |
+
const data = e.detail;
|
| 330 |
+
if (!data) return;
|
| 331 |
+
const panels = previewNodes(app.graph, []);
|
| 332 |
+
if (!panels.length) return;
|
| 333 |
+
const chainId = data.node_id == null ? null : String(data.node_id);
|
| 334 |
+
const matched = panels.filter((n) => upstreamChainId(n) === chainId);
|
| 335 |
+
const targets = matched.length ? matched
|
| 336 |
+
: (panels.length === 1 && upstreamChainId(panels[0]) === null ? panels : []);
|
| 337 |
+
for (const n of targets) n._h3ChainPreview.set(data);
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
/* The preview node's own execution: final frame count, size and A/V drift. */
|
| 341 |
+
api.addEventListener("h3_chain_preview", (e) => {
|
| 342 |
+
const data = e.detail;
|
| 343 |
+
if (!data || data.node_id == null) return;
|
| 344 |
+
const node = findNodeByQualifiedId(app.graph, data.node_id);
|
| 345 |
+
node?._h3ChainPreview?.set(data);
|
| 346 |
+
});
|
| 347 |
+
|
| 348 |
+
/* A new run starts blank rather than showing the previous run's last frame --
|
| 349 |
+
* the old panel defined reset() and never called it. */
|
| 350 |
+
api.addEventListener("execution_start", () => {
|
| 351 |
+
for (const n of previewNodes(app.graph, [])) n._h3ChainPreview.reset();
|
| 352 |
+
});
|
|
@@ -0,0 +1,872 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* H3 Ref Chain -- node panel styling.
|
| 2 |
+
*
|
| 3 |
+
* External stylesheet rather than a template literal in JS: the old inline
|
| 4 |
+
* sheet hardcoded every colour as a hex, so a light ComfyUI theme rendered the
|
| 5 |
+
* panel as dark-grey islands on a light node. Everything below derives from
|
| 6 |
+
* ComfyUI's own custom properties through color-mix(), which is the idiom the
|
| 7 |
+
* sibling MiniMaxH3-Contex-Loop pack already uses.
|
| 8 |
+
*
|
| 9 |
+
* Class prefixes: .h3e- editor, .h3rc- preview.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
.h3e-root,
|
| 13 |
+
.h3rc-preview {
|
| 14 |
+
/* Black, one hairline, no radius -- the PromptMasterLD `.ldp-root` system.
|
| 15 |
+
*
|
| 16 |
+
* This used to derive every surface from the host theme with color-mix().
|
| 17 |
+
* The intent was light-theme safety; in practice --h3-bg mixed toward
|
| 18 |
+
* #111827 and --h3-sunken toward #000, so a light theme got dark blue-grey
|
| 19 |
+
* islands anyway while the accents stayed literal hex. Committing to one
|
| 20 |
+
* look is more honest than half-following two. */
|
| 21 |
+
--h3-bg: #0a0a0a;
|
| 22 |
+
--h3-panel: #111111;
|
| 23 |
+
--h3-sunken: #0d0d0d;
|
| 24 |
+
--h3-border: #2a2a2a;
|
| 25 |
+
--h3-border-soft: #1c1c1c;
|
| 26 |
+
|
| 27 |
+
--h3-text: #e8e8e8;
|
| 28 |
+
--h3-muted: #9a9a9a;
|
| 29 |
+
--h3-faint: #6a6a6a;
|
| 30 |
+
|
| 31 |
+
/* One accent. Change this line and the whole panel follows. */
|
| 32 |
+
--h3-accent: #e8ff47;
|
| 33 |
+
--h3-accent-soft: color-mix(in srgb, var(--h3-accent) 10%, transparent);
|
| 34 |
+
--h3-accent-wash: color-mix(in srgb, var(--h3-accent) 4%, transparent);
|
| 35 |
+
/* Text that sits ON the accent. Never white -- acid yellow needs ink. */
|
| 36 |
+
--h3-on-accent: #101010;
|
| 37 |
+
--h3-warn: #ffb020;
|
| 38 |
+
--h3-error: #ff3b30;
|
| 39 |
+
--h3-ok: #5fbf7a;
|
| 40 |
+
|
| 41 |
+
/* One spacing scale. */
|
| 42 |
+
--h3-gap: 8px;
|
| 43 |
+
--h3-gap-sm: 5px;
|
| 44 |
+
/* Square. Kept as tokens rather than deleted so a rounded variant is one
|
| 45 |
+
* edit, and so no rule has to change to get there. */
|
| 46 |
+
--h3-radius: 0px;
|
| 47 |
+
--h3-radius-sm: 0px;
|
| 48 |
+
--h3-row-h: 28px;
|
| 49 |
+
|
| 50 |
+
/* A type scale, which this sheet did not have. Hierarchy was carried by
|
| 51 |
+
* four literal sizes plus alpha, with nothing bold anywhere, which is why
|
| 52 |
+
* it read flat. */
|
| 53 |
+
--h3-fs-xs: 9px;
|
| 54 |
+
--h3-fs-sm: 10px;
|
| 55 |
+
--h3-fs: 11px;
|
| 56 |
+
--h3-fs-lg: 12px;
|
| 57 |
+
--h3-weight-label: 700;
|
| 58 |
+
/* One label idiom. Seven different letter-spacings were doing this job. */
|
| 59 |
+
--h3-track: .14em;
|
| 60 |
+
|
| 61 |
+
box-sizing: border-box;
|
| 62 |
+
color: var(--h3-text);
|
| 63 |
+
font: var(--h3-fs-lg)/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.h3e-root *,
|
| 67 |
+
.h3e-root *::before,
|
| 68 |
+
.h3e-root *::after,
|
| 69 |
+
.h3rc-preview *,
|
| 70 |
+
.h3rc-preview *::before,
|
| 71 |
+
.h3rc-preview *::after { box-sizing: border-box; }
|
| 72 |
+
|
| 73 |
+
/* ---------------------------------------------------------------- editor -- */
|
| 74 |
+
|
| 75 |
+
.h3e-root {
|
| 76 |
+
display: flex;
|
| 77 |
+
flex-direction: column;
|
| 78 |
+
gap: var(--h3-gap);
|
| 79 |
+
width: 100%;
|
| 80 |
+
height: 100%;
|
| 81 |
+
min-height: 0;
|
| 82 |
+
padding: 2px;
|
| 83 |
+
/* The root no longer scrolls -- .h3e-scroll does. See the note at the end
|
| 84 |
+
* of this sheet. */
|
| 85 |
+
overflow: hidden;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* The containment context the rail's @container queries need. Without this the
|
| 89 |
+
* queries below never match -- see the note above them. */
|
| 90 |
+
.h3e-section { container-type: inline-size; }
|
| 91 |
+
|
| 92 |
+
.h3e-section {
|
| 93 |
+
background: var(--h3-bg);
|
| 94 |
+
border: 1px solid var(--h3-border);
|
| 95 |
+
border-radius: var(--h3-radius);
|
| 96 |
+
padding: var(--h3-gap);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* Sticky header so "+ shot" stays reachable in a long plan. */
|
| 100 |
+
.h3e-head {
|
| 101 |
+
position: sticky;
|
| 102 |
+
top: calc(var(--h3-gap) * -1);
|
| 103 |
+
z-index: 4;
|
| 104 |
+
display: flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
gap: var(--h3-gap-sm);
|
| 107 |
+
margin: 0 0 var(--h3-gap);
|
| 108 |
+
padding: 4px 0;
|
| 109 |
+
background: var(--h3-bg);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.h3e-title {
|
| 113 |
+
font-size: var(--h3-fs-sm);
|
| 114 |
+
font-weight: var(--h3-weight-label);
|
| 115 |
+
letter-spacing: var(--h3-track);
|
| 116 |
+
text-transform: uppercase;
|
| 117 |
+
color: var(--h3-text);
|
| 118 |
+
}
|
| 119 |
+
.h3e-count { font-size: var(--h3-fs-sm); color: var(--h3-faint); font-variant-numeric: tabular-nums; }
|
| 120 |
+
.h3e-spacer { flex: 1; }
|
| 121 |
+
|
| 122 |
+
/* -- controls ------------------------------------------------------------- */
|
| 123 |
+
|
| 124 |
+
.h3e-root button,
|
| 125 |
+
.h3e-root input,
|
| 126 |
+
.h3e-root select,
|
| 127 |
+
.h3e-root textarea {
|
| 128 |
+
font: inherit;
|
| 129 |
+
color: var(--h3-text);
|
| 130 |
+
background: var(--h3-sunken);
|
| 131 |
+
border: 1px solid var(--h3-border);
|
| 132 |
+
border-radius: var(--h3-radius-sm);
|
| 133 |
+
padding: 5px 7px;
|
| 134 |
+
min-width: 0;
|
| 135 |
+
transition: border-color .13s ease, background-color .13s ease, opacity .13s ease;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.h3e-root textarea { resize: vertical; line-height: 1.45; }
|
| 139 |
+
|
| 140 |
+
.h3e-root button { cursor: pointer; white-space: nowrap; background: var(--h3-panel); }
|
| 141 |
+
.h3e-root button:hover { border-color: var(--h3-accent); }
|
| 142 |
+
.h3e-root button:active { background: color-mix(in srgb, var(--h3-accent) 22%, var(--h3-panel)); }
|
| 143 |
+
.h3e-root button:disabled { cursor: not-allowed; opacity: .4; }
|
| 144 |
+
|
| 145 |
+
.h3e-root input:hover,
|
| 146 |
+
.h3e-root select:hover,
|
| 147 |
+
.h3e-root textarea:hover { border-color: color-mix(in srgb, var(--h3-accent) 55%, var(--h3-border)); }
|
| 148 |
+
|
| 149 |
+
/* Keyboard focus was completely invisible before -- ~8 controls per card. */
|
| 150 |
+
.h3e-root :focus-visible {
|
| 151 |
+
outline: 2px solid var(--h3-accent);
|
| 152 |
+
outline-offset: 1px;
|
| 153 |
+
border-color: var(--h3-accent);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.h3e-btn { padding: 5px 9px; }
|
| 157 |
+
.h3e-x {
|
| 158 |
+
padding: 4px 8px;
|
| 159 |
+
color: var(--h3-error);
|
| 160 |
+
background: transparent;
|
| 161 |
+
border-color: var(--h3-border-soft);
|
| 162 |
+
}
|
| 163 |
+
.h3e-x:hover { border-color: var(--h3-error); background: color-mix(in srgb, var(--h3-error) 14%, transparent); }
|
| 164 |
+
|
| 165 |
+
/* -- Simple / Shots toggle ------------------------------------------------ */
|
| 166 |
+
|
| 167 |
+
.h3e-modes { display: flex; border: 1px solid var(--h3-border); border-radius: var(--h3-radius-sm); overflow: hidden; }
|
| 168 |
+
.h3e-root .h3e-mode {
|
| 169 |
+
border: 0;
|
| 170 |
+
border-radius: 0;
|
| 171 |
+
background: var(--h3-sunken);
|
| 172 |
+
color: var(--h3-muted);
|
| 173 |
+
padding: 5px 12px;
|
| 174 |
+
font-size: 11px;
|
| 175 |
+
}
|
| 176 |
+
.h3e-root .h3e-mode:hover { background: color-mix(in srgb, var(--h3-accent) 12%, var(--h3-sunken)); color: var(--h3-text); }
|
| 177 |
+
.h3e-root .h3e-mode[aria-pressed="true"],
|
| 178 |
+
.h3e-root .h3e-mode.h3e-on {
|
| 179 |
+
background: color-mix(in srgb, var(--h3-accent) 34%, var(--h3-sunken));
|
| 180 |
+
color: var(--h3-text);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.h3e-empty {
|
| 184 |
+
color: var(--h3-muted);
|
| 185 |
+
padding: 14px 10px;
|
| 186 |
+
line-height: 1.6;
|
| 187 |
+
text-align: center;
|
| 188 |
+
border: 1px dashed var(--h3-border-soft);
|
| 189 |
+
border-radius: var(--h3-radius-sm);
|
| 190 |
+
}
|
| 191 |
+
.h3e-empty-cta { display: block; margin: 8px auto 0; }
|
| 192 |
+
|
| 193 |
+
/* -- shot cards ----------------------------------------------------------- */
|
| 194 |
+
|
| 195 |
+
.h3e-card {
|
| 196 |
+
--h3-card-accent: var(--h3-accent);
|
| 197 |
+
background:
|
| 198 |
+
linear-gradient(90deg, color-mix(in srgb, var(--h3-card-accent) 7%, transparent), transparent 140px),
|
| 199 |
+
var(--h3-panel);
|
| 200 |
+
border: 1px solid color-mix(in srgb, var(--h3-card-accent) 46%, var(--h3-border));
|
| 201 |
+
border-left: 4px solid var(--h3-card-accent);
|
| 202 |
+
border-radius: var(--h3-radius);
|
| 203 |
+
padding: var(--h3-gap-sm) var(--h3-gap);
|
| 204 |
+
margin-bottom: var(--h3-gap-sm);
|
| 205 |
+
transition: border-color .13s ease, box-shadow .13s ease;
|
| 206 |
+
}
|
| 207 |
+
.h3e-card:hover { border-color: color-mix(in srgb, var(--h3-card-accent) 70%, var(--h3-border)); }
|
| 208 |
+
.h3e-card.h3e-drop,
|
| 209 |
+
.h3e-card.h3e-drag-over { outline: 2px solid var(--h3-accent); outline-offset: 1px; }
|
| 210 |
+
.h3e-card.h3e-dragging { opacity: .45; }
|
| 211 |
+
.h3e-card.h3e-invalid { border-left-color: var(--h3-error); }
|
| 212 |
+
|
| 213 |
+
.h3e-card-head {
|
| 214 |
+
display: flex;
|
| 215 |
+
align-items: center;
|
| 216 |
+
gap: var(--h3-gap-sm);
|
| 217 |
+
min-height: var(--h3-row-h);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/* A one-character glyph is not a drag target. */
|
| 221 |
+
.h3e-drag {
|
| 222 |
+
cursor: grab;
|
| 223 |
+
user-select: none;
|
| 224 |
+
padding: 4px 5px;
|
| 225 |
+
border-radius: 4px;
|
| 226 |
+
color: var(--h3-faint);
|
| 227 |
+
line-height: 1;
|
| 228 |
+
}
|
| 229 |
+
.h3e-drag:hover { color: var(--h3-text); background: var(--h3-accent-soft); }
|
| 230 |
+
.h3e-drag:active { cursor: grabbing; }
|
| 231 |
+
|
| 232 |
+
/* Collapse/expand affordance. */
|
| 233 |
+
.h3e-root .h3e-fold {
|
| 234 |
+
padding: 3px 6px;
|
| 235 |
+
background: transparent;
|
| 236 |
+
border-color: transparent;
|
| 237 |
+
color: var(--h3-muted);
|
| 238 |
+
font-size: 10px;
|
| 239 |
+
line-height: 1;
|
| 240 |
+
}
|
| 241 |
+
.h3e-root .h3e-fold:hover { color: var(--h3-text); background: var(--h3-accent-soft); border-color: transparent; }
|
| 242 |
+
.h3e-card.h3e-collapsed .h3e-card-body { display: none; }
|
| 243 |
+
.h3e-card.h3e-collapsed { padding-bottom: var(--h3-gap-sm); }
|
| 244 |
+
.h3e-fold-mark { display: inline-block; transition: transform .15s ease; }
|
| 245 |
+
.h3e-card.h3e-collapsed .h3e-fold-mark { transform: rotate(-90deg); }
|
| 246 |
+
|
| 247 |
+
.h3e-num,
|
| 248 |
+
.h3e-ord {
|
| 249 |
+
background: color-mix(in srgb, var(--h3-accent) 28%, var(--h3-sunken));
|
| 250 |
+
color: var(--h3-text);
|
| 251 |
+
border-radius: 4px;
|
| 252 |
+
padding: 2px 8px;
|
| 253 |
+
font-size: 10px;
|
| 254 |
+
font-variant-numeric: tabular-nums;
|
| 255 |
+
white-space: nowrap;
|
| 256 |
+
}
|
| 257 |
+
.h3e-timing { font-size: 10px; color: var(--h3-muted); font-variant-numeric: tabular-nums; }
|
| 258 |
+
.h3e-lock { font-size: 10px; color: var(--h3-warn); }
|
| 259 |
+
.h3e-peek {
|
| 260 |
+
flex: 1;
|
| 261 |
+
min-width: 0;
|
| 262 |
+
overflow: hidden;
|
| 263 |
+
text-overflow: ellipsis;
|
| 264 |
+
white-space: nowrap;
|
| 265 |
+
font-size: 10px;
|
| 266 |
+
color: var(--h3-faint);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.h3e-beat { width: 100%; background: var(--h3-sunken); }
|
| 270 |
+
|
| 271 |
+
/* -- directive row: grid, not fixed-width flex ---------------------------- */
|
| 272 |
+
|
| 273 |
+
.h3e-dirs {
|
| 274 |
+
display: grid;
|
| 275 |
+
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
| 276 |
+
gap: var(--h3-gap-sm);
|
| 277 |
+
margin-top: var(--h3-gap-sm);
|
| 278 |
+
}
|
| 279 |
+
.h3e-dir { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
| 280 |
+
.h3e-dir-label,
|
| 281 |
+
.h3e-field > span:first-child {
|
| 282 |
+
font-size: var(--h3-fs-xs);
|
| 283 |
+
font-weight: var(--h3-weight-label);
|
| 284 |
+
letter-spacing: var(--h3-track);
|
| 285 |
+
text-transform: uppercase;
|
| 286 |
+
color: var(--h3-muted);
|
| 287 |
+
}
|
| 288 |
+
.h3e-select, .h3e-input { width: 100%; }
|
| 289 |
+
|
| 290 |
+
/* -- notes: hint / warning / error are three different things ------------- */
|
| 291 |
+
|
| 292 |
+
.h3e-note {
|
| 293 |
+
border-radius: var(--h3-radius-sm);
|
| 294 |
+
padding: 6px 8px;
|
| 295 |
+
margin-top: var(--h3-gap-sm);
|
| 296 |
+
line-height: 1.5;
|
| 297 |
+
border: 1px solid;
|
| 298 |
+
color: var(--h3-warn);
|
| 299 |
+
background: color-mix(in srgb, var(--h3-warn) 12%, transparent);
|
| 300 |
+
border-color: color-mix(in srgb, var(--h3-warn) 40%, transparent);
|
| 301 |
+
}
|
| 302 |
+
.h3e-note.h3e-note-error {
|
| 303 |
+
color: var(--h3-error);
|
| 304 |
+
background: color-mix(in srgb, var(--h3-error) 13%, transparent);
|
| 305 |
+
border-color: color-mix(in srgb, var(--h3-error) 45%, transparent);
|
| 306 |
+
}
|
| 307 |
+
.h3e-note.h3e-note-hint {
|
| 308 |
+
color: var(--h3-muted);
|
| 309 |
+
background: color-mix(in srgb, var(--h3-accent) 8%, transparent);
|
| 310 |
+
border-color: var(--h3-border-soft);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.h3e-adv { margin-top: var(--h3-gap-sm); }
|
| 314 |
+
.h3e-adv > summary,
|
| 315 |
+
.h3e-json-wrap > summary {
|
| 316 |
+
cursor: pointer;
|
| 317 |
+
color: var(--h3-muted);
|
| 318 |
+
font-size: 10px;
|
| 319 |
+
letter-spacing: .08em;
|
| 320 |
+
text-transform: uppercase;
|
| 321 |
+
padding: 3px 2px;
|
| 322 |
+
border-radius: 4px;
|
| 323 |
+
}
|
| 324 |
+
.h3e-adv > summary:hover,
|
| 325 |
+
.h3e-json-wrap > summary:hover { color: var(--h3-text); background: var(--h3-accent-soft); }
|
| 326 |
+
|
| 327 |
+
.h3e-grid {
|
| 328 |
+
display: grid;
|
| 329 |
+
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
|
| 330 |
+
gap: var(--h3-gap-sm);
|
| 331 |
+
margin: var(--h3-gap-sm) 0;
|
| 332 |
+
}
|
| 333 |
+
.h3e-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
| 334 |
+
.h3e-field .h3e-input, .h3e-field .h3e-select { width: 100%; }
|
| 335 |
+
.h3e-check { flex-direction: row; align-items: center; gap: 6px; }
|
| 336 |
+
.h3e-check input { width: auto; }
|
| 337 |
+
|
| 338 |
+
/* -- run panel ------------------------------------------------------------ */
|
| 339 |
+
|
| 340 |
+
/* A <details> wearing .h3e-section, so it keeps the panel chrome whether it is
|
| 341 |
+
* open or shut. Collapsed by default: these dials are set once and then left,
|
| 342 |
+
* unlike the script above them, and the summary line carries enough of the
|
| 343 |
+
* state that opening it is a choice rather than a check. */
|
| 344 |
+
.h3e-run { padding: var(--h3-gap); }
|
| 345 |
+
.h3e-run > summary {
|
| 346 |
+
cursor: pointer;
|
| 347 |
+
display: flex;
|
| 348 |
+
align-items: baseline;
|
| 349 |
+
gap: var(--h3-gap-sm);
|
| 350 |
+
padding: 2px;
|
| 351 |
+
border-radius: 4px;
|
| 352 |
+
list-style: none; /* the default triangle sits badly on a row */
|
| 353 |
+
min-width: 0;
|
| 354 |
+
}
|
| 355 |
+
.h3e-run > summary::-webkit-details-marker { display: none; }
|
| 356 |
+
.h3e-run > summary::before {
|
| 357 |
+
content: "▸";
|
| 358 |
+
color: var(--h3-faint);
|
| 359 |
+
font-size: 9px;
|
| 360 |
+
transition: transform .12s ease;
|
| 361 |
+
}
|
| 362 |
+
.h3e-run[open] > summary::before { transform: rotate(90deg); }
|
| 363 |
+
.h3e-run > summary:hover { background: var(--h3-accent-soft); }
|
| 364 |
+
.h3e-run > summary:hover .h3e-title { color: var(--h3-text); }
|
| 365 |
+
|
| 366 |
+
/* The digest is one line and must stay one line: it is read at a glance with
|
| 367 |
+
* the panel shut, and wrapping it would push the script off screen. */
|
| 368 |
+
.h3e-run-digest {
|
| 369 |
+
flex: 1;
|
| 370 |
+
min-width: 0;
|
| 371 |
+
overflow: hidden;
|
| 372 |
+
text-overflow: ellipsis;
|
| 373 |
+
white-space: nowrap;
|
| 374 |
+
text-align: right;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.h3e-run-body { margin-top: var(--h3-gap-sm); }
|
| 378 |
+
.h3e-run-group + .h3e-run-group {
|
| 379 |
+
margin-top: var(--h3-gap-sm);
|
| 380 |
+
padding-top: var(--h3-gap-sm);
|
| 381 |
+
border-top: 1px solid var(--h3-border-soft);
|
| 382 |
+
}
|
| 383 |
+
.h3e-run-label {
|
| 384 |
+
font-size: var(--h3-fs-xs);
|
| 385 |
+
font-weight: var(--h3-weight-label);
|
| 386 |
+
letter-spacing: var(--h3-track);
|
| 387 |
+
text-transform: uppercase;
|
| 388 |
+
color: var(--h3-muted);
|
| 389 |
+
}
|
| 390 |
+
/* auto-FILL, not auto-fit: auto-fit collapses the empty tracks, so the two
|
| 391 |
+
* dials in CACHE stretched to half the panel each while the four in OUTPUT sat
|
| 392 |
+
* at a quarter. Keeping the empty tracks lines every group up on one grid. */
|
| 393 |
+
.h3e-run-group .h3e-grid {
|
| 394 |
+
margin-top: 4px;
|
| 395 |
+
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
| 396 |
+
}
|
| 397 |
+
.h3e-run .h3e-run-wide { grid-column: span 2; }
|
| 398 |
+
/* Digits line up column-wise, so a changed seed is visible at a glance. */
|
| 399 |
+
.h3e-run input[type="number"] { font-variant-numeric: tabular-nums; }
|
| 400 |
+
|
| 401 |
+
/* A checkbox plus the widget's own label_on/label_off, so the state reads as
|
| 402 |
+
* "vary per hop" rather than as a bare tick. */
|
| 403 |
+
.h3e-run-toggle { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
| 404 |
+
.h3e-root .h3e-run-toggle input { width: auto; flex: none; }
|
| 405 |
+
.h3e-run-togglabel {
|
| 406 |
+
overflow: hidden;
|
| 407 |
+
text-overflow: ellipsis;
|
| 408 |
+
white-space: nowrap;
|
| 409 |
+
color: var(--h3-muted);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
/* -- reference rail ------------------------------------------------------- */
|
| 414 |
+
|
| 415 |
+
/* Was fixed pixel widths in a non-wrapping flex row summing to ~406px, so the
|
| 416 |
+
* description got crushed and the row overflowed a narrow node. */
|
| 417 |
+
.h3e-ref {
|
| 418 |
+
display: grid;
|
| 419 |
+
grid-template-columns: auto minmax(88px, 1.1fr) minmax(74px, .8fr) minmax(74px, .8fr) minmax(96px, 1fr) minmax(120px, 1.6fr) auto;
|
| 420 |
+
align-items: center;
|
| 421 |
+
gap: var(--h3-gap-sm);
|
| 422 |
+
padding: 4px;
|
| 423 |
+
margin-bottom: 4px;
|
| 424 |
+
min-height: var(--h3-row-h);
|
| 425 |
+
border: 1px solid transparent;
|
| 426 |
+
border-radius: var(--h3-radius-sm);
|
| 427 |
+
transition: background-color .13s ease, border-color .13s ease;
|
| 428 |
+
}
|
| 429 |
+
.h3e-ref:hover { background: var(--h3-panel); border-color: var(--h3-border-soft); }
|
| 430 |
+
.h3e-ref.h3e-inactive { opacity: .55; }
|
| 431 |
+
.h3e-ref.h3e-bad { border-color: color-mix(in srgb, var(--h3-error) 55%, transparent); }
|
| 432 |
+
.h3e-ref.h3e-bad .h3e-tag { border-color: var(--h3-error); }
|
| 433 |
+
.h3e-inactive .h3e-ord { background: var(--h3-sunken); color: var(--h3-faint); }
|
| 434 |
+
.h3e-tag, .h3e-slot, .h3e-subj, .h3e-ret, .h3e-desc { width: 100%; }
|
| 435 |
+
|
| 436 |
+
/* The rail's 7-track grid needs ~536px of content width and the node only ever
|
| 437 |
+
* offers ~510px at NODE_WIDTH 560, so it is crushed by default. These queries
|
| 438 |
+
* are the relief -- and until `container-type` was declared on .h3e-section
|
| 439 |
+
* (below) they matched nothing at all, because a @container query without a
|
| 440 |
+
* containment context is dead code. That is why the rail looked mangled at
|
| 441 |
+
* every node width. */
|
| 442 |
+
@container (max-width: 560px) {
|
| 443 |
+
/* Drop the description to its own line first: it is the widest track and
|
| 444 |
+
* the least useful one to read mid-row. */
|
| 445 |
+
.h3e-ref {
|
| 446 |
+
grid-template-columns: auto minmax(88px, 1.2fr) minmax(74px, .9fr) minmax(74px, .9fr) minmax(96px, 1fr) auto;
|
| 447 |
+
}
|
| 448 |
+
.h3e-ref .h3e-desc { grid-column: 2 / -1; }
|
| 449 |
+
}
|
| 450 |
+
@container (max-width: 430px) {
|
| 451 |
+
/* Then stack: tag on the top row with the ordinal and the kill, the three
|
| 452 |
+
* selects sharing the next, description below that. */
|
| 453 |
+
.h3e-ref { grid-template-columns: auto 1fr auto; }
|
| 454 |
+
.h3e-ref .h3e-slot,
|
| 455 |
+
.h3e-ref .h3e-subj { grid-column: 2 / 3; }
|
| 456 |
+
.h3e-ref .h3e-ret,
|
| 457 |
+
.h3e-ref .h3e-desc { grid-column: 1 / -1; }
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
/* Per-hop schedule chips. */
|
| 461 |
+
.h3e-chips { display: flex; flex-wrap: wrap; gap: 3px; grid-column: 1 / -1; padding: 2px 0 0 26px; }
|
| 462 |
+
.h3e-chip {
|
| 463 |
+
font-size: 9px;
|
| 464 |
+
line-height: 1;
|
| 465 |
+
padding: 3px 6px;
|
| 466 |
+
border-radius: 999px;
|
| 467 |
+
border: 1px solid var(--h3-border-soft);
|
| 468 |
+
background: var(--h3-sunken);
|
| 469 |
+
color: var(--h3-faint);
|
| 470 |
+
cursor: pointer;
|
| 471 |
+
user-select: none;
|
| 472 |
+
transition: background-color .12s ease, color .12s ease, border-color .12s ease;
|
| 473 |
+
}
|
| 474 |
+
.h3e-chip:hover { border-color: var(--h3-accent); color: var(--h3-text); }
|
| 475 |
+
.h3e-chip.h3e-chip-on {
|
| 476 |
+
background: color-mix(in srgb, var(--h3-accent) 30%, var(--h3-sunken));
|
| 477 |
+
color: var(--h3-text);
|
| 478 |
+
border-color: color-mix(in srgb, var(--h3-accent) 55%, transparent);
|
| 479 |
+
}
|
| 480 |
+
.h3e-chips-label { font-size: 9px; color: var(--h3-faint); align-self: center; margin-right: 2px; letter-spacing: .06em; }
|
| 481 |
+
|
| 482 |
+
.h3e-subject {
|
| 483 |
+
display: flex;
|
| 484 |
+
align-items: center;
|
| 485 |
+
gap: var(--h3-gap-sm);
|
| 486 |
+
margin: 4px 0 var(--h3-gap-sm) 26px;
|
| 487 |
+
}
|
| 488 |
+
.h3e-subject .h3e-input { flex: 1; }
|
| 489 |
+
.h3e-subj-badge {
|
| 490 |
+
/* Was a hardcoded #a98bd8 that appeared nowhere else in the sheet and read
|
| 491 |
+
* as an unexplained colour code beside the ordinal badge. A subject is a
|
| 492 |
+
* different KIND of thing, not a different priority: outline, not fill. */
|
| 493 |
+
background: transparent;
|
| 494 |
+
border: 1px solid var(--h3-accent);
|
| 495 |
+
color: var(--h3-accent);
|
| 496 |
+
border-radius: 4px;
|
| 497 |
+
padding: 2px 8px;
|
| 498 |
+
font-size: 10px;
|
| 499 |
+
white-space: nowrap;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.h3e-json {
|
| 503 |
+
width: 100%;
|
| 504 |
+
min-height: 120px;
|
| 505 |
+
background: var(--h3-sunken);
|
| 506 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
| 507 |
+
font-size: 10px;
|
| 508 |
+
margin-top: 4px;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
/* -- vocabulary unavailable ----------------------------------------------- */
|
| 512 |
+
|
| 513 |
+
.h3e-offline {
|
| 514 |
+
display: flex;
|
| 515 |
+
align-items: center;
|
| 516 |
+
gap: var(--h3-gap-sm);
|
| 517 |
+
margin-bottom: var(--h3-gap-sm);
|
| 518 |
+
}
|
| 519 |
+
.h3e-offline .h3e-btn { margin-left: auto; }
|
| 520 |
+
|
| 521 |
+
/* --------------------------------------------------------------- preview -- */
|
| 522 |
+
|
| 523 |
+
.h3rc-preview {
|
| 524 |
+
display: flex;
|
| 525 |
+
flex-direction: column;
|
| 526 |
+
width: 100%;
|
| 527 |
+
height: 100%;
|
| 528 |
+
min-height: 0;
|
| 529 |
+
background: var(--h3-bg);
|
| 530 |
+
border: 1px solid var(--h3-border);
|
| 531 |
+
border-radius: var(--h3-radius);
|
| 532 |
+
overflow: hidden;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
.h3rc-frame { position: relative; flex: 1 1 auto; min-height: 60px; background: var(--h3-sunken); }
|
| 536 |
+
.h3rc-img {
|
| 537 |
+
position: absolute; inset: 0;
|
| 538 |
+
width: 100%; height: 100%;
|
| 539 |
+
object-fit: contain;
|
| 540 |
+
display: none;
|
| 541 |
+
}
|
| 542 |
+
.h3rc-img.h3rc-seam-b { clip-path: inset(0 0 0 50%); }
|
| 543 |
+
|
| 544 |
+
.h3rc-placeholder {
|
| 545 |
+
position: absolute; inset: 0;
|
| 546 |
+
display: flex; align-items: center; justify-content: center;
|
| 547 |
+
color: var(--h3-faint);
|
| 548 |
+
font-size: 12px;
|
| 549 |
+
letter-spacing: .02em;
|
| 550 |
+
user-select: none;
|
| 551 |
+
text-align: center;
|
| 552 |
+
padding: 0 12px;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
/* Seam divider, only while two frames are shown. */
|
| 556 |
+
.h3rc-seam-line {
|
| 557 |
+
position: absolute; top: 0; bottom: 0; left: 50%;
|
| 558 |
+
width: 1px;
|
| 559 |
+
background: color-mix(in srgb, var(--h3-accent) 70%, transparent);
|
| 560 |
+
display: none;
|
| 561 |
+
}
|
| 562 |
+
.h3rc-seam-tag {
|
| 563 |
+
position: absolute; bottom: 6px;
|
| 564 |
+
font-size: 9px; letter-spacing: .08em; text-transform: uppercase;
|
| 565 |
+
color: var(--h3-text);
|
| 566 |
+
background: color-mix(in srgb, #000 55%, transparent);
|
| 567 |
+
padding: 2px 6px; border-radius: 3px;
|
| 568 |
+
display: none;
|
| 569 |
+
}
|
| 570 |
+
.h3rc-seam-tag.h3rc-tag-a { left: 6px; }
|
| 571 |
+
.h3rc-seam-tag.h3rc-tag-b { right: 6px; }
|
| 572 |
+
.h3rc-showing-seam .h3rc-seam-line,
|
| 573 |
+
.h3rc-showing-seam .h3rc-seam-tag { display: block; }
|
| 574 |
+
|
| 575 |
+
/* Progress across the whole chain. */
|
| 576 |
+
.h3rc-bar { flex: 0 0 3px; background: var(--h3-sunken); overflow: hidden; }
|
| 577 |
+
.h3rc-bar-fill {
|
| 578 |
+
height: 100%;
|
| 579 |
+
width: 0;
|
| 580 |
+
background: var(--h3-accent);
|
| 581 |
+
transition: width .25s ease;
|
| 582 |
+
}
|
| 583 |
+
.h3rc-bar-fill.h3rc-done { background: var(--h3-ok); }
|
| 584 |
+
|
| 585 |
+
.h3rc-panel {
|
| 586 |
+
flex: 0 0 auto;
|
| 587 |
+
display: flex;
|
| 588 |
+
flex-direction: column;
|
| 589 |
+
gap: 3px;
|
| 590 |
+
padding: 6px 9px;
|
| 591 |
+
background: var(--h3-panel);
|
| 592 |
+
border-top: 1px solid var(--h3-border-soft);
|
| 593 |
+
}
|
| 594 |
+
.h3rc-line { display: flex; align-items: center; gap: var(--h3-gap-sm); font-size: 11px; min-width: 0; }
|
| 595 |
+
.h3rc-status-label { color: var(--h3-muted); flex: 0 0 auto; }
|
| 596 |
+
.h3rc-status-value {
|
| 597 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
| 598 |
+
min-width: 0; flex: 1;
|
| 599 |
+
color: var(--h3-text);
|
| 600 |
+
font-variant-numeric: tabular-nums;
|
| 601 |
+
}
|
| 602 |
+
.h3rc-meta {
|
| 603 |
+
display: flex; flex-wrap: wrap; gap: 4px 10px;
|
| 604 |
+
font-size: 10px; color: var(--h3-muted);
|
| 605 |
+
font-variant-numeric: tabular-nums;
|
| 606 |
+
}
|
| 607 |
+
.h3rc-meta b { font-weight: 600; color: var(--h3-text); }
|
| 608 |
+
.h3rc-mech { padding: 1px 6px; border-radius: 999px; font-size: 9px; letter-spacing: .04em; }
|
| 609 |
+
.h3rc-mech-motion_context { background: color-mix(in srgb, var(--h3-ok) 26%, transparent); color: var(--h3-text); }
|
| 610 |
+
.h3rc-mech-addguide_pixels { background: color-mix(in srgb, var(--h3-warn) 30%, transparent); color: var(--h3-text); }
|
| 611 |
+
|
| 612 |
+
/* Resize grip -- pointer capture, unscaled offsetHeight. */
|
| 613 |
+
.h3rc-grip {
|
| 614 |
+
flex: 0 0 11px;
|
| 615 |
+
position: relative;
|
| 616 |
+
cursor: ns-resize;
|
| 617 |
+
background: var(--h3-panel);
|
| 618 |
+
border-top: 1px solid var(--h3-border-soft);
|
| 619 |
+
}
|
| 620 |
+
.h3rc-grip::after {
|
| 621 |
+
content: "";
|
| 622 |
+
position: absolute;
|
| 623 |
+
left: calc(50% - 18px);
|
| 624 |
+
top: 4px;
|
| 625 |
+
width: 36px; height: 2px;
|
| 626 |
+
border-radius: 2px;
|
| 627 |
+
background: var(--h3-faint);
|
| 628 |
+
}
|
| 629 |
+
.h3rc-grip:hover::after { background: var(--h3-accent); }
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
/* -- accent states, pickers, media strip ----------------------------------
|
| 633 |
+
*
|
| 634 |
+
* The old palette was a soft blue, so "active" could be a 28-34% tint sitting
|
| 635 |
+
* behind unchanged text. An acid accent cannot do that -- the result is olive
|
| 636 |
+
* mud -- so emphasis inverts instead: accent fill, ink text. Same rule
|
| 637 |
+
* PromptMasterLD uses, and the reason --h3-on-accent exists.
|
| 638 |
+
*/
|
| 639 |
+
.h3e-root .h3e-mode.h3e-on {
|
| 640 |
+
background: var(--h3-accent);
|
| 641 |
+
color: var(--h3-on-accent);
|
| 642 |
+
font-weight: 700;
|
| 643 |
+
}
|
| 644 |
+
.h3e-num,
|
| 645 |
+
.h3e-ord {
|
| 646 |
+
background: var(--h3-accent);
|
| 647 |
+
color: var(--h3-on-accent);
|
| 648 |
+
font-weight: 700;
|
| 649 |
+
}
|
| 650 |
+
.h3e-inactive .h3e-ord {
|
| 651 |
+
background: var(--h3-sunken);
|
| 652 |
+
color: var(--h3-faint);
|
| 653 |
+
}
|
| 654 |
+
.h3e-chip.h3e-chip-on {
|
| 655 |
+
background: var(--h3-accent);
|
| 656 |
+
color: var(--h3-on-accent);
|
| 657 |
+
border-color: var(--h3-accent);
|
| 658 |
+
font-weight: 700;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
/* Grouping by a left rule and a wash rather than an outlined box. */
|
| 662 |
+
.h3e-section {
|
| 663 |
+
border: none;
|
| 664 |
+
border-left: 2px solid var(--h3-border);
|
| 665 |
+
background: var(--h3-bg);
|
| 666 |
+
}
|
| 667 |
+
.h3e-card { border-left-width: 2px; }
|
| 668 |
+
.h3e-run-group {
|
| 669 |
+
border-left: 2px solid var(--h3-accent);
|
| 670 |
+
background: var(--h3-accent-wash);
|
| 671 |
+
padding-left: var(--h3-gap-sm);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
/* -- media pickers --------------------------------------------------------
|
| 675 |
+
*
|
| 676 |
+
* Empty is dashed, filled is solid, so the border alone says whether a slot
|
| 677 |
+
* holds something -- readable at a glance across nine of them. `contain` and
|
| 678 |
+
* never `cover`: the whole job of this thumbnail is letting you identify the
|
| 679 |
+
* picture, and a cropped face is worse than a letterboxed one.
|
| 680 |
+
*/
|
| 681 |
+
.h3e-pick { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
| 682 |
+
.h3e-thumb {
|
| 683 |
+
position: relative;
|
| 684 |
+
width: 100%;
|
| 685 |
+
height: 46px;
|
| 686 |
+
border: 1px dashed var(--h3-border);
|
| 687 |
+
background: var(--h3-sunken);
|
| 688 |
+
display: flex;
|
| 689 |
+
align-items: center;
|
| 690 |
+
justify-content: center;
|
| 691 |
+
cursor: pointer;
|
| 692 |
+
overflow: hidden;
|
| 693 |
+
transition: border-color .12s ease, background-color .12s ease;
|
| 694 |
+
}
|
| 695 |
+
.h3e-thumb:hover { border-color: var(--h3-accent); }
|
| 696 |
+
.h3e-thumb.h3e-has { border-style: solid; }
|
| 697 |
+
.h3e-thumb.h3e-hot {
|
| 698 |
+
border-style: solid;
|
| 699 |
+
border-color: var(--h3-accent);
|
| 700 |
+
background: color-mix(in srgb, var(--h3-accent) 10%, var(--h3-sunken));
|
| 701 |
+
}
|
| 702 |
+
.h3e-thumb-img {
|
| 703 |
+
position: absolute;
|
| 704 |
+
inset: 0;
|
| 705 |
+
width: 100%;
|
| 706 |
+
height: 100%;
|
| 707 |
+
object-fit: contain;
|
| 708 |
+
pointer-events: none;
|
| 709 |
+
}
|
| 710 |
+
.h3e-thumb-hint {
|
| 711 |
+
font-size: var(--h3-fs-xs);
|
| 712 |
+
color: var(--h3-faint);
|
| 713 |
+
text-align: center;
|
| 714 |
+
line-height: 1.15;
|
| 715 |
+
white-space: pre-line;
|
| 716 |
+
pointer-events: none;
|
| 717 |
+
}
|
| 718 |
+
.h3e-thumb-bad { color: var(--h3-error); }
|
| 719 |
+
/* Reveal on hover, and a destructive hover is a tinted ground rather than a
|
| 720 |
+
* red fill -- a clear button that shouts is one people misread. */
|
| 721 |
+
.h3e-root .h3e-thumb-x {
|
| 722 |
+
position: absolute;
|
| 723 |
+
top: 2px;
|
| 724 |
+
right: 2px;
|
| 725 |
+
z-index: 5;
|
| 726 |
+
width: 16px;
|
| 727 |
+
height: 16px;
|
| 728 |
+
padding: 0;
|
| 729 |
+
line-height: 1;
|
| 730 |
+
display: none;
|
| 731 |
+
align-items: center;
|
| 732 |
+
justify-content: center;
|
| 733 |
+
background: color-mix(in srgb, #000 78%, transparent);
|
| 734 |
+
border: 1px solid var(--h3-border);
|
| 735 |
+
color: var(--h3-muted);
|
| 736 |
+
}
|
| 737 |
+
.h3e-thumb:hover .h3e-thumb-x { display: flex; }
|
| 738 |
+
.h3e-root .h3e-thumb-x:hover {
|
| 739 |
+
color: var(--h3-error);
|
| 740 |
+
border-color: var(--h3-error);
|
| 741 |
+
background: color-mix(in srgb, var(--h3-error) 18%, #000);
|
| 742 |
+
}
|
| 743 |
+
.h3e-root .h3e-pickfile {
|
| 744 |
+
font-size: var(--h3-fs-xs);
|
| 745 |
+
padding: 2px 4px;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
/* -- media strip ---------------------------------------------------------- */
|
| 749 |
+
.h3e-media-grid {
|
| 750 |
+
display: grid;
|
| 751 |
+
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
| 752 |
+
gap: var(--h3-gap-sm);
|
| 753 |
+
margin-top: 4px;
|
| 754 |
+
}
|
| 755 |
+
.h3e-media-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
| 756 |
+
.h3e-media-label {
|
| 757 |
+
font-size: var(--h3-fs-xs);
|
| 758 |
+
font-weight: var(--h3-weight-label);
|
| 759 |
+
letter-spacing: var(--h3-track);
|
| 760 |
+
text-transform: uppercase;
|
| 761 |
+
color: var(--h3-muted);
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
/* -- pinned RUN ----------------------------------------------------------
|
| 766 |
+
*
|
| 767 |
+
* `.h3e-root` used to be the scroll container with all four sections as
|
| 768 |
+
* children, which put RUN below the script -- out of view on any workflow
|
| 769 |
+
* with more than a couple of shots, and it is the section you open on every
|
| 770 |
+
* queue. Split in two: the authoring sections scroll, RUN is pinned.
|
| 771 |
+
*
|
| 772 |
+
* `min-height: 0` on the scroller is what makes this work at all. A flex item
|
| 773 |
+
* defaults to `min-height: auto`, so the scroller would refuse to shrink below
|
| 774 |
+
* its content and push RUN off the bottom of the node instead of scrolling.
|
| 775 |
+
*/
|
| 776 |
+
.h3e-scroll {
|
| 777 |
+
flex: 1 1 auto;
|
| 778 |
+
/* Never let RUN crush the authoring sections to nothing. Below this the
|
| 779 |
+
* scroller stops shrinking and RUN scrolls internally instead. */
|
| 780 |
+
min-height: 160px;
|
| 781 |
+
overflow-y: auto;
|
| 782 |
+
scrollbar-gutter: stable;
|
| 783 |
+
display: flex;
|
| 784 |
+
flex-direction: column;
|
| 785 |
+
gap: var(--h3-gap);
|
| 786 |
+
}
|
| 787 |
+
.h3e-root > .h3e-run {
|
| 788 |
+
/* `0 0 auto`. It must NOT shrink: flex divides a deficit in proportion to
|
| 789 |
+
* each item's content height, and the scroller's content is far taller, so
|
| 790 |
+
* a shrinkable RUN loses most of the space and clips its lower groups. The
|
| 791 |
+
* scroller absorbs all the shrinking; its min-height is the floor that
|
| 792 |
+
* stops RUN taking the whole panel, and max-height below is the ceiling. */
|
| 793 |
+
flex: 0 0 auto;
|
| 794 |
+
max-height: 55%;
|
| 795 |
+
display: flex;
|
| 796 |
+
flex-direction: column;
|
| 797 |
+
min-height: 0;
|
| 798 |
+
}
|
| 799 |
+
/* Always open, so the disclosure triangle would be a control that does
|
| 800 |
+
* nothing. The summary stays as the header and digest bar. */
|
| 801 |
+
.h3e-root > .h3e-run > .h3e-run-sum {
|
| 802 |
+
cursor: default;
|
| 803 |
+
list-style: none;
|
| 804 |
+
}
|
| 805 |
+
.h3e-root > .h3e-run > .h3e-run-sum::-webkit-details-marker { display: none; }
|
| 806 |
+
.h3e-root > .h3e-run > .h3e-run-sum::marker { content: ""; }
|
| 807 |
+
.h3e-root > .h3e-run > .h3e-run-body {
|
| 808 |
+
overflow-y: auto;
|
| 809 |
+
min-height: 0;
|
| 810 |
+
}
|
| 811 |
+
/* Pinned means it reads as chrome, not as another card in the stack. */
|
| 812 |
+
.h3e-root > .h3e-run > .h3e-run-sum {
|
| 813 |
+
position: sticky;
|
| 814 |
+
top: 0;
|
| 815 |
+
z-index: 2;
|
| 816 |
+
background: var(--h3-bg);
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
/* -- template panel -------------------------------------------------------
|
| 820 |
+
* Sits between the SCRIPT header and the card list, hidden until asked for.
|
| 821 |
+
* Each row is text plus one button, so the whole thing survives the narrow
|
| 822 |
+
* container breakpoints without its own media query. */
|
| 823 |
+
.h3e-tpl {
|
| 824 |
+
display: flex;
|
| 825 |
+
flex-direction: column;
|
| 826 |
+
gap: 1px;
|
| 827 |
+
margin-bottom: var(--h3-gap-sm);
|
| 828 |
+
border: 1px solid var(--h3-border-soft);
|
| 829 |
+
background: var(--h3-sunken);
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.h3e-tpl > .h3e-note {
|
| 833 |
+
margin: 0;
|
| 834 |
+
border: 0;
|
| 835 |
+
border-bottom: 1px solid var(--h3-border-soft);
|
| 836 |
+
color: var(--h3-faint);
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
.h3e-tpl-row {
|
| 840 |
+
display: flex;
|
| 841 |
+
align-items: flex-start;
|
| 842 |
+
gap: var(--h3-gap);
|
| 843 |
+
padding: 7px 8px;
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
.h3e-tpl-row + .h3e-tpl-row { border-top: 1px solid var(--h3-border-soft); }
|
| 847 |
+
.h3e-tpl-row:hover { background: var(--h3-accent-wash); }
|
| 848 |
+
|
| 849 |
+
/* min-width: 0 or the text column refuses to wrap and pushes the button off
|
| 850 |
+
* the panel -- a flex item will not shrink below its content by default. */
|
| 851 |
+
.h3e-tpl-text { flex: 1 1 auto; min-width: 0; }
|
| 852 |
+
|
| 853 |
+
.h3e-tpl-name {
|
| 854 |
+
color: var(--h3-text);
|
| 855 |
+
font-size: var(--h3-fs);
|
| 856 |
+
font-weight: var(--h3-weight-label);
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
.h3e-tpl-hint {
|
| 860 |
+
color: var(--h3-muted);
|
| 861 |
+
font-size: var(--h3-fs-sm);
|
| 862 |
+
margin-top: 1px;
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
.h3e-tpl-note {
|
| 866 |
+
color: var(--h3-faint);
|
| 867 |
+
font-size: var(--h3-fs-sm);
|
| 868 |
+
line-height: 1.5;
|
| 869 |
+
margin-top: 3px;
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
.h3e-tpl-row > .h3e-btn { flex: 0 0 auto; }
|
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { app } from "../../scripts/app.js";
|
| 2 |
+
import { api } from "../../scripts/api.js";
|
| 3 |
+
import {
|
| 4 |
+
el, isolateEvents, installHeightGuard, setWidgetVisibility, widgetByName,
|
| 5 |
+
} from "./editor/widget_utils.js";
|
| 6 |
+
import { createPlanEditor } from "./editor/plan_editor.js";
|
| 7 |
+
import { createRefRail, parseRefPlan, refPlanToJson } from "./editor/ref_rail.js";
|
| 8 |
+
import { createRunPanel } from "./editor/run_panel.js";
|
| 9 |
+
import { createMediaStrip, MEDIA_WIDGETS } from "./editor/media_strip.js";
|
| 10 |
+
|
| 11 |
+
const VERSION = "v1.5.0";
|
| 12 |
+
/* Both ids. The pack registers the pre-rename id as a deprecated subclass so
|
| 13 |
+
* workflows saved before 2026-08-29 still load. If this check knew only the
|
| 14 |
+
* new id those nodes would come up with NO editor at all, which looks exactly
|
| 15 |
+
* like the rename having broken the pack. */
|
| 16 |
+
const NODE_TYPES = new Set(["HandTieClips", "H3RefChain"]);
|
| 17 |
+
const NODE_WIDTH = 560;
|
| 18 |
+
const EDITOR_MIN_H = 460;
|
| 19 |
+
const STYLE_ID = "h3rc-style";
|
| 20 |
+
|
| 21 |
+
/* Widgets the editor owns. In Simple mode the plan is hidden instead.
|
| 22 |
+
*
|
| 23 |
+
* The run panel's share is NOT listed here: it reports the widgets it actually
|
| 24 |
+
* managed to draw, and only those are hidden. A dial the panel could not render
|
| 25 |
+
* -- a type it does not know, or one renamed on the Python side -- therefore
|
| 26 |
+
* stays visible as a native widget instead of vanishing from the node. */
|
| 27 |
+
// MEDIA_WIDGETS are the three filename STRINGs that replaced the
|
| 28 |
+
// start_image / reference_video / voice sockets. They are always hidden:
|
| 29 |
+
// the media strip owns them in both modes, and shown raw they are three
|
| 30 |
+
// text boxes inviting you to type a path that would not resolve.
|
| 31 |
+
const SHOTS_HIDDEN = ["prompt", "chains", "hop_script", "shot_plan", "ref_plan"]
|
| 32 |
+
.concat(MEDIA_WIDGETS);
|
| 33 |
+
const SIMPLE_HIDDEN = ["shot_plan", "ref_plan"].concat(MEDIA_WIDGETS);
|
| 34 |
+
|
| 35 |
+
const _cssUrl = new URL("./h3_ref_chain.css", import.meta.url).href;
|
| 36 |
+
|
| 37 |
+
/* External stylesheet, not a template literal. The old inline sheet hardcoded
|
| 38 |
+
* every colour, so a light ComfyUI theme rendered the panel as dark islands on
|
| 39 |
+
* a light node; the CSS file derives everything from the host theme's own
|
| 40 |
+
* custom properties. */
|
| 41 |
+
function ensureStyles() {
|
| 42 |
+
if (document.getElementById(STYLE_ID)) return;
|
| 43 |
+
const link = document.createElement("link");
|
| 44 |
+
link.id = STYLE_ID;
|
| 45 |
+
link.rel = "stylesheet";
|
| 46 |
+
link.href = _cssUrl;
|
| 47 |
+
document.head.appendChild(link);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* -- editor -------------------------------------------------------------- */
|
| 51 |
+
|
| 52 |
+
function mountEditor(node) {
|
| 53 |
+
if (node._h3Editor) return;
|
| 54 |
+
if (typeof node.addDOMWidget !== "function") {
|
| 55 |
+
console.warn("[HandTieClips] no addDOMWidget on this frontend; editor not mounted");
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
const planWidget = widgetByName(node, "shot_plan");
|
| 59 |
+
const refWidget = widgetByName(node, "ref_plan");
|
| 60 |
+
if (!planWidget || !refWidget) {
|
| 61 |
+
// Stale node definition: the Python side was not reloaded. Silent here
|
| 62 |
+
// is what made this hard to diagnose the first time.
|
| 63 |
+
console.warn(
|
| 64 |
+
"[HandTieClips] shot_plan/ref_plan widget missing -- restart ComfyUI. "
|
| 65 |
+
+ "Widgets present:", (node.widgets || []).map((w) => w.name),
|
| 66 |
+
);
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
ensureStyles();
|
| 70 |
+
|
| 71 |
+
const root = el("div", "h3e-root");
|
| 72 |
+
isolateEvents(root);
|
| 73 |
+
|
| 74 |
+
// A ref_plan that does not parse must NOT be silently replaced by an empty
|
| 75 |
+
// one: the rail would then write "" over it on the next edit, destroying a
|
| 76 |
+
// paste the author was in the middle of repairing. Keep the raw text in the
|
| 77 |
+
// widget, flag it, and let the rail's JSON section show and fix it.
|
| 78 |
+
let refBad = false;
|
| 79 |
+
const readRefPlan = () => {
|
| 80 |
+
const parsed = parseRefPlan(refWidget.value);
|
| 81 |
+
refBad = parsed === null;
|
| 82 |
+
return parsed || { refs: [], subjects: {} };
|
| 83 |
+
};
|
| 84 |
+
let refPlan = readRefPlan();
|
| 85 |
+
|
| 86 |
+
// How many hops this run will actually do. The shot plan is authoritative
|
| 87 |
+
// when present -- run() ignores `chains` the moment one is loaded -- so both
|
| 88 |
+
// the rail's schedule chips and the run panel's summary read it from here
|
| 89 |
+
// rather than from the widget that no longer decides.
|
| 90 |
+
const hopCount = () => {
|
| 91 |
+
const plan = widgetByName(node, "shot_plan")?.value;
|
| 92 |
+
try {
|
| 93 |
+
const parsed = JSON.parse((plan || "").trim() || "null");
|
| 94 |
+
const arr = Array.isArray(parsed) ? parsed : parsed?.shots;
|
| 95 |
+
if (Array.isArray(arr) && arr.length) return arr.length;
|
| 96 |
+
} catch (_) { /* unparseable plan: fall back to chains */ }
|
| 97 |
+
return Number(widgetByName(node, "chains")?.value) || 1;
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const rail = createRefRail(node, {
|
| 101 |
+
getPlan: () => refPlan,
|
| 102 |
+
setPlan: (p) => { refPlan = p; refBad = false; refWidget.value = refPlanToJson(p); },
|
| 103 |
+
getRaw: () => refWidget.value,
|
| 104 |
+
isBad: () => refBad,
|
| 105 |
+
onChange: () => node.graph?.setDirtyCanvas?.(true, true),
|
| 106 |
+
hopCount,
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
const editor = createPlanEditor(node, {
|
| 110 |
+
onChange: () => {
|
| 111 |
+
applyVisibility();
|
| 112 |
+
node.graph?.setDirtyCanvas?.(true, true);
|
| 113 |
+
},
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
const runPanel = createRunPanel(node, {
|
| 117 |
+
onChange: () => node.graph?.setDirtyCanvas?.(true, true),
|
| 118 |
+
hopCount,
|
| 119 |
+
// In Shots mode these two are decided by the plan, not by the user:
|
| 120 |
+
// run() ignores `chains` and forces `hop_script=next`. They are already
|
| 121 |
+
// in SHOTS_HIDDEN, so dropping them here leaves them hidden rather than
|
| 122 |
+
// offering a control that does nothing.
|
| 123 |
+
suppressed: () => (editor.mode() === "shots" ? ["chains", "hop_script"] : []),
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
const mediaStrip = createMediaStrip(node, {
|
| 127 |
+
onChange: () => node.graph?.setDirtyCanvas?.(true, true),
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// Authoring sections scroll; RUN does not. RUN is the one section touched
|
| 131 |
+
// on every queue, and it used to be the last child of the scroller -- so
|
| 132 |
+
// it sat below however many shot cards the script had and you had to
|
| 133 |
+
// scroll the panel just to reach the summary bar. It is pinned to the
|
| 134 |
+
// bottom of the panel now, collapsed to its ~28px summary until opened.
|
| 135 |
+
const scroll = el("div", "h3e-scroll");
|
| 136 |
+
scroll.appendChild(rail.root);
|
| 137 |
+
scroll.appendChild(mediaStrip.root);
|
| 138 |
+
scroll.appendChild(editor.root);
|
| 139 |
+
root.appendChild(scroll);
|
| 140 |
+
root.appendChild(runPanel.root);
|
| 141 |
+
|
| 142 |
+
const domWidget = node.addDOMWidget("h3_editor", "h3_editor", root, {
|
| 143 |
+
serialize: false,
|
| 144 |
+
hideOnZoom: false,
|
| 145 |
+
getMinHeight: () => EDITOR_MIN_H,
|
| 146 |
+
});
|
| 147 |
+
domWidget.serialize = false;
|
| 148 |
+
|
| 149 |
+
installHeightGuard(node, domWidget, { minHeight: EDITOR_MIN_H, minWidth: NODE_WIDTH });
|
| 150 |
+
|
| 151 |
+
function applyVisibility() {
|
| 152 |
+
// The rail always owns ref_plan; the plan widget's visibility follows
|
| 153 |
+
// the mode, so Simple mode never leaves an inert second text area up.
|
| 154 |
+
// sync() first: ownedNames() decides what gets hidden, so rebuilding
|
| 155 |
+
// after the hide would hide a dial the panel had stopped drawing.
|
| 156 |
+
runPanel.sync();
|
| 157 |
+
const base = editor.mode() === "shots" ? SHOTS_HIDDEN : SIMPLE_HIDDEN;
|
| 158 |
+
setWidgetVisibility(node, base.concat(runPanel.ownedNames()));
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
node._h3Editor = {
|
| 162 |
+
refresh() {
|
| 163 |
+
refPlan = readRefPlan();
|
| 164 |
+
editor.reload();
|
| 165 |
+
rail.render();
|
| 166 |
+
mediaStrip.render();
|
| 167 |
+
applyVisibility(); // syncs the run panel on the way through
|
| 168 |
+
},
|
| 169 |
+
rail,
|
| 170 |
+
editor,
|
| 171 |
+
runPanel,
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
applyVisibility();
|
| 175 |
+
rail.render();
|
| 176 |
+
mediaStrip.render();
|
| 177 |
+
editor.render();
|
| 178 |
+
|
| 179 |
+
// `control_after_generate` bumps the seed widget as the prompt is queued,
|
| 180 |
+
// and undo rewrites widgets wholesale -- both behind this panel's back.
|
| 181 |
+
// RUN used to re-read on open, which is no longer a moment that exists.
|
| 182 |
+
// `promptQueued` fires client-side after the bump has been applied.
|
| 183 |
+
const onQueued = () => {
|
| 184 |
+
try {
|
| 185 |
+
runPanel.sync();
|
| 186 |
+
} catch (err) {
|
| 187 |
+
console.warn("[HandTieClips] run panel resync failed:", err);
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
api.addEventListener("promptQueued", onQueued);
|
| 191 |
+
// Removing the node must drop the listener, or every add/remove cycle
|
| 192 |
+
// leaves another closure holding this node alive.
|
| 193 |
+
const prevRemoved = node.onRemoved;
|
| 194 |
+
node.onRemoved = function (...args) {
|
| 195 |
+
api.removeEventListener("promptQueued", onQueued);
|
| 196 |
+
return prevRemoved?.apply(this, args);
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
console.log(`[HandTieClips] ${VERSION} editor mounted on node ${node.id}`);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
console.log(`[HandTieClips] editor ui ${VERSION} loaded`);
|
| 203 |
+
|
| 204 |
+
app.registerExtension({
|
| 205 |
+
name: "HandTieClips.ui",
|
| 206 |
+
async beforeRegisterNodeDef(nodeType, nodeData) {
|
| 207 |
+
if (!NODE_TYPES.has(nodeData.name)) return;
|
| 208 |
+
|
| 209 |
+
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
| 210 |
+
nodeType.prototype.onNodeCreated = function () {
|
| 211 |
+
const r = onNodeCreated?.apply(this, arguments);
|
| 212 |
+
mountEditor(this);
|
| 213 |
+
this.setSize([
|
| 214 |
+
Math.max(this.size[0], NODE_WIDTH),
|
| 215 |
+
Math.max(this.size[1], 640),
|
| 216 |
+
]);
|
| 217 |
+
return r;
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
const onConfigure = nodeType.prototype.onConfigure;
|
| 221 |
+
nodeType.prototype.onConfigure = function () {
|
| 222 |
+
const r = onConfigure?.apply(this, arguments);
|
| 223 |
+
mountEditor(this);
|
| 224 |
+
// Widget values arrive with the workflow, after the mount, so the
|
| 225 |
+
// panel has to re-read them or a loaded graph shows an empty editor.
|
| 226 |
+
this._h3Editor?.refresh();
|
| 227 |
+
return r;
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
// Wiring or unwiring a ref_image_N input changes the ordinals and the
|
| 231 |
+
// wired/unwired dots, so the rail has to be told.
|
| 232 |
+
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
| 233 |
+
nodeType.prototype.onConnectionsChange = function () {
|
| 234 |
+
const r = onConnectionsChange?.apply(this, arguments);
|
| 235 |
+
this._h3Editor?.rail?.render();
|
| 236 |
+
return r;
|
| 237 |
+
};
|
| 238 |
+
},
|
| 239 |
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Files on disk -> tensors, plus the path safety that makes that survivable.
|
| 2 |
+
|
| 3 |
+
The node used to take its pictures down nine `ref_image_N` IMAGE sockets, which
|
| 4 |
+
meant nine `Load Image` nodes and a 16-socket column occupying a third of the
|
| 5 |
+
node before the editor even started. References now live as *files* in
|
| 6 |
+
`<ComfyUI input>/h3_refs`, and a reference carries a filename rather than a
|
| 7 |
+
wire.
|
| 8 |
+
|
| 9 |
+
Two rules this module exists to enforce:
|
| 10 |
+
|
| 11 |
+
**Nothing from the browser is trusted as a path.** `resolve` normalises, then
|
| 12 |
+
prefix-checks against the reference directory, then checks the extension. A
|
| 13 |
+
POST route that writes files is only as safe as the function deciding where the
|
| 14 |
+
bytes land, so that function lives here, is nine lines, and is used by both the
|
| 15 |
+
route and the loaders.
|
| 16 |
+
|
| 17 |
+
**Pixels never travel as base64.** Only the basename is stored in a widget.
|
| 18 |
+
PromptMasterLD measured 1.68 MB of widget value for nine images and ComfyUI
|
| 19 |
+
then failed to save the workflow at all; the filename is the whole payload.
|
| 20 |
+
|
| 21 |
+
The loaders deliberately return exactly what a `Load Image` would -- float
|
| 22 |
+
`[N,H,W,3]` in 0..1 -- so everything downstream of the old sockets is unchanged,
|
| 23 |
+
including `_ref_frames`' resize and `store.tensor_digest`'s cache keying.
|
| 24 |
+
|
| 25 |
+
No hard ComfyUI import: `input_dir()` falls back to a path relative to this
|
| 26 |
+
file, so the loaders and the safety check are testable without a running server
|
| 27 |
+
(same rule as `plan.py`, `refs.py` and `tone.py`).
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import os
|
| 32 |
+
|
| 33 |
+
TAG = "HandTieClips"
|
| 34 |
+
|
| 35 |
+
# One flat folder under ComfyUI's input dir. Flat on purpose: `/view` takes a
|
| 36 |
+
# basename plus one subfolder, and a tree would need a second lookup for no
|
| 37 |
+
# benefit at the scale a chain uses (single digits of references).
|
| 38 |
+
REFS_SUBDIR = "h3_refs"
|
| 39 |
+
|
| 40 |
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
| 41 |
+
VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"}
|
| 42 |
+
AUDIO_EXTS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac"}
|
| 43 |
+
ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS | AUDIO_EXTS
|
| 44 |
+
|
| 45 |
+
# Anything past this is re-encoded on upload. A 12 MP phone photo is a
|
| 46 |
+
# reference, not an asset: it costs decode RAM on every load and is about to be
|
| 47 |
+
# resized to the canvas anyway.
|
| 48 |
+
MAX_SIDE = 2048
|
| 49 |
+
MAX_PIXELS = 1_500_000
|
| 50 |
+
JPEG_QUALITY = 88
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def kind_of(name):
|
| 54 |
+
"""'image' | 'video' | 'audio' | None, from the extension alone."""
|
| 55 |
+
ext = os.path.splitext(str(name or ""))[1].lower()
|
| 56 |
+
if ext in IMAGE_EXTS:
|
| 57 |
+
return "image"
|
| 58 |
+
if ext in VIDEO_EXTS:
|
| 59 |
+
return "video"
|
| 60 |
+
if ext in AUDIO_EXTS:
|
| 61 |
+
return "audio"
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def input_dir():
|
| 66 |
+
"""ComfyUI's input directory, or a sibling `input/` when it is absent."""
|
| 67 |
+
try:
|
| 68 |
+
import folder_paths # noqa: PLC0415
|
| 69 |
+
return folder_paths.get_input_directory()
|
| 70 |
+
except Exception:
|
| 71 |
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 72 |
+
return os.path.join(os.path.dirname(here), "input")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def refs_dir(create=False):
|
| 76 |
+
d = os.path.join(input_dir(), REFS_SUBDIR)
|
| 77 |
+
if create:
|
| 78 |
+
os.makedirs(d, exist_ok=True)
|
| 79 |
+
return d
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def resolve(name, kinds=None):
|
| 83 |
+
"""Absolute path for a reference basename, or None.
|
| 84 |
+
|
| 85 |
+
Refuses anything that escapes the reference directory, carries an unknown
|
| 86 |
+
extension, or does not exist. `name` is treated as a bare filename -- any
|
| 87 |
+
directory component is a caller error, not a feature, so `..` cannot even be
|
| 88 |
+
expressed as a traversal.
|
| 89 |
+
|
| 90 |
+
`kinds` optionally restricts to {'image','video','audio'}.
|
| 91 |
+
"""
|
| 92 |
+
raw = str(name or "").strip()
|
| 93 |
+
if not raw:
|
| 94 |
+
return None
|
| 95 |
+
kind = kind_of(raw)
|
| 96 |
+
if kind is None:
|
| 97 |
+
return None
|
| 98 |
+
if kinds and kind not in kinds:
|
| 99 |
+
return None
|
| 100 |
+
base = os.path.normpath(refs_dir())
|
| 101 |
+
full = os.path.normpath(os.path.join(base, raw.replace("/", os.sep)))
|
| 102 |
+
# The prefix check is the load-bearing line. normpath has already collapsed
|
| 103 |
+
# any `..`, so a path that still starts with the reference directory cannot
|
| 104 |
+
# be pointing outside it.
|
| 105 |
+
if full != base and not full.startswith(base + os.sep):
|
| 106 |
+
return None
|
| 107 |
+
return full if os.path.isfile(full) else None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def listing(kinds=None):
|
| 111 |
+
"""Reference filenames on disk, newest first, for the editor's dropdown."""
|
| 112 |
+
d = refs_dir()
|
| 113 |
+
if not os.path.isdir(d):
|
| 114 |
+
return []
|
| 115 |
+
rows = []
|
| 116 |
+
for name in os.listdir(d):
|
| 117 |
+
kind = kind_of(name)
|
| 118 |
+
if kind is None or (kinds and kind not in kinds):
|
| 119 |
+
continue
|
| 120 |
+
path = os.path.join(d, name)
|
| 121 |
+
if not os.path.isfile(path):
|
| 122 |
+
continue
|
| 123 |
+
try:
|
| 124 |
+
rows.append((os.path.getmtime(path), name, kind))
|
| 125 |
+
except OSError:
|
| 126 |
+
continue
|
| 127 |
+
rows.sort(reverse=True)
|
| 128 |
+
return [{"name": n, "kind": k} for _m, n, k in rows]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def stamp(names):
|
| 132 |
+
"""`name:mtime` for each file, for IS_CHANGED.
|
| 133 |
+
|
| 134 |
+
ComfyUI caches a node's output on its inputs, and a filename is a stable
|
| 135 |
+
input even when the bytes behind it change. Without this, replacing a
|
| 136 |
+
reference in place would serve the previous render.
|
| 137 |
+
"""
|
| 138 |
+
bits = []
|
| 139 |
+
for name in names:
|
| 140 |
+
if not name:
|
| 141 |
+
continue
|
| 142 |
+
path = resolve(name)
|
| 143 |
+
try:
|
| 144 |
+
bits.append(f"{name}:{os.path.getmtime(path)}" if path else f"{name}:missing")
|
| 145 |
+
except OSError:
|
| 146 |
+
bits.append(f"{name}:missing")
|
| 147 |
+
return "|".join(bits)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# -- loaders ---------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
def _to_tensor(pil):
|
| 153 |
+
import numpy as np # noqa: PLC0415
|
| 154 |
+
import torch # noqa: PLC0415
|
| 155 |
+
|
| 156 |
+
arr = np.asarray(pil.convert("RGB"), dtype=np.float32) / 255.0
|
| 157 |
+
return torch.from_numpy(arr).unsqueeze(0)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def load_image(name):
|
| 161 |
+
"""One still as an IMAGE tensor `[1,H,W,3]`, or None.
|
| 162 |
+
|
| 163 |
+
Returns what a `Load Image` returns, so `_ref_frames` resizes it and
|
| 164 |
+
`tensor_digest` keys it exactly as before.
|
| 165 |
+
"""
|
| 166 |
+
path = resolve(name, kinds={"image"})
|
| 167 |
+
if path is None:
|
| 168 |
+
return None
|
| 169 |
+
try:
|
| 170 |
+
from PIL import Image, ImageOps # noqa: PLC0415
|
| 171 |
+
with Image.open(path) as im:
|
| 172 |
+
# EXIF orientation: a phone portrait otherwise loads on its side,
|
| 173 |
+
# and the model would be handed a rotated face.
|
| 174 |
+
return _to_tensor(ImageOps.exif_transpose(im))
|
| 175 |
+
except Exception as exc:
|
| 176 |
+
print(f"[{TAG}] could not read reference {name!r}: {exc!r}", flush=True)
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def load_video(name, max_frames=None):
|
| 181 |
+
"""A clip as an IMAGE batch `[N,H,W,3]`, or None."""
|
| 182 |
+
path = resolve(name, kinds={"video"})
|
| 183 |
+
if path is None:
|
| 184 |
+
return None
|
| 185 |
+
try:
|
| 186 |
+
import av # noqa: PLC0415
|
| 187 |
+
import numpy as np # noqa: PLC0415
|
| 188 |
+
import torch # noqa: PLC0415
|
| 189 |
+
|
| 190 |
+
frames = []
|
| 191 |
+
with av.open(path) as container:
|
| 192 |
+
for frame in container.decode(video=0):
|
| 193 |
+
frames.append(frame.to_ndarray(format="rgb24"))
|
| 194 |
+
if max_frames and len(frames) >= int(max_frames):
|
| 195 |
+
break
|
| 196 |
+
if not frames:
|
| 197 |
+
return None
|
| 198 |
+
arr = np.stack(frames).astype(np.float32) / 255.0
|
| 199 |
+
return torch.from_numpy(arr)
|
| 200 |
+
except Exception as exc:
|
| 201 |
+
print(f"[{TAG}] could not read video {name!r}: {exc!r}", flush=True)
|
| 202 |
+
return None
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def load_audio(name):
|
| 206 |
+
"""A take as ComfyUI's AUDIO dict, or None.
|
| 207 |
+
|
| 208 |
+
Shape is `[batch, channels, samples]`, which is what every AUDIO consumer
|
| 209 |
+
in the graph expects.
|
| 210 |
+
"""
|
| 211 |
+
path = resolve(name, kinds={"audio"})
|
| 212 |
+
if path is None:
|
| 213 |
+
return None
|
| 214 |
+
try:
|
| 215 |
+
import torch # noqa: PLC0415
|
| 216 |
+
import torchaudio # noqa: PLC0415
|
| 217 |
+
|
| 218 |
+
wav, sr = torchaudio.load(path)
|
| 219 |
+
if wav.dim() == 1:
|
| 220 |
+
wav = wav.unsqueeze(0)
|
| 221 |
+
return {"waveform": wav.unsqueeze(0).float(), "sample_rate": int(sr)}
|
| 222 |
+
except Exception as exc:
|
| 223 |
+
print(f"[{TAG}] could not read audio {name!r}: {exc!r}", flush=True)
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# -- writing (used by the upload route) ------------------------------------
|
| 228 |
+
|
| 229 |
+
def unique_path(directory, filename):
|
| 230 |
+
"""A free path in `directory`, suffixing `_1`, `_2`... on collision."""
|
| 231 |
+
name = os.path.basename(str(filename or "").strip()) or "upload"
|
| 232 |
+
stem, ext = os.path.splitext(name)
|
| 233 |
+
path, i = os.path.join(directory, name), 1
|
| 234 |
+
while os.path.exists(path):
|
| 235 |
+
path = os.path.join(directory, f"{stem}_{i}{ext}")
|
| 236 |
+
i += 1
|
| 237 |
+
return path
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def shrink_image(path):
|
| 241 |
+
"""Re-encode an oversized still in place. -> (final_path, w, h).
|
| 242 |
+
|
| 243 |
+
A 12 MP drop costs decode RAM on every single load and is about to be
|
| 244 |
+
resized to a 0.3 MP canvas regardless. Anything within budget is left
|
| 245 |
+
untouched, so a PNG the author cared about stays a PNG.
|
| 246 |
+
"""
|
| 247 |
+
try:
|
| 248 |
+
from PIL import Image, ImageOps # noqa: PLC0415
|
| 249 |
+
with Image.open(path) as im:
|
| 250 |
+
im = ImageOps.exif_transpose(im)
|
| 251 |
+
w, h = im.size
|
| 252 |
+
if w * h <= MAX_PIXELS and max(w, h) <= MAX_SIDE:
|
| 253 |
+
return path, w, h
|
| 254 |
+
scale = min(MAX_SIDE / max(w, h), (MAX_PIXELS / float(w * h)) ** 0.5, 1.0)
|
| 255 |
+
new = (max(1, int(w * scale)), max(1, int(h * scale)))
|
| 256 |
+
im = im.convert("RGB").resize(new, Image.Resampling.LANCZOS)
|
| 257 |
+
out = os.path.splitext(path)[0] + ".jpg"
|
| 258 |
+
out = out if out == path else unique_path(os.path.dirname(path),
|
| 259 |
+
os.path.basename(out))
|
| 260 |
+
im.save(out, format="JPEG", quality=JPEG_QUALITY, optimize=False)
|
| 261 |
+
if out != path:
|
| 262 |
+
try:
|
| 263 |
+
os.remove(path)
|
| 264 |
+
except OSError:
|
| 265 |
+
pass
|
| 266 |
+
return out, new[0], new[1]
|
| 267 |
+
except Exception as exc:
|
| 268 |
+
print(f"[{TAG}] could not shrink {os.path.basename(path)}: {exc!r}",
|
| 269 |
+
flush=True)
|
| 270 |
+
return path, 0, 0
|
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shot-plan schema for H3 Ref Chain.
|
| 2 |
+
|
| 3 |
+
A plan is the authored script for one chain: an ordered list of shots, one per
|
| 4 |
+
hop. It is carried as a single JSON string so the whole plan lives in one widget
|
| 5 |
+
and serializes with the workflow -- the DOM editor renders cards from this and
|
| 6 |
+
writes back to the same string, so the JSON stays the source of truth whether it
|
| 7 |
+
was typed by hand or clicked together.
|
| 8 |
+
|
| 9 |
+
The shot count IS the hop count. There is no separate `chains` number to keep in
|
| 10 |
+
sync, which removes a whole class of "3 blocks but chains=4" mismatches.
|
| 11 |
+
|
| 12 |
+
Shot fields (all optional except `beat`):
|
| 13 |
+
beat what happens this hop
|
| 14 |
+
directives {join, camera, framing, pace, tail} -- see directives.VOCAB
|
| 15 |
+
prose free text appended verbatim, for anything the vocabulary lacks
|
| 16 |
+
seed int override, else the chain seed
|
| 17 |
+
steps int override
|
| 18 |
+
duration str override, e.g. "8 s"
|
| 19 |
+
locked bool -- reuse this hop's cached render (hop store, step 4)
|
| 20 |
+
id stable identifier, generated if absent
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
|
| 25 |
+
from . import directives as _d
|
| 26 |
+
|
| 27 |
+
TAG = "HandTieClips"
|
| 28 |
+
|
| 29 |
+
# No "refs" here. A shot never activated a reference: activation is the ref's
|
| 30 |
+
# own `shots` list in refs.py. The field was parsed, normalised and printed but
|
| 31 |
+
# read by nothing -- and the editor neither loads nor writes it, so a
|
| 32 |
+
# hand-authored shot_plan lost it the first time anyone touched a card. Leaving
|
| 33 |
+
# it out means _norm_shot's unknown-field error names it and points at `shots`.
|
| 34 |
+
_SHOT_KEYS = {"id", "beat", "directives", "prose",
|
| 35 |
+
"seed", "steps", "duration", "locked"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _norm_shot(raw, i):
|
| 39 |
+
where = f"shot {i + 1}: "
|
| 40 |
+
if isinstance(raw, str):
|
| 41 |
+
raw = {"beat": raw}
|
| 42 |
+
if not isinstance(raw, dict):
|
| 43 |
+
raise ValueError(f"{TAG}: {where}each shot must be an object or a string")
|
| 44 |
+
|
| 45 |
+
unknown = set(raw) - _SHOT_KEYS
|
| 46 |
+
if unknown:
|
| 47 |
+
raise ValueError(
|
| 48 |
+
f"{TAG}: {where}unknown field(s) {sorted(unknown)}. "
|
| 49 |
+
f"Valid: {sorted(_SHOT_KEYS)}"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
d_raw = raw.get("directives") or {}
|
| 53 |
+
if not isinstance(d_raw, dict):
|
| 54 |
+
raise ValueError(f"{TAG}: {where}directives must be an object")
|
| 55 |
+
bad = set(d_raw) - set(_d.AXES)
|
| 56 |
+
if bad:
|
| 57 |
+
raise ValueError(
|
| 58 |
+
f"{TAG}: {where}unknown directive axis/axes {sorted(bad)}. "
|
| 59 |
+
f"Valid: {list(_d.AXES)}"
|
| 60 |
+
)
|
| 61 |
+
dirs = {ax: _d.validate(ax, d_raw.get(ax), where=where) for ax in _d.AXES}
|
| 62 |
+
dirs = {k: v for k, v in dirs.items() if v}
|
| 63 |
+
|
| 64 |
+
def _int(name):
|
| 65 |
+
v = raw.get(name)
|
| 66 |
+
if v in (None, ""):
|
| 67 |
+
return None
|
| 68 |
+
try:
|
| 69 |
+
return int(v)
|
| 70 |
+
except (TypeError, ValueError):
|
| 71 |
+
raise ValueError(f"{TAG}: {where}{name} must be a whole number, got {v!r}")
|
| 72 |
+
|
| 73 |
+
return {
|
| 74 |
+
"id": str(raw.get("id") or f"s{i + 1}"),
|
| 75 |
+
"beat": str(raw.get("beat") or "").strip(),
|
| 76 |
+
"directives": dirs,
|
| 77 |
+
"prose": str(raw.get("prose") or "").strip(),
|
| 78 |
+
"seed": _int("seed"),
|
| 79 |
+
"steps": _int("steps"),
|
| 80 |
+
"duration": (str(raw["duration"]).strip() or None) if raw.get("duration") else None,
|
| 81 |
+
"locked": bool(raw.get("locked")),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def parse_plan(text):
|
| 86 |
+
"""Parse a shot-plan JSON string. Blank -> [] (caller falls back to `prompt`)."""
|
| 87 |
+
text = (text or "").strip()
|
| 88 |
+
if not text:
|
| 89 |
+
return []
|
| 90 |
+
try:
|
| 91 |
+
data = json.loads(text)
|
| 92 |
+
except json.JSONDecodeError as e:
|
| 93 |
+
raise ValueError(f"{TAG}: shot_plan does not parse as JSON ({e})") from e
|
| 94 |
+
|
| 95 |
+
if isinstance(data, dict):
|
| 96 |
+
shots = data.get("shots")
|
| 97 |
+
if shots is None:
|
| 98 |
+
raise ValueError(f'{TAG}: shot_plan object needs a "shots" array')
|
| 99 |
+
elif isinstance(data, list):
|
| 100 |
+
shots = data
|
| 101 |
+
else:
|
| 102 |
+
raise ValueError(f'{TAG}: shot_plan must be an object with "shots", or an array')
|
| 103 |
+
|
| 104 |
+
if not isinstance(shots, list) or not shots:
|
| 105 |
+
raise ValueError(f"{TAG}: shot_plan has no shots")
|
| 106 |
+
|
| 107 |
+
out = [_norm_shot(s, i) for i, s in enumerate(shots)]
|
| 108 |
+
if not any(s["beat"] or s["prose"] for s in out):
|
| 109 |
+
raise ValueError(f"{TAG}: shot_plan has no beat text in any shot")
|
| 110 |
+
return out
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def check_coherence(shots):
|
| 114 |
+
"""Warn (never raise) about directive combinations that fight each other.
|
| 115 |
+
|
| 116 |
+
A framing change asks the audience to be somewhere new. With a moving camera
|
| 117 |
+
the move earns it; with a held camera the only way to get there is a cut, so
|
| 118 |
+
`join=continuous` and the framing change are asking for opposite things and
|
| 119 |
+
the model will pick one. Warn rather than raise -- it is a legitimate thing
|
| 120 |
+
to want, it just rarely reads as continuous.
|
| 121 |
+
|
| 122 |
+
Also warns when the camera move and the framing point opposite ways
|
| 123 |
+
(push_in + wide, pull_back + close), which is a contradiction regardless of
|
| 124 |
+
how the hop joins.
|
| 125 |
+
"""
|
| 126 |
+
warnings = []
|
| 127 |
+
# push_in narrows the frame, pull_back opens it. Asking for one and naming
|
| 128 |
+
# the opposite destination is a physical contradiction at any join value,
|
| 129 |
+
# and nothing warned about it before.
|
| 130 |
+
_opposed = {("push_in", "wide"), ("pull_back", "close")}
|
| 131 |
+
for i, s in enumerate(shots):
|
| 132 |
+
d = s.get("directives") or {}
|
| 133 |
+
framing = d.get("framing", "")
|
| 134 |
+
camera = d.get("camera", "")
|
| 135 |
+
# Hop 1 has no previous hop, and directive_prose does not emit `join`
|
| 136 |
+
# there, so a join warning on shot 1 points at a sentence that is never
|
| 137 |
+
# compiled. The JS mirror already skips index 0.
|
| 138 |
+
if (i > 0 and d.get("join") == "continuous"
|
| 139 |
+
and framing not in ("", "keep")
|
| 140 |
+
and camera in ("", "hold")):
|
| 141 |
+
warnings.append(
|
| 142 |
+
f"shot {i + 1}: join=continuous with framing={framing} and a held "
|
| 143 |
+
f"camera implies a cut. Use camera=push_in/pull_back/pan_follow to "
|
| 144 |
+
f"reach that framing on the move, or framing=keep."
|
| 145 |
+
)
|
| 146 |
+
if (camera, framing) in _opposed:
|
| 147 |
+
warnings.append(
|
| 148 |
+
f"shot {i + 1}: camera={camera} moves the opposite way from "
|
| 149 |
+
f"framing={framing}. Pick the framing the move actually lands on."
|
| 150 |
+
)
|
| 151 |
+
return warnings
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def check_place_handoff(shots, ref_plan=None):
|
| 155 |
+
"""Warn when a hop changes location without the previous beat arriving there.
|
| 156 |
+
|
| 157 |
+
This is the failure that put the one visible cut in the 8x15 s anime chain:
|
| 158 |
+
shot 3 ended "ahead the trunks begin to thin toward open ground" and shot 4
|
| 159 |
+
opened "Across the flat moonlit stone of @arena_clearing the two of them
|
| 160 |
+
square off". Hop 4 was handed a live frame of a man among trees and a beat
|
| 161 |
+
asserting he was already standing on open stone. It held the forest for
|
| 162 |
+
3.25 s and then reset the scene -- a hard cut 78 frames into the hop, the
|
| 163 |
+
single largest frame-to-frame jump in 114 seconds of film.
|
| 164 |
+
|
| 165 |
+
Nothing warned. `check_coherence` sees only directives, and the plan was
|
| 166 |
+
clean by every other check the pack has.
|
| 167 |
+
|
| 168 |
+
The rule the warning encodes: a beat must be true from ANY plausible ending
|
| 169 |
+
of the hop before it. When shot N names a place tag that shot N-1 never
|
| 170 |
+
mentions, shot N-1 has to do the arriving, or the model has to cut.
|
| 171 |
+
|
| 172 |
+
Two shapes are accepted as an arrival, because both work in practice:
|
| 173 |
+
shot N-1 naming the new place tag itself, or shot N's own beat carrying the
|
| 174 |
+
journey ("reaches the top of the stairs and pushes open the door"), which is
|
| 175 |
+
satisfiable from a live frame that is still on the stairs.
|
| 176 |
+
|
| 177 |
+
`ref_plan` is the parsed dict from refs.parse_ref_plan. Without it there is
|
| 178 |
+
no way to tell a place tag from a face tag, so the check no-ops.
|
| 179 |
+
"""
|
| 180 |
+
warnings = []
|
| 181 |
+
if not ref_plan or len(shots) < 2:
|
| 182 |
+
return warnings
|
| 183 |
+
# A ref with no subject is a place plate. Faces ride people, not rooms.
|
| 184 |
+
places = {r["tag"] for r in (ref_plan.get("refs") or []) if not r.get("subject")}
|
| 185 |
+
if not places:
|
| 186 |
+
return warnings
|
| 187 |
+
|
| 188 |
+
# Phrases that mean the beat itself carries the journey, so it is
|
| 189 |
+
# satisfiable from a live frame still in the old location. The pack's own
|
| 190 |
+
# Showcase is the case to keep clean: shot 5 leaves her at a window in the
|
| 191 |
+
# hallway and shot 6 reads "She walks back along the hallway and through
|
| 192 |
+
# the doorway to the counter in @kitchen" -- the travelling is right there
|
| 193 |
+
# in the beat, and warning about it would be noise.
|
| 194 |
+
#
|
| 195 |
+
# Kept as travel language rather than a list of rooms: what makes a beat
|
| 196 |
+
# safe is that it starts where the last hop ended and moves, not which
|
| 197 |
+
# place it moves to.
|
| 198 |
+
_ARRIVES = (
|
| 199 |
+
"reach", "arriv", "enter", "emerg",
|
| 200 |
+
"step into", "steps into", "step through", "steps through",
|
| 201 |
+
"walk into", "walks into", "walk back", "walks back", "walking back",
|
| 202 |
+
"walk through", "walks through", "go back", "goes back",
|
| 203 |
+
"return", "returns", "returning", "head back", "heads back",
|
| 204 |
+
"head toward", "heads toward", "back along", "back through",
|
| 205 |
+
"push open", "pushes open", "through the door", "through the doorway",
|
| 206 |
+
"through the gate", "through the entrance", "across into",
|
| 207 |
+
"come out", "comes out", "break out", "breaks out",
|
| 208 |
+
"cross into", "crosses into", "climb", "climbs", "descend", "descends",
|
| 209 |
+
"makes her way", "makes his way", "makes their way",
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
for i in range(1, len(shots)):
|
| 213 |
+
prev = (shots[i - 1].get("beat") or "").lower()
|
| 214 |
+
here = shots[i].get("beat") or ""
|
| 215 |
+
low = here.lower()
|
| 216 |
+
new_places = [t for t in places
|
| 217 |
+
if ("@" + t).lower() in low and ("@" + t).lower() not in prev]
|
| 218 |
+
for tag in sorted(new_places):
|
| 219 |
+
if any(v in low for v in _ARRIVES):
|
| 220 |
+
continue
|
| 221 |
+
warnings.append(
|
| 222 |
+
f"shot {i + 1}: @{tag} is a new place and shot {i} never goes there. "
|
| 223 |
+
f"The hop opens on a live frame of the old location, so the only way "
|
| 224 |
+
f"to obey is a cut. End shot {i} with the arrival, or have shot "
|
| 225 |
+
f"{i + 1} do the travelling."
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# The same defect seen from the other side. Both LM Studio models plated
|
| 229 |
+
# the opening location, moved the story somewhere else, and gave the new
|
| 230 |
+
# place no plate at all -- then justified it with a rule that does not
|
| 231 |
+
# exist ("to avoid conflicting with the frame pin of the new space"). In
|
| 232 |
+
# the lighthouse plan that left the lamp room, the main setting of four of
|
| 233 |
+
# six hops, carried by beat text alone.
|
| 234 |
+
#
|
| 235 |
+
# The test is ABANDONMENT, not gaps. A plan is free to leave a transitional
|
| 236 |
+
# space unplated and come back: the pack's own Showcase plates the kitchen
|
| 237 |
+
# on shots 1-3, walks her down an unplated hallway for 4-5, and returns the
|
| 238 |
+
# plate on 6. What is always wrong is plating the opening and then letting
|
| 239 |
+
# the plan END with no plate riding, because that is the destination -- the
|
| 240 |
+
# place the film spends its last hops in -- with nothing holding it.
|
| 241 |
+
#
|
| 242 |
+
# A plan with no place plates at all is a legitimate shape and says nothing
|
| 243 |
+
# here: the Starter ships that way so it runs before any pictures exist.
|
| 244 |
+
covered = set()
|
| 245 |
+
for r in (ref_plan.get("refs") or []):
|
| 246 |
+
if r.get("subject"):
|
| 247 |
+
continue
|
| 248 |
+
covered |= set(r.get("shots") or range(1, len(shots) + 1))
|
| 249 |
+
n = len(shots)
|
| 250 |
+
if covered and n not in covered:
|
| 251 |
+
first_bare = n
|
| 252 |
+
while first_bare - 1 >= 1 and (first_bare - 1) not in covered:
|
| 253 |
+
first_bare -= 1
|
| 254 |
+
span = (f"shot {first_bare}" if first_bare == n
|
| 255 |
+
else f"shots {first_bare}-{n}")
|
| 256 |
+
warnings.append(
|
| 257 |
+
f"{span}: the plan's place plates stop riding and never resume, so "
|
| 258 |
+
f"the film ends somewhere no picture describes. A location "
|
| 259 |
+
f"introduced part way through needs its own plate on the hop it "
|
| 260 |
+
f"arrives and every hop after -- tightening a plate to its own "
|
| 261 |
+
f"shots does not mean the next location goes without one."
|
| 262 |
+
)
|
| 263 |
+
return warnings
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def compile_blocks(shots, establish=None, ref_plan=None):
|
| 267 |
+
"""Compile a plan into one body string per hop, ready for the chain loop."""
|
| 268 |
+
for w in check_coherence(shots):
|
| 269 |
+
print(f"[{TAG}] note: {w}", flush=True)
|
| 270 |
+
for w in check_place_handoff(shots, ref_plan):
|
| 271 |
+
print(f"[{TAG}] note: {w}", flush=True)
|
| 272 |
+
return [_d.compile_shot(s, i, establish) for i, s in enumerate(shots)]
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def describe(shots):
|
| 276 |
+
"""One-line-per-shot summary for the console, so the plan is auditable."""
|
| 277 |
+
rows = []
|
| 278 |
+
for i, s in enumerate(shots):
|
| 279 |
+
bits = [f"{k}={v}" for k, v in s["directives"].items()]
|
| 280 |
+
extra = []
|
| 281 |
+
if s["seed"] is not None:
|
| 282 |
+
extra.append(f"seed={s['seed']}")
|
| 283 |
+
if s["steps"] is not None:
|
| 284 |
+
extra.append(f"steps={s['steps']}")
|
| 285 |
+
if s["duration"]:
|
| 286 |
+
extra.append(f"duration={s['duration']}")
|
| 287 |
+
if s["locked"]:
|
| 288 |
+
extra.append("locked")
|
| 289 |
+
beat = (s["beat"] or "").replace(chr(10), " ")
|
| 290 |
+
if len(beat) > 60:
|
| 291 |
+
beat = beat[:57] + "..."
|
| 292 |
+
rows.append(
|
| 293 |
+
f" shot {i + 1} [{s['id']}] {' '.join(bits + extra) or '(no directives)'}"
|
| 294 |
+
+ chr(10) + f" beat: {beat or '(continues)'}"
|
| 295 |
+
)
|
| 296 |
+
return chr(10).join(rows)
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""H3 Chain Preview -- the chain's progress panel, as its own node.
|
| 2 |
+
|
| 3 |
+
Split off `HandTieClips` deliberately. That node already carries a 21-widget shot
|
| 4 |
+
editor and a reference rail; folding a large media panel onto it as well makes
|
| 5 |
+
an unreadable node, and the panel wants to be somewhere else in the graph
|
| 6 |
+
anyway -- on the IMAGE wire, right before CreateVideo, where the thing being
|
| 7 |
+
previewed actually flows.
|
| 8 |
+
|
| 9 |
+
It is a passthrough: images and audio go straight out again, unchanged, so
|
| 10 |
+
dropping it into an existing chain costs nothing and removing it changes no
|
| 11 |
+
pixels. What it adds is a place for the live `h3_refchain_preview` events to
|
| 12 |
+
land, and an end-of-run report of the two numbers the chain never surfaced --
|
| 13 |
+
which pin mechanism each hop actually used, and how far the audio has drifted
|
| 14 |
+
from the video.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from server import PromptServer
|
| 20 |
+
except Exception: # noqa: BLE001 -- headless / API-only runs have no server
|
| 21 |
+
PromptServer = None
|
| 22 |
+
|
| 23 |
+
TAG = "HTCChainPreview"
|
| 24 |
+
FPS = 24
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _send(payload):
|
| 28 |
+
if PromptServer is None:
|
| 29 |
+
return
|
| 30 |
+
try:
|
| 31 |
+
PromptServer.instance.send_sync(
|
| 32 |
+
"h3_chain_preview", payload, PromptServer.instance.client_id)
|
| 33 |
+
except Exception as e: # noqa: BLE001
|
| 34 |
+
print(f"[{TAG}] preview send skipped: {e!r}", flush=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class HTCChainPreview:
|
| 38 |
+
"""Pass images and audio through, and report the join.
|
| 39 |
+
|
| 40 |
+
Wire between HandTieClips and CreateVideo.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
@classmethod
|
| 44 |
+
def INPUT_TYPES(cls):
|
| 45 |
+
return {
|
| 46 |
+
"required": {
|
| 47 |
+
"images": ("IMAGE", {"tooltip": "From H3 Ref2VA Chain. Passed straight through."}),
|
| 48 |
+
},
|
| 49 |
+
"optional": {
|
| 50 |
+
"audio": ("AUDIO", {
|
| 51 |
+
"tooltip": (
|
| 52 |
+
"The chain's audio. Wire it and the panel reports A/V "
|
| 53 |
+
"drift, which accumulates roughly 40 ms per hop from the "
|
| 54 |
+
"audio crossfade at each join."
|
| 55 |
+
),
|
| 56 |
+
}),
|
| 57 |
+
"info": ("STRING", {
|
| 58 |
+
"forceInput": True,
|
| 59 |
+
"tooltip": "The chain's `info` output. Shown in the panel's detail view, never in the status strip.",
|
| 60 |
+
}),
|
| 61 |
+
},
|
| 62 |
+
"hidden": {"unique_id": "UNIQUE_ID"},
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
RETURN_TYPES = ("IMAGE", "AUDIO")
|
| 66 |
+
RETURN_NAMES = ("images", "audio")
|
| 67 |
+
FUNCTION = "run"
|
| 68 |
+
CATEGORY = "Hand Tie Clips"
|
| 69 |
+
OUTPUT_NODE = False
|
| 70 |
+
DESCRIPTION = (
|
| 71 |
+
"Preview panel for an H3 Ref2VA chain. Sits on the IMAGE wire before "
|
| 72 |
+
"CreateVideo and passes images and audio through untouched. Shows the "
|
| 73 |
+
"live sample, per-hop progress, which pin mechanism each hop used "
|
| 74 |
+
"(Motion-Context vs the AddGuide fallback), and end-of-run A/V drift."
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
def run(self, images, audio=None, info="", unique_id=None):
|
| 78 |
+
frames = int(images.shape[0]) if images is not None else 0
|
| 79 |
+
video_s = frames / float(FPS)
|
| 80 |
+
payload = {
|
| 81 |
+
"node_id": unique_id,
|
| 82 |
+
"frames": frames,
|
| 83 |
+
"video_s": round(video_s, 3),
|
| 84 |
+
"width": int(images.shape[2]) if frames else 0,
|
| 85 |
+
"height": int(images.shape[1]) if frames else 0,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if isinstance(audio, dict) and audio.get("waveform") is not None:
|
| 89 |
+
wav = audio["waveform"]
|
| 90 |
+
sr = int(audio.get("sample_rate") or 0)
|
| 91 |
+
if sr:
|
| 92 |
+
audio_s = float(wav.shape[-1]) / float(sr)
|
| 93 |
+
drift_ms = (audio_s - video_s) * 1000.0
|
| 94 |
+
payload["audio_s"] = round(audio_s, 3)
|
| 95 |
+
payload["sample_rate"] = sr
|
| 96 |
+
payload["drift_ms"] = round(drift_ms, 1)
|
| 97 |
+
# Worth a console line too: it is cumulative across a chain and
|
| 98 |
+
# nothing else in the pack reports it.
|
| 99 |
+
print(f"[{TAG}] {frames}f / {video_s:.2f}s video, "
|
| 100 |
+
f"{audio_s:.2f}s audio, drift {drift_ms:+.0f} ms", flush=True)
|
| 101 |
+
if info:
|
| 102 |
+
payload["info"] = str(info)
|
| 103 |
+
|
| 104 |
+
_send(payload)
|
| 105 |
+
return (images, audio)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# -- pre-rename ids ----------------------------------------------------------
|
| 111 |
+
# A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
|
| 112 |
+
# lists the node a second time in search: ComfyUI falls back to the mapping key
|
| 113 |
+
# when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
|
| 114 |
+
# DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
|
| 115 |
+
# frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
|
| 116 |
+
# while leaving it fully functional in workflows that name it.
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class _LegacyH3ChainPreview(HTCChainPreview):
|
| 120 |
+
DEPRECATED = True
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
NODE_CLASS_MAPPINGS = {
|
| 124 |
+
"HTCChainPreview": HTCChainPreview,
|
| 125 |
+
"H3ChainPreview": _LegacyH3ChainPreview,
|
| 126 |
+
}
|
| 127 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"HTCChainPreview": "H3 Chain Preview"}
|
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Authoring prompt
|
| 2 |
+
|
| 3 |
+
Paste everything between the rules below into any chat model, then describe your
|
| 4 |
+
scene in plain language. It answers with two JSON blocks: paste the first into
|
| 5 |
+
the **JSON** box under **SCRIPT** (`shot_plan`) and the second into the **JSON**
|
| 6 |
+
box under **REFERENCES** (`ref_plan`). Each panel section has its own.
|
| 7 |
+
|
| 8 |
+
The node validates everything it is given, so a model that gets this wrong is
|
| 9 |
+
caught rather than obeyed. If a plan is rejected, paste the error back — every
|
| 10 |
+
message names the shot or the reference it came from.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
You are writing shot plans for **Hand Tie Clips**, a ComfyUI node that
|
| 15 |
+
renders a multi-hop video-with-audio chain. Each hop is one continuous clip;
|
| 16 |
+
consecutive hops are joined by pinning the previous hop's final frames and audio
|
| 17 |
+
tail into the next hop's conditioning.
|
| 18 |
+
|
| 19 |
+
You produce exactly two JSON documents: `shot_plan` and `ref_plan`.
|
| 20 |
+
|
| 21 |
+
## How the renderer behaves
|
| 22 |
+
|
| 23 |
+
These are not style preferences. They are how this model fails.
|
| 24 |
+
|
| 25 |
+
1. **Sampling runs at cfg 1.0 with no negative branch, so the prompt is purely
|
| 26 |
+
additive.** Every concept named is added to the conditioning. Nothing can be
|
| 27 |
+
removed by mentioning it — "no cut" puts the word *cut* in front of the
|
| 28 |
+
encoder. **Never write a negation.** Write what the shot *is* doing.
|
| 29 |
+
|
| 30 |
+
2. **Never name the thing you want to end.** "The cook stops talking" keeps her
|
| 31 |
+
talking. Write the state you want as **a pose plus a sound**: "leans back
|
| 32 |
+
against the counter with her lips closed, and lets her eyes move slowly
|
| 33 |
+
across the room. The kitchen is quiet apart from the low hum of the
|
| 34 |
+
refrigerator."
|
| 35 |
+
|
| 36 |
+
**The ban is on the idea, not on a list of words.** Two different models,
|
| 37 |
+
given a scene that ended *"the storm finally stops"*, avoided every banned
|
| 38 |
+
word below and still wrote *"The storm's roar begins to fade... raindrops
|
| 39 |
+
strike the glass with decreasing force"* and *"The storm has passed."* Both
|
| 40 |
+
name the ending. Both keep the storm. Fading, passing, waning, subsiding,
|
| 41 |
+
dying down, easing off, receding, growing quiet and dropping away are the
|
| 42 |
+
same move wearing different words, and every one of them adds the thing it
|
| 43 |
+
describes.
|
| 44 |
+
|
| 45 |
+
Test every sentence with one question: **is this a thing that is happening,
|
| 46 |
+
or a thing that has finished happening?** Only the first survives at cfg 1.0.
|
| 47 |
+
To end a storm, write the world that is left: *"Water runs down the glass in
|
| 48 |
+
slow threads. The mechanism turns with a low hum and the sea moves against
|
| 49 |
+
the rocks below."*
|
| 50 |
+
|
| 51 |
+
These words are rejected outright, in any beat: **no, not, never, n't,
|
| 52 |
+
without, none of, stop, stops, silent, silence.** They are the crudest
|
| 53 |
+
examples of the idea, not the whole of it -- a beat clean of all ten can
|
| 54 |
+
still break this rule, and usually does.
|
| 55 |
+
|
| 56 |
+
3. **Audio is always generated.** Silence written as an absence produces speech,
|
| 57 |
+
because the model fills the track with the most likely thing. Silence must be
|
| 58 |
+
written **as a sound** — room tone, a refrigerator, a distant car.
|
| 59 |
+
|
| 60 |
+
4. **Ambience must be narrowband and specific.** "faint street noise through the
|
| 61 |
+
window" is broadband and renders as a five-second hiss. "the low hum of the
|
| 62 |
+
refrigerator" renders as a refrigerator. When in doubt, name a single
|
| 63 |
+
discrete event: "a single click from the refrigerator, then stillness".
|
| 64 |
+
|
| 65 |
+
5. **A state change belongs at the END of the previous shot.** Every hop opens
|
| 66 |
+
holding the frames it was handed, and the audio pin carries the previous
|
| 67 |
+
hop's tail across the join. If shot 2 ends mid-sentence, nothing you write in
|
| 68 |
+
shot 3 will make shot 3 start quiet. To be silent, still, or elsewhere in a
|
| 69 |
+
shot, arrive there before the previous shot ends.
|
| 70 |
+
|
| 71 |
+
6. **A hop that ends on dialogue keeps talking into the next hop.** The audio
|
| 72 |
+
pin carries the previous hop's tail, so speech at the end of hop N becomes
|
| 73 |
+
the opening of hop N+1 and propagates down the whole chain. Land each spoken
|
| 74 |
+
line in the MIDDLE of its hop and leave a non-verbal action running into the
|
| 75 |
+
seam — slicing, walking, a hand on a doorframe. Give every hop with no
|
| 76 |
+
dialogue its own narrowband sound bed (footsteps, a refrigerator, rain on
|
| 77 |
+
glass), or the audio has nowhere to go but back to speech.
|
| 78 |
+
|
| 79 |
+
7. **A beat must be true from any plausible ending of the hop before it.** A
|
| 80 |
+
hop routinely over-delivers -- given a movement it finishes it, and then some.
|
| 81 |
+
If the next beat instructs something its own opening frames have already done,
|
| 82 |
+
the only way to obey is to reset the scene, which reads as a hard cut about a
|
| 83 |
+
second and a half *into* the hop rather than at the seam. Give one hop the
|
| 84 |
+
whole of one movement, and write the next beat so it holds whether the
|
| 85 |
+
previous hop stopped short or ran ahead.
|
| 86 |
+
|
| 87 |
+
8. **A walk between two different rooms is `match_cut`, not `continuous`.**
|
| 88 |
+
Asking for one unbroken take across a real location change makes the model
|
| 89 |
+
morph one room into the other mid-movement.
|
| 90 |
+
|
| 91 |
+
9. **Shot 1 establishes everything. Every later shot carries ONLY the new
|
| 92 |
+
beat.** Never re-describe the face, the clothes or the room after shot 1 —
|
| 93 |
+
the reference photographs, the register and the frame pin already carry all
|
| 94 |
+
three, and repeating them competes with the pin instead of reinforcing it.
|
| 95 |
+
|
| 96 |
+
10. **Write `@tags` into the action line.** Describing a reference in the register
|
| 97 |
+
does not make the model use it; the beat is what drives the frame. Write
|
| 98 |
+
"stands at the counter in @kitchen", not merely a `@kitchen` entry in the
|
| 99 |
+
register. Phrase a place as a place that is depicted, not as a container to
|
| 100 |
+
be placed inside. **A beat is plain prose.** A tag written inside a beat
|
| 101 |
+
carries no backticks, asterisks or other markdown -- every character you
|
| 102 |
+
write reaches the encoder literally, so formatting becomes noise in the
|
| 103 |
+
conditioning.
|
| 104 |
+
|
| 105 |
+
11. **Every object that persists gets an adjective.** A bare noun is
|
| 106 |
+
unanchored: each hop encodes "the bowl" from scratch and is free to make it
|
| 107 |
+
steel in one hop and porcelain in the next. Name a colour or a material the
|
| 108 |
+
first time and repeat it every time -- "the white porcelain bowl", "the dark
|
| 109 |
+
blue wrapped hilt". State it in the subject's `context` as a **property**,
|
| 110 |
+
never as a location.
|
| 111 |
+
|
| 112 |
+
12. **Name the visual style in shot 1, once.** Nothing else states it. If the
|
| 113 |
+
user asks for 2D anime, film noir, stop motion, watercolour or any look other
|
| 114 |
+
than live action, shot 1's beat opens with it: "Hand-drawn 2D anime in the
|
| 115 |
+
style of ..., crisp inked linework over painted backgrounds." Reference
|
| 116 |
+
pictures pull the look one way and unstated text pulls it back toward
|
| 117 |
+
photoreal video, and the text wins often enough to matter. Later shots do not
|
| 118 |
+
repeat it -- it belongs in `setting.locked` on the H3 Continuity State node,
|
| 119 |
+
which rides every hop from 2 on.
|
| 120 |
+
|
| 121 |
+
The node prepends an establishing line of its own to hop 1, and its default
|
| 122 |
+
asserts live action. When shot 1 opens by naming a medium, that default is
|
| 123 |
+
dropped automatically, so a styled plan needs nothing else from you. Name the
|
| 124 |
+
medium in the **first sentence** of shot 1 for that to happen.
|
| 125 |
+
|
| 126 |
+
13. **Set `tail` on the final shot** to `settle` or `hold`. Left at its default
|
| 127 |
+
`ongoing`, the model is told action is still underway at the last frame and
|
| 128 |
+
will invent something to satisfy that — a stray gesture, or a stray line of
|
| 129 |
+
dialogue in the closing second.
|
| 130 |
+
|
| 131 |
+
## `shot_plan`
|
| 132 |
+
|
| 133 |
+
One shot per hop. **The number of shots is the number of hops.**
|
| 134 |
+
|
| 135 |
+
```json
|
| 136 |
+
{
|
| 137 |
+
"shots": [
|
| 138 |
+
{
|
| 139 |
+
"id": "s1",
|
| 140 |
+
"beat": "...",
|
| 141 |
+
"directives": {"camera": "hold", "framing": "medium", "pace": "steady", "tail": "ongoing"}
|
| 142 |
+
}
|
| 143 |
+
]
|
| 144 |
+
}
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
Valid shot fields, and no others: `id`, `beat`, `directives`, `prose`, `seed`,
|
| 148 |
+
`steps`, `duration`, `locked`. Only `beat` is required. An unknown field is a
|
| 149 |
+
hard error.
|
| 150 |
+
|
| 151 |
+
Directive axes, and no others. Every axis is optional; an unset axis emits
|
| 152 |
+
nothing at all, which costs no tokens.
|
| 153 |
+
|
| 154 |
+
| axis | values |
|
| 155 |
+
|---|---|
|
| 156 |
+
| `join` | `continuous`, `match_cut`, `hard_cut` — **omit on shot 1**, which has nothing to join to |
|
| 157 |
+
| `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
|
| 158 |
+
| `framing` | `keep`, `wide`, `medium`, `close` |
|
| 159 |
+
| `pace` | `slow`, `steady`, `brisk` |
|
| 160 |
+
| `tail` | `ongoing` (default), `settle`, `hold` |
|
| 161 |
+
|
| 162 |
+
Two combinations are contradictions and will be flagged:
|
| 163 |
+
|
| 164 |
+
- `join: continuous` + a framing change + `camera: hold`. With the camera still,
|
| 165 |
+
the only way to reach a new framing is a cut. Earn it on the move (`push_in`,
|
| 166 |
+
`pull_back`, `pan_follow`) or use `framing: keep`.
|
| 167 |
+
- `push_in` with `wide`, or `pull_back` with `close`. The move points the
|
| 168 |
+
opposite way from the destination.
|
| 169 |
+
|
| 170 |
+
`duration` may override the chain per shot, using exactly these labels:
|
| 171 |
+
`"5 s"`, `"7 s"`, `"8 s"`, `"10 s"`, `"15 s"`.
|
| 172 |
+
|
| 173 |
+
### How long a beat has to be
|
| 174 |
+
|
| 175 |
+
A beat has to fill its whole hop. Written short, the model finishes the action
|
| 176 |
+
early and invents something for the seconds left over -- most often a cut to the
|
| 177 |
+
reference photograph in the closing moments. **Ask the user how long each hop is
|
| 178 |
+
if they have not said**, and size every beat to it.
|
| 179 |
+
|
| 180 |
+
**Count the words in each beat before you answer, and write the count down.**
|
| 181 |
+
This is the instruction most often ignored, and ignoring it is not a small
|
| 182 |
+
miss. Two different models asked for six 15 s hops both returned beats averaging
|
| 183 |
+
**54 words** -- every single beat under the floor -- and the same two models,
|
| 184 |
+
asked for much shorter hops, returned 40 to 48. Left to itself a model writes
|
| 185 |
+
about fifty words whatever the hop length. Fifty words is right for a 7 s hop
|
| 186 |
+
and is half of what 15 s needs. The table below is not a style note; it is the
|
| 187 |
+
one part of this document you have to do arithmetic for.
|
| 188 |
+
|
| 189 |
+
| hop | words in the beat |
|
| 190 |
+
|---|---|
|
| 191 |
+
| `5 s` | 30-45 |
|
| 192 |
+
| `7 s` | 30-55 |
|
| 193 |
+
| `8 s` | 35-60 |
|
| 194 |
+
| `10 s` | 45-75 |
|
| 195 |
+
| `15 s` | 70-100 |
|
| 196 |
+
|
| 197 |
+
A worked `15 s` beat, at 74 words, from a rendered eight-hop chain:
|
| 198 |
+
|
| 199 |
+
> They close the distance together and the blades meet at the centre of the clearing, white sparks bursting from the impact as the camera orbits around the lock. @warrior_face turns the heavier blade aside and cuts back across the body; @enemy_shadow catches it on the flat and steps in behind it. Steel rings sharp and resonant on every contact, armour plates grind against one another, and boots drag hard over stone between the exchanges.
|
| 200 |
+
|
| 201 |
+
Read how the length is spent, because padding to a word count fails differently
|
| 202 |
+
but just as badly. One continuous movement carries the whole hop -- they close,
|
| 203 |
+
the blades meet, one turns the other aside, the other steps in. The camera move
|
| 204 |
+
is named inside the action rather than after it. The last third is the sound bed
|
| 205 |
+
and nothing else: three specific noises, each tied to a thing on screen. There
|
| 206 |
+
is no second event and no scene change.
|
| 207 |
+
|
| 208 |
+
The `5 s` and `7 s` rows are the measured spread of the two plans that ship with
|
| 209 |
+
the node. Their thinnest beat is 28 words in a 7 s hop, carrying one simple
|
| 210 |
+
action; their fullest is 56. The `15 s` row now has one measurement behind it --
|
| 211 |
+
the chain the example above comes from ran 74 to 90 words a beat across eight
|
| 212 |
+
hops and every hop delivered its beat in full, with no hop idling into the
|
| 213 |
+
reference. **The `8 s` and `10 s` rows are still interpolated, not measured.**
|
| 214 |
+
If the closing seconds drift or cut to the reference photograph, the beat was
|
| 215 |
+
short.
|
| 216 |
+
|
| 217 |
+
Fill that length with *continuous* material -- one movement that takes the whole
|
| 218 |
+
hop, what the camera is doing while it happens, and the sound bed underneath --
|
| 219 |
+
rather than with more separate events. Four events crammed into one hop is the
|
| 220 |
+
over-delivery in rule 7, and it costs you the next hop's opening.
|
| 221 |
+
|
| 222 |
+
### Dialogue
|
| 223 |
+
|
| 224 |
+
Put the spoken line inside the beat, in **single** quotes:
|
| 225 |
+
|
| 226 |
+
```
|
| 227 |
+
@hero_face looks up from the chopping board and says, 'You are early. I have
|
| 228 |
+
barely started.'
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
Single quotes survive copy-paste; escaped double quotes are the most common
|
| 232 |
+
cause of a rejected plan. Budget roughly one line of dialogue per 5–7 seconds of
|
| 233 |
+
hop — a long speech in a short hop is truncated mid-word, and that truncation is
|
| 234 |
+
then pinned into the next hop's audio.
|
| 235 |
+
|
| 236 |
+
## `ref_plan`
|
| 237 |
+
|
| 238 |
+
```json
|
| 239 |
+
{
|
| 240 |
+
"refs": [
|
| 241 |
+
{"tag": "hero_face", "file": "face.jpg", "subject": 1,
|
| 242 |
+
"retention": "fully_preserved", "shots": [1, 4],
|
| 243 |
+
"desc": "head-and-shoulders, even light"}
|
| 244 |
+
],
|
| 245 |
+
"subjects": {
|
| 246 |
+
"1": {"name": "the cook",
|
| 247 |
+
"locked": "the same face, the same short dark hair",
|
| 248 |
+
"context": "the apron stays tied over the grey t-shirt"}
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
Valid ref fields: `tag`, `file`, `subject`, `retention`, `desc`, `shots`. (There
|
| 254 |
+
is also `slot`, which is derived from list position — never author it.)
|
| 255 |
+
|
| 256 |
+
- **`tag`** is required, unique, `[A-Za-z0-9_]+`, and is what appears in beats
|
| 257 |
+
as `@tag`.
|
| 258 |
+
- **`file`** is a bare filename in `ComfyUI/input/h3_refs`, never a path. A
|
| 259 |
+
filename that is not in that folder **stops the run** -- it is an error, not a
|
| 260 |
+
warning, so a placeholder you invent must be one the user then actually
|
| 261 |
+
supplies. If the user has not told you their filenames, use clear placeholders
|
| 262 |
+
and list every one of them at the end of your answer, with what each picture
|
| 263 |
+
should show.
|
| 264 |
+
- **`subject`** is an integer ≥ 1 grouping pictures **of the same person**.
|
| 265 |
+
Declaring two different people under one subject number makes the model render
|
| 266 |
+
**the average of their faces**. One number per person, always.
|
| 267 |
+
- **`retention`** is one of:
|
| 268 |
+
- `fully_preserved` — face, bone structure and hairstyle carry over exactly
|
| 269 |
+
- `partially_copy` — the garment and its cut carry over
|
| 270 |
+
- `reference` — layout, surfaces and light carry over, i.e. a place
|
| 271 |
+
|
| 272 |
+
It defaults to `fully_preserved` when `subject` is set, `reference` otherwise.
|
| 273 |
+
- **`shots`** is the list of 1-based hops the picture rides on.
|
| 274 |
+
|
| 275 |
+
Every subject that appears in `subjects` must be claimed by at least one ref, or
|
| 276 |
+
the plan is rejected. Give every subject a **`locked`**: pictures put a face in
|
| 277 |
+
front of the encoder, but `locked` is what carries the identity across a hop
|
| 278 |
+
where the picture is absent. At most **9 references in the whole plan** -- the limit counts entries in
|
| 279 |
+
`refs`, not pictures per hop.
|
| 280 |
+
|
| 281 |
+
### Scheduling references is the part that decides whether continuity holds
|
| 282 |
+
|
| 283 |
+
Write `@tag` for a person on any hop you like. Hop 1 resolves it to
|
| 284 |
+
`<Subject N>` against its definitions block; hop 2 onward resolves it to the
|
| 285 |
+
subject's `name`, which binds to the identity sentence every continuation hop
|
| 286 |
+
carries. **Every subject therefore needs a `name`.**
|
| 287 |
+
|
| 288 |
+
**A reference with no `shots` list rides hop 1 only.** That default is right
|
| 289 |
+
for a **place** plate: a room still riding a hop set somewhere else beats the
|
| 290 |
+
frame pin, because the model has a crisp picture of one room and a noisy
|
| 291 |
+
carried-over frame of another, and it follows the still. **List every hop a
|
| 292 |
+
picture belongs on, explicitly.**
|
| 293 |
+
|
| 294 |
+
**A face plate is the opposite. Put it on every hop.** Two six-hop renders
|
| 295 |
+
settled this. A face plate rode a hop set in a space it had never seen and held
|
| 296 |
+
cleanly; the hop scheduled with no references came back a different person, and
|
| 297 |
+
nothing after it recovered. Identity drift does not self-correct. `locked` holds
|
| 298 |
+
a face that is still right; only a plate rebuilds one that is gone.
|
| 299 |
+
|
| 300 |
+
- Put every **face** reference on every hop: `"shots": [1, 2, 3, 4, 5, 6]`.
|
| 301 |
+
- Keep a **place** reference on every hop set in that place, and off every hop
|
| 302 |
+
that is not.
|
| 303 |
+
- An **outfit** plate can ride hop 1 only, but then `context` is the only thing
|
| 304 |
+
holding the wardrobe from hop 2 on, so name its colours there.
|
| 305 |
+
- Never let a place plate shot in one location ride a hop set somewhere else.
|
| 306 |
+
- **The hop that changes location belongs entirely to the new place.** Put the
|
| 307 |
+
departure at the end of the *previous* beat, so that no place plate ever rides
|
| 308 |
+
a hop which opens somewhere it was not photographed.
|
| 309 |
+
- **A location introduced part way through needs its own plate, on the hop it
|
| 310 |
+
arrives and every hop after.** Tightening a place plate to its own shots does
|
| 311 |
+
**not** mean the next location goes without one. This is the most common
|
| 312 |
+
mistake made on this rule: two models both plated the opening location, moved
|
| 313 |
+
the story to a second one, gave that second one no plate at all, and wrote a
|
| 314 |
+
confident justification for it. In one case that left the film's main setting
|
| 315 |
+
-- four of six hops -- held by beat text alone. Ask for the extra picture. If
|
| 316 |
+
the plan ends somewhere no picture describes, the plan is wrong.
|
| 317 |
+
|
| 318 |
+
## What to produce
|
| 319 |
+
|
| 320 |
+
1. A one-paragraph plan in prose: how many hops, what each covers, where the
|
| 321 |
+
references sit and why.
|
| 322 |
+
2. ```json ``` block — the `shot_plan`.
|
| 323 |
+
3. ```json ``` block — the `ref_plan`.
|
| 324 |
+
4. A short list of any pictures the user still needs to supply, with the
|
| 325 |
+
filename you used for each and what the picture should show.
|
| 326 |
+
|
| 327 |
+
Before you answer, check every one of these:
|
| 328 |
+
|
| 329 |
+
- [ ] Shot count matches the hop count the user asked for.
|
| 330 |
+
- [ ] Every beat's word count has been counted, written down, and falls inside
|
| 331 |
+
the band for its hop length. A 15 s hop needs 70-100 words; fifty is
|
| 332 |
+
what comes out when this check is skipped.
|
| 333 |
+
- [ ] No shot contains a negation anywhere.
|
| 334 |
+
- [ ] No beat contains any of: no, not, never, n't, without, none of, stop,
|
| 335 |
+
stops, silent, silence.
|
| 336 |
+
- [ ] No beat describes a thing that has finished happening -- nothing fades,
|
| 337 |
+
passes, wanes, subsides, dies down, eases off, recedes or grows quiet.
|
| 338 |
+
Passing this check is not the same as passing the one above it.
|
| 339 |
+
- [ ] No beat contains backticks, asterisks or any other markdown. Beats are
|
| 340 |
+
plain prose; a tag inside a beat is written bare, as @tag.
|
| 341 |
+
- [ ] No shot names an action ending; endings are written as a pose plus a sound.
|
| 342 |
+
- [ ] Any quiet moment names a specific narrowband sound.
|
| 343 |
+
- [ ] No hop ends on a spoken line; every dialogue-free hop names a sound of its own.
|
| 344 |
+
- [ ] Any hop that changes location joins on `match_cut` or `hard_cut`, not `continuous`.
|
| 345 |
+
- [ ] Every state change lands at the end of the shot *before* the one that
|
| 346 |
+
needs it.
|
| 347 |
+
- [ ] Shot 1 establishes; no later shot re-describes face, clothes or room.
|
| 348 |
+
- [ ] Shot 1 names the visual style, if the user asked for anything other
|
| 349 |
+
than live action.
|
| 350 |
+
- [ ] Every persistent object and garment carries an adjective, in the beat
|
| 351 |
+
and again in `context`.
|
| 352 |
+
- [ ] Each beat holds true whether the hop before it stopped short or ran
|
| 353 |
+
ahead of its beat.
|
| 354 |
+
- [ ] Every `@tag` used in a beat exists in `ref_plan`, spelled identically.
|
| 355 |
+
- [ ] Every `@tag` used on hop N has that N in its ref's `shots` list.
|
| 356 |
+
- [ ] Every location the film visits has a place plate covering the hops set
|
| 357 |
+
there, including any location introduced after hop 1. The final hop is
|
| 358 |
+
covered by one.
|
| 359 |
+
- [ ] `join` is absent from shot 1.
|
| 360 |
+
- [ ] The final shot sets `tail` to `settle` or `hold`.
|
| 361 |
+
- [ ] Compare each shot's `framing` with the previous shot's: where it
|
| 362 |
+
changes on `join: continuous`, the camera is moving, not `hold`. And no
|
| 363 |
+
`push_in`+`wide`, no `pull_back`+`close`.
|
| 364 |
+
- [ ] Every subject in `subjects` is claimed by a ref, and has both a `name`
|
| 365 |
+
and a `locked`.
|
| 366 |
+
- [ ] `refs` holds at most 9 entries in total.
|
| 367 |
+
- [ ] Dialogue uses single quotes.
|
| 368 |
+
- [ ] Both blocks are valid JSON: no trailing commas, no comments, no smart
|
| 369 |
+
quotes, plain ASCII, and no field outside the lists above.
|
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Worked example: six hops, three pictures
|
| 2 |
+
|
| 3 |
+
This is the plan inside `workflows/HandTieClips_Showcase.json`, reproduced here so
|
| 4 |
+
it can be shown to a model as an example of the shape and the reasoning. It is
|
| 5 |
+
generated from that workflow, so the two cannot drift apart.
|
| 6 |
+
|
| 7 |
+
The scene: a cook in a kitchen speaks a line, crosses the room, leaves through a
|
| 8 |
+
doorway into a hallway the register has no picture of, speaks again there, and
|
| 9 |
+
comes back.
|
| 10 |
+
|
| 11 |
+
## What each hop is for
|
| 12 |
+
|
| 13 |
+
| hop | id | references active |
|
| 14 |
+
|---|---|---|
|
| 15 |
+
| 1 | s1 | @hero_face, @hero_outfit, @kitchen |
|
| 16 |
+
| 2 | s2 | @kitchen |
|
| 17 |
+
| 3 | s3 | @kitchen |
|
| 18 |
+
| 4 | s4 | @hero_face |
|
| 19 |
+
| 5 | s5 | **none** |
|
| 20 |
+
| 6 | s6 | @kitchen |
|
| 21 |
+
|
| 22 |
+
Three things in that table are the whole point:
|
| 23 |
+
|
| 24 |
+
- **Hop 3** carries the kitchen while she is leaving it, and hop 4 does not.
|
| 25 |
+
The hallway is a space no reference describes, so the model must invent it —
|
| 26 |
+
and a kitchen plate riding hop 4 would drag her back into the kitchen.
|
| 27 |
+
- **Hop 4** re-asserts the face. Entering an unseen space is where identity
|
| 28 |
+
drift starts, and re-asserting there is cheaper than recovering on 5 and 6.
|
| 29 |
+
- **Hop 5 has no references at all.** Identity, wardrobe and voice ride on the
|
| 30 |
+
frame pin plus `subjects.1.locked` and `.context` alone. If she is still the
|
| 31 |
+
same person in the same apron with the same voice, the register works.
|
| 32 |
+
|
| 33 |
+
Note also that the dialogue lands on hops 1 and 5 — the establishing shot and
|
| 34 |
+
the one with no pictures — and that both use single quotes.
|
| 35 |
+
|
| 36 |
+
## shot_plan
|
| 37 |
+
|
| 38 |
+
```json
|
| 39 |
+
{
|
| 40 |
+
"shots": [
|
| 41 |
+
{
|
| 42 |
+
"id": "s1",
|
| 43 |
+
"beat": "@hero_face stands at the counter in @kitchen, an apron over a grey t-shirt. She looks up from the chopping board and says, 'You are early. I have barely started.' She turns back to the board and goes on slicing, the knife tapping steadily against the wood.",
|
| 44 |
+
"directives": {
|
| 45 |
+
"camera": "hold",
|
| 46 |
+
"framing": "medium",
|
| 47 |
+
"pace": "steady",
|
| 48 |
+
"tail": "ongoing"
|
| 49 |
+
}
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"id": "s2",
|
| 53 |
+
"beat": "She sets the knife down and walks the length of the counter to the window of @kitchen, looking out at the street. Her steps are soft on the tiles and the refrigerator hums behind her.",
|
| 54 |
+
"directives": {
|
| 55 |
+
"join": "continuous",
|
| 56 |
+
"camera": "pan_follow",
|
| 57 |
+
"framing": "medium",
|
| 58 |
+
"pace": "steady",
|
| 59 |
+
"tail": "ongoing"
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "s3",
|
| 64 |
+
"beat": "She turns from the window, crosses @kitchen and pushes through the doorway into the hallway beyond, the room falling away behind her. Her footsteps carry on the tiles.",
|
| 65 |
+
"directives": {
|
| 66 |
+
"join": "continuous",
|
| 67 |
+
"camera": "pan_follow",
|
| 68 |
+
"framing": "wide",
|
| 69 |
+
"pace": "brisk",
|
| 70 |
+
"tail": "ongoing"
|
| 71 |
+
}
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"id": "s4",
|
| 75 |
+
"beat": "@hero_face walks down a narrow hallway hung with coats, one hand trailing along the wall, her footsteps muffled on the runner. None of the kitchen is visible.",
|
| 76 |
+
"directives": {
|
| 77 |
+
"join": "continuous",
|
| 78 |
+
"camera": "handheld",
|
| 79 |
+
"framing": "medium",
|
| 80 |
+
"pace": "steady",
|
| 81 |
+
"tail": "ongoing"
|
| 82 |
+
}
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"id": "s5",
|
| 86 |
+
"beat": "She stops at the window at the end of the hall, half turns back over her shoulder and says, 'It is still raining. We will have to do it inside.' She looks back out at the glass with her lips closed, and the rain taps steadily on the pane.",
|
| 87 |
+
"directives": {
|
| 88 |
+
"join": "continuous",
|
| 89 |
+
"camera": "push_in",
|
| 90 |
+
"framing": "close",
|
| 91 |
+
"pace": "slow",
|
| 92 |
+
"tail": "settle"
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "s6",
|
| 97 |
+
"beat": "She walks back along the hallway and through the doorway to the counter in @kitchen, picking the knife up again. The refrigerator hums and the knife starts on the board.",
|
| 98 |
+
"directives": {
|
| 99 |
+
"join": "match_cut",
|
| 100 |
+
"camera": "pull_back",
|
| 101 |
+
"framing": "wide",
|
| 102 |
+
"pace": "steady",
|
| 103 |
+
"tail": "hold"
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
]
|
| 107 |
+
}
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## ref_plan
|
| 111 |
+
|
| 112 |
+
```json
|
| 113 |
+
{
|
| 114 |
+
"refs": [
|
| 115 |
+
{
|
| 116 |
+
"tag": "hero_face",
|
| 117 |
+
"file": "ref_face.jpg",
|
| 118 |
+
"subject": 1,
|
| 119 |
+
"retention": "fully_preserved",
|
| 120 |
+
"shots": [
|
| 121 |
+
1,
|
| 122 |
+
4
|
| 123 |
+
],
|
| 124 |
+
"desc": "head-and-shoulders photograph of the cook, even light"
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"tag": "hero_outfit",
|
| 128 |
+
"file": "ref_outfit.jpg",
|
| 129 |
+
"subject": 1,
|
| 130 |
+
"retention": "partially_copy",
|
| 131 |
+
"shots": [
|
| 132 |
+
1
|
| 133 |
+
],
|
| 134 |
+
"desc": "full-length photograph of the same cook, apron over a grey t-shirt"
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"tag": "kitchen",
|
| 138 |
+
"file": "ref_room.jpg",
|
| 139 |
+
"retention": "reference",
|
| 140 |
+
"shots": [
|
| 141 |
+
1,
|
| 142 |
+
2,
|
| 143 |
+
3,
|
| 144 |
+
6
|
| 145 |
+
],
|
| 146 |
+
"desc": "the kitchen: counter, window, and the light coming through it"
|
| 147 |
+
}
|
| 148 |
+
],
|
| 149 |
+
"subjects": {
|
| 150 |
+
"1": {
|
| 151 |
+
"name": "the cook",
|
| 152 |
+
"locked": "the same face, the same short dark hair, the same silver stud earrings",
|
| 153 |
+
"context": "the apron stays tied over the grey t-shirt"
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
```
|
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# prompt_pack
|
| 2 |
+
|
| 3 |
+
Everything needed to have a language model write plans for this node.
|
| 4 |
+
|
| 5 |
+
| file | what it is |
|
| 6 |
+
|---|---|
|
| 7 |
+
| **`AUTHORING_PROMPT.md`** | Paste into any chat model, describe your scene, paste the two JSON blocks it returns into the node. Start here. |
|
| 8 |
+
| **`EXAMPLE_6_HOP.md`** | A worked six-hop plan with the reasoning behind its reference schedule. Useful as a second message to the model when you want it to match a shape. |
|
| 9 |
+
| **`SYSTEM_PROMPT.md`** | The same thing with the human preamble stripped, for pasting into a *System Prompt* box. Select all, paste, done. |
|
| 10 |
+
| **`SCHEMA.json`** | JSON Schema for both documents, for anyone wiring this into their own tooling. Carries the rule set under `x-rules` and the duration/frame table under `x-duration-frames`. |
|
| 11 |
+
|
| 12 |
+
## Using it in LM Studio (or any local chat app)
|
| 13 |
+
|
| 14 |
+
1. Load a model and set its **context length to 16384 or more**. The system
|
| 15 |
+
prompt is ~4,700 tokens and the reply is another 1,000-2,000; a small window
|
| 16 |
+
truncates the rules and you get invented directive names.
|
| 17 |
+
2. Open `SYSTEM_PROMPT.md`, select all, paste it into the **System Prompt** box
|
| 18 |
+
in LM Studio's right-hand sidebar. Nothing else goes in that box.
|
| 19 |
+
3. Set **temperature 0.3-0.5**. Higher and the JSON starts growing trailing
|
| 20 |
+
commas and smart quotes.
|
| 21 |
+
4. In the chat, describe your scene in plain language, and say **how many hops**
|
| 22 |
+
and **what pictures you have**:
|
| 23 |
+
|
| 24 |
+
> Six hops. A cook in a kitchen; she says one line, walks out into a hallway,
|
| 25 |
+
> waits by a window, then comes back. I have a face photo, a photo of her
|
| 26 |
+
> apron, and a photo of the kitchen.
|
| 27 |
+
|
| 28 |
+
5. It answers with a paragraph of reasoning and two ```json``` blocks. Each
|
| 29 |
+
section of the panel has its own **JSON** disclosure at the bottom:
|
| 30 |
+
- the first block goes in the **JSON** box under **SCRIPT** (`shot_plan`);
|
| 31 |
+
- the second goes in the **JSON** box under **REFERENCES** (`ref_plan`).
|
| 32 |
+
|
| 33 |
+
Both parse as you type. Bad JSON leaves the cards and rows showing the last
|
| 34 |
+
good version and says so, rather than throwing your paste away.
|
| 35 |
+
6. Put your pictures in `ComfyUI/input/h3_refs` under the filenames the model
|
| 36 |
+
used, or drop them onto the reference thumbnails and fix the names.
|
| 37 |
+
7. Queue. **If the node rejects the plan, paste the error straight back into the
|
| 38 |
+
chat** — every message names the shot or reference it came from, and one
|
| 39 |
+
round trip usually fixes it.
|
| 40 |
+
|
| 41 |
+
Want it to match a particular shape? Paste `EXAMPLE_6_HOP.md` as a second
|
| 42 |
+
message before describing your scene.
|
| 43 |
+
|
| 44 |
+
Small local models (7B-8B) hold the JSON schema fine but drift on the prose
|
| 45 |
+
rules — they will write negations. Read the plan before queueing; a beat that
|
| 46 |
+
says "she stops talking" costs you a render.
|
| 47 |
+
|
| 48 |
+
`SCHEMA.json`, `EXAMPLE_6_HOP.md` and `SYSTEM_PROMPT.md` are **generated**, not
|
| 49 |
+
written:
|
| 50 |
+
|
| 51 |
+
```
|
| 52 |
+
python tools/gen_schema.py # regenerate the schema from the node
|
| 53 |
+
python tools/gen_schema.py --check # exit 1 if it is out of date
|
| 54 |
+
python tools/gen_example.py # regenerate the example from the workflow
|
| 55 |
+
sed -n '14,$p' prompt_pack/AUTHORING_PROMPT.md \n > prompt_pack/SYSTEM_PROMPT.md # re-strip the preamble
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Both read the installed node and the shipped workflow, so they cannot describe a
|
| 59 |
+
vocabulary or a plan that does not exist. Run `gen_schema.py` with ComfyUI's
|
| 60 |
+
interpreter — importing the node pulls in torch. Re-run both after changing
|
| 61 |
+
`directives.VOCAB`, `refs.RETENTION`, the shot fields, or the duration table;
|
| 62 |
+
`gen_schema.py` asserts against `plan._SHOT_KEYS`, `refs.REF_FIELDS` and
|
| 63 |
+
`refs.SUBJECT_FIELDS` and will fail loudly rather than emit a stale schema.
|
| 64 |
+
|
| 65 |
+
The node validates every plan it is handed, so a model that gets this wrong is
|
| 66 |
+
caught rather than obeyed. Errors name the shot or the reference they came from
|
| 67 |
+
— pasting one back to the model is usually enough to fix it.
|
| 68 |
+
|
| 69 |
+
The prose guide for humans is [`../PROMPTING.md`](../PROMPTING.md), and the
|
| 70 |
+
Starter workflow carries a condensed version of it as a card board on its own
|
| 71 |
+
canvas — including the LM Studio recipe above.
|
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"title": "H3 Ref Chain plans",
|
| 4 |
+
"description": "Two documents. `shot_plan` goes in the shot_plan widget, `ref_plan` in the ref_plan widget. Generated by tools/gen_schema.py from the installed node -- do not edit by hand.",
|
| 5 |
+
"type": "object",
|
| 6 |
+
"properties": {
|
| 7 |
+
"shot_plan": {
|
| 8 |
+
"type": "object",
|
| 9 |
+
"required": [
|
| 10 |
+
"shots"
|
| 11 |
+
],
|
| 12 |
+
"additionalProperties": false,
|
| 13 |
+
"properties": {
|
| 14 |
+
"shots": {
|
| 15 |
+
"type": "array",
|
| 16 |
+
"minItems": 1,
|
| 17 |
+
"items": {
|
| 18 |
+
"type": "object",
|
| 19 |
+
"required": [
|
| 20 |
+
"beat"
|
| 21 |
+
],
|
| 22 |
+
"additionalProperties": false,
|
| 23 |
+
"properties": {
|
| 24 |
+
"id": {
|
| 25 |
+
"type": "string",
|
| 26 |
+
"description": "Stable name. Generated if absent."
|
| 27 |
+
},
|
| 28 |
+
"beat": {
|
| 29 |
+
"type": "string",
|
| 30 |
+
"description": "What happens THIS hop. Shot 1 is the whole opening; every later shot is only the new beat."
|
| 31 |
+
},
|
| 32 |
+
"directives": {
|
| 33 |
+
"type": "object",
|
| 34 |
+
"additionalProperties": false,
|
| 35 |
+
"properties": {
|
| 36 |
+
"join": {
|
| 37 |
+
"type": "string",
|
| 38 |
+
"enum": [
|
| 39 |
+
"continuous",
|
| 40 |
+
"hard_cut",
|
| 41 |
+
"match_cut"
|
| 42 |
+
],
|
| 43 |
+
"description": "continuous: The camera and the action carry straight on from the pinned frames in one unbroken take. | match_cut: The shot changes on a matched movement, the new framing picking up the same gesture already underway. | hard_cut: A clean cut opens a new setup, the same place a moment later."
|
| 44 |
+
},
|
| 45 |
+
"camera": {
|
| 46 |
+
"type": "string",
|
| 47 |
+
"enum": [
|
| 48 |
+
"handheld",
|
| 49 |
+
"hold",
|
| 50 |
+
"orbit",
|
| 51 |
+
"pan_follow",
|
| 52 |
+
"pull_back",
|
| 53 |
+
"push_in"
|
| 54 |
+
],
|
| 55 |
+
"description": "hold: The camera holds its position throughout. | pan_follow: The camera pans smoothly to follow the movement, holding it in frame. | push_in: The camera pushes slowly in. | pull_back: The camera draws slowly back, opening the frame. | orbit: The camera arcs slowly around the action. | handheld: The camera carries a light handheld float."
|
| 56 |
+
},
|
| 57 |
+
"framing": {
|
| 58 |
+
"type": "string",
|
| 59 |
+
"enum": [
|
| 60 |
+
"close",
|
| 61 |
+
"keep",
|
| 62 |
+
"medium",
|
| 63 |
+
"wide"
|
| 64 |
+
],
|
| 65 |
+
"description": "keep: The framing stays as it is. | wide: A wide shot with the full room in view. | medium: A medium shot from the waist up. | close: A close shot, head and shoulders filling the frame."
|
| 66 |
+
},
|
| 67 |
+
"pace": {
|
| 68 |
+
"type": "string",
|
| 69 |
+
"enum": [
|
| 70 |
+
"brisk",
|
| 71 |
+
"slow",
|
| 72 |
+
"steady"
|
| 73 |
+
],
|
| 74 |
+
"description": "slow: Everything moves at an unhurried pace. | steady: The action moves at a steady, even pace. | brisk: The action moves briskly."
|
| 75 |
+
},
|
| 76 |
+
"tail": {
|
| 77 |
+
"type": "string",
|
| 78 |
+
"enum": [
|
| 79 |
+
"hold",
|
| 80 |
+
"ongoing",
|
| 81 |
+
"settle"
|
| 82 |
+
],
|
| 83 |
+
"description": "ongoing: The action is still underway as the clip ends. | settle: The movement eases to a rest and stays there. | hold: The final position holds steady through the last moments.",
|
| 84 |
+
"default": "ongoing"
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
"description": "Compiled in the order join, camera, framing, pace, tail. `join` is ignored on shot 1."
|
| 88 |
+
},
|
| 89 |
+
"prose": {
|
| 90 |
+
"type": "string",
|
| 91 |
+
"description": "Appended verbatim, for anything the vocabulary lacks. Phrase it affirmatively."
|
| 92 |
+
},
|
| 93 |
+
"seed": {
|
| 94 |
+
"type": "integer"
|
| 95 |
+
},
|
| 96 |
+
"steps": {
|
| 97 |
+
"type": "integer"
|
| 98 |
+
},
|
| 99 |
+
"duration": {
|
| 100 |
+
"type": "string",
|
| 101 |
+
"enum": [
|
| 102 |
+
"5 s",
|
| 103 |
+
"7 s",
|
| 104 |
+
"8 s",
|
| 105 |
+
"10 s",
|
| 106 |
+
"15 s"
|
| 107 |
+
]
|
| 108 |
+
},
|
| 109 |
+
"locked": {
|
| 110 |
+
"type": "boolean",
|
| 111 |
+
"description": "Reuse this hop's cached render even when its inputs changed. Needs cache_hops=on."
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
"description": "One shot per hop. The shot count IS the hop count."
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
"ref_plan": {
|
| 120 |
+
"type": "object",
|
| 121 |
+
"required": [
|
| 122 |
+
"refs"
|
| 123 |
+
],
|
| 124 |
+
"additionalProperties": false,
|
| 125 |
+
"properties": {
|
| 126 |
+
"refs": {
|
| 127 |
+
"type": "array",
|
| 128 |
+
"items": {
|
| 129 |
+
"type": "object",
|
| 130 |
+
"required": [
|
| 131 |
+
"tag"
|
| 132 |
+
],
|
| 133 |
+
"additionalProperties": false,
|
| 134 |
+
"properties": {
|
| 135 |
+
"tag": {
|
| 136 |
+
"type": "string",
|
| 137 |
+
"pattern": "^[A-Za-z0-9_]+$",
|
| 138 |
+
"description": "Unique. Written into beats as @tag."
|
| 139 |
+
},
|
| 140 |
+
"file": {
|
| 141 |
+
"type": "string",
|
| 142 |
+
"description": "Basename under ComfyUI/input/h3_refs. Never a path."
|
| 143 |
+
},
|
| 144 |
+
"slot": {
|
| 145 |
+
"type": "integer",
|
| 146 |
+
"description": "Derived from list position. Do not author it; present only for legacy plans."
|
| 147 |
+
},
|
| 148 |
+
"subject": {
|
| 149 |
+
"type": "integer",
|
| 150 |
+
"minimum": 1,
|
| 151 |
+
"description": "Groups pictures OF THE SAME PERSON. Two different people under one number makes the model render the average of their faces."
|
| 152 |
+
},
|
| 153 |
+
"retention": {
|
| 154 |
+
"type": "string",
|
| 155 |
+
"enum": [
|
| 156 |
+
"fully_preserved",
|
| 157 |
+
"partially_copy",
|
| 158 |
+
"reference"
|
| 159 |
+
],
|
| 160 |
+
"description": "fully_preserved: face, bone structure, and hairstyle carry over exactly | partially_copy: the garment and its cut carry over, moving naturally with the body | reference: the layout, surfaces, and light carry over as the setting"
|
| 161 |
+
},
|
| 162 |
+
"desc": {
|
| 163 |
+
"type": "string"
|
| 164 |
+
},
|
| 165 |
+
"shots": {
|
| 166 |
+
"type": "array",
|
| 167 |
+
"items": {
|
| 168 |
+
"type": "integer",
|
| 169 |
+
"minimum": 1
|
| 170 |
+
},
|
| 171 |
+
"minItems": 1,
|
| 172 |
+
"description": "1-based hops this picture rides on. OMITTING IT MEANS HOP 1 ONLY on a continuation chain -- a plate shot elsewhere riding a later hop beats the frame pin."
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
},
|
| 176 |
+
"maxItems": 9,
|
| 177 |
+
"description": "At most 9 pictures on any one hop."
|
| 178 |
+
},
|
| 179 |
+
"subjects": {
|
| 180 |
+
"type": "object",
|
| 181 |
+
"patternProperties": {
|
| 182 |
+
"^[0-9]+$": {
|
| 183 |
+
"type": "object",
|
| 184 |
+
"additionalProperties": false,
|
| 185 |
+
"properties": {
|
| 186 |
+
"name": {
|
| 187 |
+
"type": "string"
|
| 188 |
+
},
|
| 189 |
+
"locked": {
|
| 190 |
+
"type": "string",
|
| 191 |
+
"description": "What must not change. This is what survives on a hop where the photograph is absent."
|
| 192 |
+
},
|
| 193 |
+
"context": {
|
| 194 |
+
"type": "string",
|
| 195 |
+
"description": "Situational state that should persist -- wardrobe, what they are holding."
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
"additionalProperties": false,
|
| 201 |
+
"description": "Keyed by subject number, as a STRING. Every subject here must be claimed by at least one ref, or the plan is rejected."
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
},
|
| 206 |
+
"x-duration-frames": {
|
| 207 |
+
"5 s": 124,
|
| 208 |
+
"7 s": 175,
|
| 209 |
+
"8 s": 192,
|
| 210 |
+
"10 s": 243,
|
| 211 |
+
"15 s": 362
|
| 212 |
+
},
|
| 213 |
+
"x-rules": [
|
| 214 |
+
"cfg is 1.0 with no negative branch: the prompt is ADDITIVE. Anything named is added; nothing can be removed by mentioning it. Never write a negation.",
|
| 215 |
+
"Never name the thing you want to end. 'stops talking' keeps them talking. Write the state as a pose plus a sound.",
|
| 216 |
+
"H3 always generates audio. Silence must be written AS a sound -- room tone, a refrigerator, a single click.",
|
| 217 |
+
"Ambience must be narrowband. 'faint street noise' renders as hiss; 'the low hum of the refrigerator' renders as a refrigerator.",
|
| 218 |
+
"A state change belongs at the END of the previous shot. Every hop opens holding the frames it was handed.",
|
| 219 |
+
"Shot 1 establishes everything. Later shots carry ONLY the new beat -- never re-describe the face, the clothes or the room.",
|
| 220 |
+
"Write @tags into the action line. Describing a reference in the register does not make the model use it.",
|
| 221 |
+
"Set `tail` to settle or hold on the FINAL shot, or the model is told action is still underway at the last frame and invents something to satisfy it."
|
| 222 |
+
]
|
| 223 |
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are writing shot plans for **Hand Tie Clips**, a ComfyUI node that
|
| 2 |
+
renders a multi-hop video-with-audio chain. Each hop is one continuous clip;
|
| 3 |
+
consecutive hops are joined by pinning the previous hop's final frames and audio
|
| 4 |
+
tail into the next hop's conditioning.
|
| 5 |
+
|
| 6 |
+
You produce exactly two JSON documents: `shot_plan` and `ref_plan`.
|
| 7 |
+
|
| 8 |
+
## How the renderer behaves
|
| 9 |
+
|
| 10 |
+
These are not style preferences. They are how this model fails.
|
| 11 |
+
|
| 12 |
+
1. **Sampling runs at cfg 1.0 with no negative branch, so the prompt is purely
|
| 13 |
+
additive.** Every concept named is added to the conditioning. Nothing can be
|
| 14 |
+
removed by mentioning it — "no cut" puts the word *cut* in front of the
|
| 15 |
+
encoder. **Never write a negation.** Write what the shot *is* doing.
|
| 16 |
+
|
| 17 |
+
2. **Never name the thing you want to end.** "The cook stops talking" keeps her
|
| 18 |
+
talking. Write the state you want as **a pose plus a sound**: "leans back
|
| 19 |
+
against the counter with her lips closed, and lets her eyes move slowly
|
| 20 |
+
across the room. The kitchen is quiet apart from the low hum of the
|
| 21 |
+
refrigerator."
|
| 22 |
+
|
| 23 |
+
**The ban is on the idea, not on a list of words.** Two different models,
|
| 24 |
+
given a scene that ended *"the storm finally stops"*, avoided every banned
|
| 25 |
+
word below and still wrote *"The storm's roar begins to fade... raindrops
|
| 26 |
+
strike the glass with decreasing force"* and *"The storm has passed."* Both
|
| 27 |
+
name the ending. Both keep the storm. Fading, passing, waning, subsiding,
|
| 28 |
+
dying down, easing off, receding, growing quiet and dropping away are the
|
| 29 |
+
same move wearing different words, and every one of them adds the thing it
|
| 30 |
+
describes.
|
| 31 |
+
|
| 32 |
+
Test every sentence with one question: **is this a thing that is happening,
|
| 33 |
+
or a thing that has finished happening?** Only the first survives at cfg 1.0.
|
| 34 |
+
To end a storm, write the world that is left: *"Water runs down the glass in
|
| 35 |
+
slow threads. The mechanism turns with a low hum and the sea moves against
|
| 36 |
+
the rocks below."*
|
| 37 |
+
|
| 38 |
+
These words are rejected outright, in any beat: **no, not, never, n't,
|
| 39 |
+
without, none of, stop, stops, silent, silence.** They are the crudest
|
| 40 |
+
examples of the idea, not the whole of it -- a beat clean of all ten can
|
| 41 |
+
still break this rule, and usually does.
|
| 42 |
+
|
| 43 |
+
3. **Audio is always generated.** Silence written as an absence produces speech,
|
| 44 |
+
because the model fills the track with the most likely thing. Silence must be
|
| 45 |
+
written **as a sound** — room tone, a refrigerator, a distant car.
|
| 46 |
+
|
| 47 |
+
4. **Ambience must be narrowband and specific.** "faint street noise through the
|
| 48 |
+
window" is broadband and renders as a five-second hiss. "the low hum of the
|
| 49 |
+
refrigerator" renders as a refrigerator. When in doubt, name a single
|
| 50 |
+
discrete event: "a single click from the refrigerator, then stillness".
|
| 51 |
+
|
| 52 |
+
5. **A state change belongs at the END of the previous shot.** Every hop opens
|
| 53 |
+
holding the frames it was handed, and the audio pin carries the previous
|
| 54 |
+
hop's tail across the join. If shot 2 ends mid-sentence, nothing you write in
|
| 55 |
+
shot 3 will make shot 3 start quiet. To be silent, still, or elsewhere in a
|
| 56 |
+
shot, arrive there before the previous shot ends.
|
| 57 |
+
|
| 58 |
+
6. **A hop that ends on dialogue keeps talking into the next hop.** The audio
|
| 59 |
+
pin carries the previous hop's tail, so speech at the end of hop N becomes
|
| 60 |
+
the opening of hop N+1 and propagates down the whole chain. Land each spoken
|
| 61 |
+
line in the MIDDLE of its hop and leave a non-verbal action running into the
|
| 62 |
+
seam — slicing, walking, a hand on a doorframe. Give every hop with no
|
| 63 |
+
dialogue its own narrowband sound bed (footsteps, a refrigerator, rain on
|
| 64 |
+
glass), or the audio has nowhere to go but back to speech.
|
| 65 |
+
|
| 66 |
+
7. **A beat must be true from any plausible ending of the hop before it.** A
|
| 67 |
+
hop routinely over-delivers -- given a movement it finishes it, and then some.
|
| 68 |
+
If the next beat instructs something its own opening frames have already done,
|
| 69 |
+
the only way to obey is to reset the scene, which reads as a hard cut about a
|
| 70 |
+
second and a half *into* the hop rather than at the seam. Give one hop the
|
| 71 |
+
whole of one movement, and write the next beat so it holds whether the
|
| 72 |
+
previous hop stopped short or ran ahead.
|
| 73 |
+
|
| 74 |
+
8. **A walk between two different rooms is `match_cut`, not `continuous`.**
|
| 75 |
+
Asking for one unbroken take across a real location change makes the model
|
| 76 |
+
morph one room into the other mid-movement.
|
| 77 |
+
|
| 78 |
+
9. **Shot 1 establishes everything. Every later shot carries ONLY the new
|
| 79 |
+
beat.** Never re-describe the face, the clothes or the room after shot 1 —
|
| 80 |
+
the reference photographs, the register and the frame pin already carry all
|
| 81 |
+
three, and repeating them competes with the pin instead of reinforcing it.
|
| 82 |
+
|
| 83 |
+
10. **Write `@tags` into the action line.** Describing a reference in the register
|
| 84 |
+
does not make the model use it; the beat is what drives the frame. Write
|
| 85 |
+
"stands at the counter in @kitchen", not merely a `@kitchen` entry in the
|
| 86 |
+
register. Phrase a place as a place that is depicted, not as a container to
|
| 87 |
+
be placed inside. **A beat is plain prose.** A tag written inside a beat
|
| 88 |
+
carries no backticks, asterisks or other markdown -- every character you
|
| 89 |
+
write reaches the encoder literally, so formatting becomes noise in the
|
| 90 |
+
conditioning.
|
| 91 |
+
|
| 92 |
+
11. **Every object that persists gets an adjective.** A bare noun is
|
| 93 |
+
unanchored: each hop encodes "the bowl" from scratch and is free to make it
|
| 94 |
+
steel in one hop and porcelain in the next. Name a colour or a material the
|
| 95 |
+
first time and repeat it every time -- "the white porcelain bowl", "the dark
|
| 96 |
+
blue wrapped hilt". State it in the subject's `context` as a **property**,
|
| 97 |
+
never as a location.
|
| 98 |
+
|
| 99 |
+
12. **Name the visual style in shot 1, once.** Nothing else states it. If the
|
| 100 |
+
user asks for 2D anime, film noir, stop motion, watercolour or any look other
|
| 101 |
+
than live action, shot 1's beat opens with it: "Hand-drawn 2D anime in the
|
| 102 |
+
style of ..., crisp inked linework over painted backgrounds." Reference
|
| 103 |
+
pictures pull the look one way and unstated text pulls it back toward
|
| 104 |
+
photoreal video, and the text wins often enough to matter. Later shots do not
|
| 105 |
+
repeat it -- it belongs in `setting.locked` on the H3 Continuity State node,
|
| 106 |
+
which rides every hop from 2 on.
|
| 107 |
+
|
| 108 |
+
The node prepends an establishing line of its own to hop 1, and its default
|
| 109 |
+
asserts live action. When shot 1 opens by naming a medium, that default is
|
| 110 |
+
dropped automatically, so a styled plan needs nothing else from you. Name the
|
| 111 |
+
medium in the **first sentence** of shot 1 for that to happen.
|
| 112 |
+
|
| 113 |
+
13. **Set `tail` on the final shot** to `settle` or `hold`. Left at its default
|
| 114 |
+
`ongoing`, the model is told action is still underway at the last frame and
|
| 115 |
+
will invent something to satisfy that — a stray gesture, or a stray line of
|
| 116 |
+
dialogue in the closing second.
|
| 117 |
+
|
| 118 |
+
## `shot_plan`
|
| 119 |
+
|
| 120 |
+
One shot per hop. **The number of shots is the number of hops.**
|
| 121 |
+
|
| 122 |
+
```json
|
| 123 |
+
{
|
| 124 |
+
"shots": [
|
| 125 |
+
{
|
| 126 |
+
"id": "s1",
|
| 127 |
+
"beat": "...",
|
| 128 |
+
"directives": {"camera": "hold", "framing": "medium", "pace": "steady", "tail": "ongoing"}
|
| 129 |
+
}
|
| 130 |
+
]
|
| 131 |
+
}
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Valid shot fields, and no others: `id`, `beat`, `directives`, `prose`, `seed`,
|
| 135 |
+
`steps`, `duration`, `locked`. Only `beat` is required. An unknown field is a
|
| 136 |
+
hard error.
|
| 137 |
+
|
| 138 |
+
Directive axes, and no others. Every axis is optional; an unset axis emits
|
| 139 |
+
nothing at all, which costs no tokens.
|
| 140 |
+
|
| 141 |
+
| axis | values |
|
| 142 |
+
|---|---|
|
| 143 |
+
| `join` | `continuous`, `match_cut`, `hard_cut` — **omit on shot 1**, which has nothing to join to |
|
| 144 |
+
| `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
|
| 145 |
+
| `framing` | `keep`, `wide`, `medium`, `close` |
|
| 146 |
+
| `pace` | `slow`, `steady`, `brisk` |
|
| 147 |
+
| `tail` | `ongoing` (default), `settle`, `hold` |
|
| 148 |
+
|
| 149 |
+
Two combinations are contradictions and will be flagged:
|
| 150 |
+
|
| 151 |
+
- `join: continuous` + a framing change + `camera: hold`. With the camera still,
|
| 152 |
+
the only way to reach a new framing is a cut. Earn it on the move (`push_in`,
|
| 153 |
+
`pull_back`, `pan_follow`) or use `framing: keep`.
|
| 154 |
+
- `push_in` with `wide`, or `pull_back` with `close`. The move points the
|
| 155 |
+
opposite way from the destination.
|
| 156 |
+
|
| 157 |
+
`duration` may override the chain per shot, using exactly these labels:
|
| 158 |
+
`"5 s"`, `"7 s"`, `"8 s"`, `"10 s"`, `"15 s"`.
|
| 159 |
+
|
| 160 |
+
### How long a beat has to be
|
| 161 |
+
|
| 162 |
+
A beat has to fill its whole hop. Written short, the model finishes the action
|
| 163 |
+
early and invents something for the seconds left over -- most often a cut to the
|
| 164 |
+
reference photograph in the closing moments. **Ask the user how long each hop is
|
| 165 |
+
if they have not said**, and size every beat to it.
|
| 166 |
+
|
| 167 |
+
**Count the words in each beat before you answer, and write the count down.**
|
| 168 |
+
This is the instruction most often ignored, and ignoring it is not a small
|
| 169 |
+
miss. Two different models asked for six 15 s hops both returned beats averaging
|
| 170 |
+
**54 words** -- every single beat under the floor -- and the same two models,
|
| 171 |
+
asked for much shorter hops, returned 40 to 48. Left to itself a model writes
|
| 172 |
+
about fifty words whatever the hop length. Fifty words is right for a 7 s hop
|
| 173 |
+
and is half of what 15 s needs. The table below is not a style note; it is the
|
| 174 |
+
one part of this document you have to do arithmetic for.
|
| 175 |
+
|
| 176 |
+
| hop | words in the beat |
|
| 177 |
+
|---|---|
|
| 178 |
+
| `5 s` | 30-45 |
|
| 179 |
+
| `7 s` | 30-55 |
|
| 180 |
+
| `8 s` | 35-60 |
|
| 181 |
+
| `10 s` | 45-75 |
|
| 182 |
+
| `15 s` | 70-100 |
|
| 183 |
+
|
| 184 |
+
A worked `15 s` beat, at 74 words, from a rendered eight-hop chain:
|
| 185 |
+
|
| 186 |
+
> They close the distance together and the blades meet at the centre of the clearing, white sparks bursting from the impact as the camera orbits around the lock. @warrior_face turns the heavier blade aside and cuts back across the body; @enemy_shadow catches it on the flat and steps in behind it. Steel rings sharp and resonant on every contact, armour plates grind against one another, and boots drag hard over stone between the exchanges.
|
| 187 |
+
|
| 188 |
+
Read how the length is spent, because padding to a word count fails differently
|
| 189 |
+
but just as badly. One continuous movement carries the whole hop -- they close,
|
| 190 |
+
the blades meet, one turns the other aside, the other steps in. The camera move
|
| 191 |
+
is named inside the action rather than after it. The last third is the sound bed
|
| 192 |
+
and nothing else: three specific noises, each tied to a thing on screen. There
|
| 193 |
+
is no second event and no scene change.
|
| 194 |
+
|
| 195 |
+
The `5 s` and `7 s` rows are the measured spread of the two plans that ship with
|
| 196 |
+
the node. Their thinnest beat is 28 words in a 7 s hop, carrying one simple
|
| 197 |
+
action; their fullest is 56. The `15 s` row now has one measurement behind it --
|
| 198 |
+
the chain the example above comes from ran 74 to 90 words a beat across eight
|
| 199 |
+
hops and every hop delivered its beat in full, with no hop idling into the
|
| 200 |
+
reference. **The `8 s` and `10 s` rows are still interpolated, not measured.**
|
| 201 |
+
If the closing seconds drift or cut to the reference photograph, the beat was
|
| 202 |
+
short.
|
| 203 |
+
|
| 204 |
+
Fill that length with *continuous* material -- one movement that takes the whole
|
| 205 |
+
hop, what the camera is doing while it happens, and the sound bed underneath --
|
| 206 |
+
rather than with more separate events. Four events crammed into one hop is the
|
| 207 |
+
over-delivery in rule 7, and it costs you the next hop's opening.
|
| 208 |
+
|
| 209 |
+
### Dialogue
|
| 210 |
+
|
| 211 |
+
Put the spoken line inside the beat, in **single** quotes:
|
| 212 |
+
|
| 213 |
+
```
|
| 214 |
+
@hero_face looks up from the chopping board and says, 'You are early. I have
|
| 215 |
+
barely started.'
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
Single quotes survive copy-paste; escaped double quotes are the most common
|
| 219 |
+
cause of a rejected plan. Budget roughly one line of dialogue per 5–7 seconds of
|
| 220 |
+
hop — a long speech in a short hop is truncated mid-word, and that truncation is
|
| 221 |
+
then pinned into the next hop's audio.
|
| 222 |
+
|
| 223 |
+
## `ref_plan`
|
| 224 |
+
|
| 225 |
+
```json
|
| 226 |
+
{
|
| 227 |
+
"refs": [
|
| 228 |
+
{"tag": "hero_face", "file": "face.jpg", "subject": 1,
|
| 229 |
+
"retention": "fully_preserved", "shots": [1, 4],
|
| 230 |
+
"desc": "head-and-shoulders, even light"}
|
| 231 |
+
],
|
| 232 |
+
"subjects": {
|
| 233 |
+
"1": {"name": "the cook",
|
| 234 |
+
"locked": "the same face, the same short dark hair",
|
| 235 |
+
"context": "the apron stays tied over the grey t-shirt"}
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Valid ref fields: `tag`, `file`, `subject`, `retention`, `desc`, `shots`. (There
|
| 241 |
+
is also `slot`, which is derived from list position — never author it.)
|
| 242 |
+
|
| 243 |
+
- **`tag`** is required, unique, `[A-Za-z0-9_]+`, and is what appears in beats
|
| 244 |
+
as `@tag`.
|
| 245 |
+
- **`file`** is a bare filename in `ComfyUI/input/h3_refs`, never a path. A
|
| 246 |
+
filename that is not in that folder **stops the run** -- it is an error, not a
|
| 247 |
+
warning, so a placeholder you invent must be one the user then actually
|
| 248 |
+
supplies. If the user has not told you their filenames, use clear placeholders
|
| 249 |
+
and list every one of them at the end of your answer, with what each picture
|
| 250 |
+
should show.
|
| 251 |
+
- **`subject`** is an integer ≥ 1 grouping pictures **of the same person**.
|
| 252 |
+
Declaring two different people under one subject number makes the model render
|
| 253 |
+
**the average of their faces**. One number per person, always.
|
| 254 |
+
- **`retention`** is one of:
|
| 255 |
+
- `fully_preserved` — face, bone structure and hairstyle carry over exactly
|
| 256 |
+
- `partially_copy` — the garment and its cut carry over
|
| 257 |
+
- `reference` — layout, surfaces and light carry over, i.e. a place
|
| 258 |
+
|
| 259 |
+
It defaults to `fully_preserved` when `subject` is set, `reference` otherwise.
|
| 260 |
+
- **`shots`** is the list of 1-based hops the picture rides on.
|
| 261 |
+
|
| 262 |
+
Every subject that appears in `subjects` must be claimed by at least one ref, or
|
| 263 |
+
the plan is rejected. Give every subject a **`locked`**: pictures put a face in
|
| 264 |
+
front of the encoder, but `locked` is what carries the identity across a hop
|
| 265 |
+
where the picture is absent. At most **9 references in the whole plan** -- the limit counts entries in
|
| 266 |
+
`refs`, not pictures per hop.
|
| 267 |
+
|
| 268 |
+
### Scheduling references is the part that decides whether continuity holds
|
| 269 |
+
|
| 270 |
+
Write `@tag` for a person on any hop you like. Hop 1 resolves it to
|
| 271 |
+
`<Subject N>` against its definitions block; hop 2 onward resolves it to the
|
| 272 |
+
subject's `name`, which binds to the identity sentence every continuation hop
|
| 273 |
+
carries. **Every subject therefore needs a `name`.**
|
| 274 |
+
|
| 275 |
+
**A reference with no `shots` list rides hop 1 only.** That default is right
|
| 276 |
+
for a **place** plate: a room still riding a hop set somewhere else beats the
|
| 277 |
+
frame pin, because the model has a crisp picture of one room and a noisy
|
| 278 |
+
carried-over frame of another, and it follows the still. **List every hop a
|
| 279 |
+
picture belongs on, explicitly.**
|
| 280 |
+
|
| 281 |
+
**A face plate is the opposite. Put it on every hop.** Two six-hop renders
|
| 282 |
+
settled this. A face plate rode a hop set in a space it had never seen and held
|
| 283 |
+
cleanly; the hop scheduled with no references came back a different person, and
|
| 284 |
+
nothing after it recovered. Identity drift does not self-correct. `locked` holds
|
| 285 |
+
a face that is still right; only a plate rebuilds one that is gone.
|
| 286 |
+
|
| 287 |
+
- Put every **face** reference on every hop: `"shots": [1, 2, 3, 4, 5, 6]`.
|
| 288 |
+
- Keep a **place** reference on every hop set in that place, and off every hop
|
| 289 |
+
that is not.
|
| 290 |
+
- An **outfit** plate can ride hop 1 only, but then `context` is the only thing
|
| 291 |
+
holding the wardrobe from hop 2 on, so name its colours there.
|
| 292 |
+
- Never let a place plate shot in one location ride a hop set somewhere else.
|
| 293 |
+
- **The hop that changes location belongs entirely to the new place.** Put the
|
| 294 |
+
departure at the end of the *previous* beat, so that no place plate ever rides
|
| 295 |
+
a hop which opens somewhere it was not photographed.
|
| 296 |
+
- **A location introduced part way through needs its own plate, on the hop it
|
| 297 |
+
arrives and every hop after.** Tightening a place plate to its own shots does
|
| 298 |
+
**not** mean the next location goes without one. This is the most common
|
| 299 |
+
mistake made on this rule: two models both plated the opening location, moved
|
| 300 |
+
the story to a second one, gave that second one no plate at all, and wrote a
|
| 301 |
+
confident justification for it. In one case that left the film's main setting
|
| 302 |
+
-- four of six hops -- held by beat text alone. Ask for the extra picture. If
|
| 303 |
+
the plan ends somewhere no picture describes, the plan is wrong.
|
| 304 |
+
|
| 305 |
+
## What to produce
|
| 306 |
+
|
| 307 |
+
1. A one-paragraph plan in prose: how many hops, what each covers, where the
|
| 308 |
+
references sit and why.
|
| 309 |
+
2. ```json ``` block — the `shot_plan`.
|
| 310 |
+
3. ```json ``` block — the `ref_plan`.
|
| 311 |
+
4. A short list of any pictures the user still needs to supply, with the
|
| 312 |
+
filename you used for each and what the picture should show.
|
| 313 |
+
|
| 314 |
+
Before you answer, check every one of these:
|
| 315 |
+
|
| 316 |
+
- [ ] Shot count matches the hop count the user asked for.
|
| 317 |
+
- [ ] Every beat's word count has been counted, written down, and falls inside
|
| 318 |
+
the band for its hop length. A 15 s hop needs 70-100 words; fifty is
|
| 319 |
+
what comes out when this check is skipped.
|
| 320 |
+
- [ ] No shot contains a negation anywhere.
|
| 321 |
+
- [ ] No beat contains any of: no, not, never, n't, without, none of, stop,
|
| 322 |
+
stops, silent, silence.
|
| 323 |
+
- [ ] No beat describes a thing that has finished happening -- nothing fades,
|
| 324 |
+
passes, wanes, subsides, dies down, eases off, recedes or grows quiet.
|
| 325 |
+
Passing this check is not the same as passing the one above it.
|
| 326 |
+
- [ ] No beat contains backticks, asterisks or any other markdown. Beats are
|
| 327 |
+
plain prose; a tag inside a beat is written bare, as @tag.
|
| 328 |
+
- [ ] No shot names an action ending; endings are written as a pose plus a sound.
|
| 329 |
+
- [ ] Any quiet moment names a specific narrowband sound.
|
| 330 |
+
- [ ] No hop ends on a spoken line; every dialogue-free hop names a sound of its own.
|
| 331 |
+
- [ ] Any hop that changes location joins on `match_cut` or `hard_cut`, not `continuous`.
|
| 332 |
+
- [ ] Every state change lands at the end of the shot *before* the one that
|
| 333 |
+
needs it.
|
| 334 |
+
- [ ] Shot 1 establishes; no later shot re-describes face, clothes or room.
|
| 335 |
+
- [ ] Shot 1 names the visual style, if the user asked for anything other
|
| 336 |
+
than live action.
|
| 337 |
+
- [ ] Every persistent object and garment carries an adjective, in the beat
|
| 338 |
+
and again in `context`.
|
| 339 |
+
- [ ] Each beat holds true whether the hop before it stopped short or ran
|
| 340 |
+
ahead of its beat.
|
| 341 |
+
- [ ] Every `@tag` used in a beat exists in `ref_plan`, spelled identically.
|
| 342 |
+
- [ ] Every `@tag` used on hop N has that N in its ref's `shots` list.
|
| 343 |
+
- [ ] Every location the film visits has a place plate covering the hops set
|
| 344 |
+
there, including any location introduced after hop 1. The final hop is
|
| 345 |
+
covered by one.
|
| 346 |
+
- [ ] `join` is absent from shot 1.
|
| 347 |
+
- [ ] The final shot sets `tail` to `settle` or `hold`.
|
| 348 |
+
- [ ] Compare each shot's `framing` with the previous shot's: where it
|
| 349 |
+
changes on `join: continuous`, the camera is moving, not `hold`. And no
|
| 350 |
+
`push_in`+`wide`, no `pull_back`+`close`.
|
| 351 |
+
- [ ] Every subject in `subjects` is claimed by a ref, and has both a `name`
|
| 352 |
+
and a `locked`.
|
| 353 |
+
- [ ] `refs` holds at most 9 entries in total.
|
| 354 |
+
- [ ] Dialogue uses single quotes.
|
| 355 |
+
- [ ] Both blocks are valid JSON: no trailing commas, no comments, no smart
|
| 356 |
+
quotes, plain ASCII, and no field outside the lists above.
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "comfyui-hand-tie-clips"
|
| 3 |
+
version = "0.3.3"
|
| 4 |
+
description = "Native MiniMax H3 Ref2VA chain: persistent references, previous-segment clip+audio pin, one queue."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
license = { file = "LICENSE" }
|
| 7 |
+
requires-python = ">=3.10"
|
| 8 |
+
dependencies = []
|
| 9 |
+
|
| 10 |
+
[project.urls]
|
| 11 |
+
Repository = "https://github.com/dntpi/ComfyUI-Hand-Tie-Clips"
|
| 12 |
+
|
| 13 |
+
# `comfy node publish` needs PublisherId as well as the Repository above.
|
| 14 |
+
# PublisherId comes from a registry.comfy.org account and is the last field
|
| 15 |
+
# outstanding. Neither is needed to install the pack by hand or from a zip.
|
| 16 |
+
[tool.comfy]
|
| 17 |
+
PublisherId = ""
|
| 18 |
+
DisplayName = "Hand Tie Clips"
|
| 19 |
+
Icon = ""
|
|
@@ -0,0 +1,501 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reference register: stable @tags, subject numbers, retention tiers.
|
| 2 |
+
|
| 3 |
+
Two bugs this exists to fix.
|
| 4 |
+
|
| 5 |
+
**Ordinal instability.** Core assigns `<Picture N>` 1-based in list order
|
| 6 |
+
(`comfy/text_encoders/minimax.py:148-202`), and `_collect_ref_images` packs the
|
| 7 |
+
wired slots densely. Wire slots 1/3/5 and they become `<Picture 1/2/3>`; unplug
|
| 8 |
+
one and every later reference silently renumbers. Any prompt text naming a
|
| 9 |
+
literal ordinal is then pointing at the wrong picture. Here a ref carries a
|
| 10 |
+
stable `@tag` and the ordinal is *derived per hop*, so prompt prose refers to
|
| 11 |
+
tags and the compiler resolves them.
|
| 12 |
+
|
| 13 |
+
**Subject collapse.** H3-Multishot found that declaring every picture as a photo
|
| 14 |
+
of `<Subject 1>` makes the model render the average of two different people.
|
| 15 |
+
Subject numbers group pictures per person and state distinctness explicitly.
|
| 16 |
+
|
| 17 |
+
Phrasing here follows the same rule as `directives.py`: AFFIRMATIVE ONLY.
|
| 18 |
+
Sampling runs at cfg 1.0 with no negative branch, so every concept named is
|
| 19 |
+
additive. State what each picture IS for.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from os.path import basename as _basename
|
| 23 |
+
|
| 24 |
+
TAG = "HandTieClips"
|
| 25 |
+
|
| 26 |
+
MAX_REF_IMAGES = 9
|
| 27 |
+
|
| 28 |
+
# What the model should do with a picture. Wording is deliberately about the
|
| 29 |
+
# *carry-over*, not about the picture, because the model is being told how much
|
| 30 |
+
# of it to reproduce.
|
| 31 |
+
RETENTION = {
|
| 32 |
+
"fully_preserved": "face, bone structure, and hairstyle carry over exactly",
|
| 33 |
+
"partially_copy": "the garment and its cut carry over, moving naturally with the body",
|
| 34 |
+
"reference": "the layout, surfaces, and light carry over as the setting",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# `file` is the reference: a basename under <ComfyUI input>/h3_refs. `slot` is
|
| 38 |
+
# still accepted, and still *emitted*, but it means something different now --
|
| 39 |
+
# it is derived from the ref's position in the list rather than naming a socket,
|
| 40 |
+
# and it exists only so the ordinal machinery below did not have to change.
|
| 41 |
+
# A plan from before the sockets were removed carries an authored `slot` and no
|
| 42 |
+
# `file`; that is detected, kept as `legacy_slot`, and reported by check().
|
| 43 |
+
REF_FIELDS = ("tag", "file", "slot", "subject", "retention", "desc", "shots")
|
| 44 |
+
|
| 45 |
+
# Per-subject continuity text -- the half `HTCContinuityState` owned, moved here
|
| 46 |
+
# so it is keyed by the same subject number that owns the picture ordinals.
|
| 47 |
+
#
|
| 48 |
+
# The old node could not express a second character at all: `continuity_state`
|
| 49 |
+
# is a single forceInput STRING and the node emits exactly one `characters` key
|
| 50 |
+
# from one `character_id` widget, so two people were unrepresentable (its own
|
| 51 |
+
# docstring says so). Worse, its prose named people by an arbitrary string while
|
| 52 |
+
# `<Picture N>` was assigned positionally by slot order, with nothing joining
|
| 53 |
+
# the two -- so with two people at two refs each, `_identity_lock` emitted one
|
| 54 |
+
# undifferentiated pool of four "identities" and the model was free to average
|
| 55 |
+
# the faces. Keying continuity text by subject number closes that join.
|
| 56 |
+
SUBJECT_FIELDS = ("name", "locked", "context")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _fail(msg):
|
| 60 |
+
raise ValueError(f"{TAG}: {msg}")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _norm_subject(key, raw):
|
| 64 |
+
"""Validate one subject entry. Errors name the subject and valid fields."""
|
| 65 |
+
try:
|
| 66 |
+
num = int(key)
|
| 67 |
+
except (TypeError, ValueError):
|
| 68 |
+
_fail(f"subject key {key!r} is not a number. Use the same subject numbers "
|
| 69 |
+
f"the refs use, e.g. \"1\".")
|
| 70 |
+
if num < 1:
|
| 71 |
+
_fail(f"subject {key}: subject numbers start at 1")
|
| 72 |
+
if not isinstance(raw, dict):
|
| 73 |
+
_fail(f"subject {num}: expected an object, got {type(raw).__name__}")
|
| 74 |
+
unknown = sorted(set(raw) - set(SUBJECT_FIELDS))
|
| 75 |
+
if unknown:
|
| 76 |
+
_fail(f"subject {num}: unknown field(s) {unknown}. "
|
| 77 |
+
f"Valid: {list(SUBJECT_FIELDS)}")
|
| 78 |
+
return num, {
|
| 79 |
+
"name": str(raw.get("name") or "").strip(),
|
| 80 |
+
"locked": str(raw.get("locked") or "").strip(),
|
| 81 |
+
"context": str(raw.get("context") or "").strip(),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _norm_ref(raw, i):
|
| 86 |
+
"""Validate one ref entry. Errors name the ref and the valid options."""
|
| 87 |
+
where = f"ref {i + 1}"
|
| 88 |
+
if not isinstance(raw, dict):
|
| 89 |
+
_fail(f"{where}: expected an object, got {type(raw).__name__}")
|
| 90 |
+
|
| 91 |
+
unknown = sorted(set(raw) - set(REF_FIELDS))
|
| 92 |
+
if unknown:
|
| 93 |
+
_fail(f"{where}: unknown field(s) {unknown}. Valid: {list(REF_FIELDS)}")
|
| 94 |
+
|
| 95 |
+
tag = str(raw.get("tag") or "").strip().lstrip("@")
|
| 96 |
+
if not tag:
|
| 97 |
+
_fail(f"{where}: 'tag' is required (a short stable name like 'hero_face')")
|
| 98 |
+
|
| 99 |
+
# A bare filename, never a path: media.resolve refuses anything with a
|
| 100 |
+
# directory component, so accepting one here would only produce a ref that
|
| 101 |
+
# silently never loads.
|
| 102 |
+
file = _basename(str(raw.get("file") or "").strip())
|
| 103 |
+
|
| 104 |
+
# Legacy: a plan authored against the ref_image_N sockets. Not an error --
|
| 105 |
+
# failing here would stop the editor opening the very plan the author needs
|
| 106 |
+
# to repair, so it is carried through and reported by check().
|
| 107 |
+
legacy_slot = None
|
| 108 |
+
if not file and raw.get("slot") is not None:
|
| 109 |
+
try:
|
| 110 |
+
legacy_slot = int(raw["slot"])
|
| 111 |
+
except (TypeError, ValueError):
|
| 112 |
+
legacy_slot = None
|
| 113 |
+
|
| 114 |
+
subject = raw.get("subject")
|
| 115 |
+
if subject is not None:
|
| 116 |
+
try:
|
| 117 |
+
subject = int(subject)
|
| 118 |
+
except (TypeError, ValueError):
|
| 119 |
+
_fail(f"{where} (@{tag}): subject {subject!r} is not a number")
|
| 120 |
+
if subject < 1:
|
| 121 |
+
_fail(f"{where} (@{tag}): subject must be 1 or greater")
|
| 122 |
+
|
| 123 |
+
retention = str(raw.get("retention") or "").strip()
|
| 124 |
+
if not retention:
|
| 125 |
+
# A ref tied to a person defaults to identity; anything else is setting.
|
| 126 |
+
retention = "fully_preserved" if subject else "reference"
|
| 127 |
+
if retention not in RETENTION:
|
| 128 |
+
_fail(f"{where} (@{tag}): retention '{retention}' is not valid. "
|
| 129 |
+
f"Use one of: {', '.join(sorted(RETENTION))}")
|
| 130 |
+
|
| 131 |
+
shots = raw.get("shots")
|
| 132 |
+
if shots is not None:
|
| 133 |
+
if not isinstance(shots, (list, tuple)):
|
| 134 |
+
_fail(f"{where} (@{tag}): 'shots' must be a list of 1-based shot "
|
| 135 |
+
f"numbers, e.g. [1, 2]")
|
| 136 |
+
clean = []
|
| 137 |
+
for s in shots:
|
| 138 |
+
try:
|
| 139 |
+
clean.append(int(s))
|
| 140 |
+
except (TypeError, ValueError):
|
| 141 |
+
_fail(f"{where} (@{tag}): shot number {s!r} is not a number")
|
| 142 |
+
if not clean:
|
| 143 |
+
_fail(f"{where} (@{tag}): 'shots' is empty. Omit it for every shot.")
|
| 144 |
+
shots = sorted(set(clean))
|
| 145 |
+
|
| 146 |
+
return {
|
| 147 |
+
"tag": tag,
|
| 148 |
+
"file": file,
|
| 149 |
+
"legacy_slot": legacy_slot,
|
| 150 |
+
# Filled in by parse_ref_plan from list position; see REF_FIELDS.
|
| 151 |
+
"slot": 0,
|
| 152 |
+
"subject": subject,
|
| 153 |
+
"retention": retention,
|
| 154 |
+
"desc": str(raw.get("desc") or "").strip(),
|
| 155 |
+
"shots": shots,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def parse_ref_plan(text):
|
| 160 |
+
"""Parse the ref_plan widget.
|
| 161 |
+
|
| 162 |
+
Returns {"refs": [...], "subjects": {n: {...}}}. Blank -> empty plan, and
|
| 163 |
+
the caller falls back to raw slot order.
|
| 164 |
+
"""
|
| 165 |
+
import json
|
| 166 |
+
|
| 167 |
+
t = (text or "").strip()
|
| 168 |
+
if not t:
|
| 169 |
+
return {"refs": [], "subjects": {}}
|
| 170 |
+
try:
|
| 171 |
+
data = json.loads(t)
|
| 172 |
+
except json.JSONDecodeError as e:
|
| 173 |
+
_fail(f"ref_plan is not valid JSON: {e}")
|
| 174 |
+
|
| 175 |
+
raw_subjects = {}
|
| 176 |
+
if isinstance(data, list):
|
| 177 |
+
raw_refs = data
|
| 178 |
+
elif isinstance(data, dict):
|
| 179 |
+
raw_refs = data.get("refs")
|
| 180 |
+
if raw_refs is None:
|
| 181 |
+
_fail("ref_plan object needs a 'refs' array")
|
| 182 |
+
raw_subjects = data.get("subjects") or {}
|
| 183 |
+
if not isinstance(raw_subjects, dict):
|
| 184 |
+
_fail("ref_plan 'subjects' must be an object keyed by subject number, "
|
| 185 |
+
"e.g. {\"1\": {\"name\": \"...\", \"locked\": \"...\"}}")
|
| 186 |
+
else:
|
| 187 |
+
_fail(f"ref_plan must be an object or array, got {type(data).__name__}")
|
| 188 |
+
|
| 189 |
+
if not isinstance(raw_refs, (list, tuple)):
|
| 190 |
+
_fail("ref_plan 'refs' must be an array")
|
| 191 |
+
|
| 192 |
+
refs = [_norm_ref(r, i) for i, r in enumerate(raw_refs)]
|
| 193 |
+
if len(refs) > MAX_REF_IMAGES:
|
| 194 |
+
_fail(f"ref_plan has {len(refs)} references; the encoder takes at most "
|
| 195 |
+
f"{MAX_REF_IMAGES} in one plan.")
|
| 196 |
+
# The ordinal is derived from list position, which is what the author sees
|
| 197 |
+
# in the rail. This keeps `active_refs`/`ordinals` unchanged while the
|
| 198 |
+
# authored identity moves from a socket number to a filename.
|
| 199 |
+
for i, r in enumerate(refs):
|
| 200 |
+
r["slot"] = i + 1
|
| 201 |
+
subjects = dict(_norm_subject(k, v) for k, v in raw_subjects.items())
|
| 202 |
+
|
| 203 |
+
# A subject block that no ref points at is continuity text for a person with
|
| 204 |
+
# no face wired -- it would ride every hop describing someone who never
|
| 205 |
+
# appears, which at cfg 1.0 is purely additive.
|
| 206 |
+
used = {r["subject"] for r in refs if r["subject"] is not None}
|
| 207 |
+
for num in sorted(set(subjects) - used):
|
| 208 |
+
_fail(f"subject {num} has continuity text but no ref claims subject "
|
| 209 |
+
f"{num}. Give one of the refs \"subject\": {num}, or remove the "
|
| 210 |
+
f"subject block.")
|
| 211 |
+
|
| 212 |
+
seen_tags = {}
|
| 213 |
+
for i, r in enumerate(refs):
|
| 214 |
+
if r["tag"] in seen_tags:
|
| 215 |
+
_fail(f"ref {i + 1}: tag '@{r['tag']}' is already used by ref "
|
| 216 |
+
f"{seen_tags[r['tag']] + 1}. Tags must be unique.")
|
| 217 |
+
seen_tags[r["tag"]] = i
|
| 218 |
+
return {"refs": refs, "subjects": subjects}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def active_refs(refs, hop_index, wired_slots):
|
| 222 |
+
"""Refs live on this hop, in rail order, restricted to those whose file loaded.
|
| 223 |
+
|
| 224 |
+
`hop_index` is 0-based; `shots` in the plan is 1-based because that is what
|
| 225 |
+
the shot plan shows the author.
|
| 226 |
+
"""
|
| 227 |
+
shot_no = hop_index + 1
|
| 228 |
+
out = []
|
| 229 |
+
for r in refs:
|
| 230 |
+
if r["slot"] not in wired_slots:
|
| 231 |
+
continue
|
| 232 |
+
if r["shots"] is not None and shot_no not in r["shots"]:
|
| 233 |
+
continue
|
| 234 |
+
out.append(r)
|
| 235 |
+
return sorted(out, key=lambda r: r["slot"])
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def ordinals(active):
|
| 239 |
+
"""tag -> `<Picture N>` ordinal for this hop.
|
| 240 |
+
|
| 241 |
+
This is the whole point: the ordinal is derived from what is active on this
|
| 242 |
+
hop, so prose written against @tags stays correct when refs are added,
|
| 243 |
+
removed, or scheduled off.
|
| 244 |
+
"""
|
| 245 |
+
return {r["tag"]: i + 1 for i, r in enumerate(active)}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def resolve_tags(text, tag_map, subject_map=None, where="", declared=None,
|
| 249 |
+
subject_names=None):
|
| 250 |
+
"""Replace `@tag` in prose with what this hop calls that reference.
|
| 251 |
+
|
| 252 |
+
A ref tied to a person resolves to `<Subject N>`, not `<Picture N>`: beat
|
| 253 |
+
prose describes someone *acting*, and a picture cannot act. The
|
| 254 |
+
`subject_definitions:` block binds the subject to its pictures, so the
|
| 255 |
+
identity still lands. Setting and prop refs have no subject and resolve to
|
| 256 |
+
their picture ordinal.
|
| 257 |
+
|
| 258 |
+
`subject_map` may include people whose photograph is off this hop
|
| 259 |
+
(continuation: pin-only). Those tags still resolve to `<Subject N>`.
|
| 260 |
+
|
| 261 |
+
An unknown tag is a hard error -- silently leaving `@hero_face` in the
|
| 262 |
+
prompt would put the literal word in front of the encoder, which at cfg 1.0
|
| 263 |
+
is additive noise.
|
| 264 |
+
|
| 265 |
+
`subject_names` maps subject number -> the name to use in prose, and is
|
| 266 |
+
passed on **continuation hops only**. There, `<Subject N>` is a dangling
|
| 267 |
+
token: `subject_definitions:` is hop-1 material, so on hop 4 the ordinal has
|
| 268 |
+
nothing in its own encode to bind to -- the same defect that turned an
|
| 269 |
+
undescribed "the bowl" into a stainless steel one. A name binds to the
|
| 270 |
+
identity sentence `continuity_line` puts on every continuation hop, so the
|
| 271 |
+
prose reads "The cook walks down the hallway" and every word is anchored.
|
| 272 |
+
Without a name for that subject it falls back to `<Subject N>`, which is no
|
| 273 |
+
worse than before.
|
| 274 |
+
|
| 275 |
+
`declared` is every tag in the register, active on this hop or not. A tag
|
| 276 |
+
that is in it has been spelled correctly and the fault lies elsewhere --
|
| 277 |
+
a missing picture, or a `shots` list that leaves this hop out. Reporting
|
| 278 |
+
that as "unknown reference" sent authors hunting through their beats for a
|
| 279 |
+
typo that was never there.
|
| 280 |
+
"""
|
| 281 |
+
import re
|
| 282 |
+
|
| 283 |
+
t = str(text or "")
|
| 284 |
+
if "@" not in t:
|
| 285 |
+
return t
|
| 286 |
+
subject_map = subject_map or {}
|
| 287 |
+
declared = declared or set()
|
| 288 |
+
|
| 289 |
+
def sub(m):
|
| 290 |
+
tag = m.group(1)
|
| 291 |
+
# A person tag stays <Subject N> even when that photograph is off this
|
| 292 |
+
# hop (continuation: the pin carries wardrobe and room, the still does
|
| 293 |
+
# not). Requiring the tag in tag_map first made @hero_face a hard error
|
| 294 |
+
# the moment hop 2 dropped the face plate.
|
| 295 |
+
if tag in subject_map:
|
| 296 |
+
num = subject_map[tag]
|
| 297 |
+
name = str((subject_names or {}).get(num) or "").strip()
|
| 298 |
+
if name:
|
| 299 |
+
# Sentence-initial gets a capital: "@hero_face walks" became
|
| 300 |
+
# "the cook walks" mid-paragraph, which reads as a fragment.
|
| 301 |
+
head = t[:m.start()].rstrip()
|
| 302 |
+
if not head or head[-1] in '.!?:;':
|
| 303 |
+
return name[0].upper() + name[1:]
|
| 304 |
+
return name
|
| 305 |
+
return f"<Subject {num}>"
|
| 306 |
+
if tag in tag_map:
|
| 307 |
+
return f"<Picture {tag_map[tag]}>"
|
| 308 |
+
w = where + ": " if where else ""
|
| 309 |
+
if tag in declared:
|
| 310 |
+
_fail(f"{w}@{tag} is in the reference register but has no picture "
|
| 311 |
+
f"on this hop. Either its file is not in the reference "
|
| 312 |
+
f"folder, or its `shots` list leaves this hop out.")
|
| 313 |
+
known = ", ".join("@" + k for k in sorted(set(tag_map) | set(subject_map))) or "(none)"
|
| 314 |
+
_fail(f"{w}unknown reference '@{tag}'. Known: {known}")
|
| 315 |
+
|
| 316 |
+
return re.sub(r"@([A-Za-z0-9_]+)", sub, t)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def subjects(active):
|
| 320 |
+
"""tag -> subject number, for refs that name a person."""
|
| 321 |
+
return {r["tag"]: r["subject"] for r in active if r["subject"] is not None}
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def subject_prose(active, subjects=None):
|
| 325 |
+
"""Build `subject_definitions:` and `retention_analysis:` for this hop.
|
| 326 |
+
|
| 327 |
+
Pictures are grouped by subject number so two people stay two people, and
|
| 328 |
+
each subject's continuity text is emitted *inside its own block*, directly
|
| 329 |
+
after the ordinals that subject owns. That adjacency is the whole fix: the
|
| 330 |
+
old split put identity prose in one place (keyed by an arbitrary string) and
|
| 331 |
+
picture ordinals in another (assigned positionally), so the encoder was
|
| 332 |
+
never told which face belonged to which description.
|
| 333 |
+
|
| 334 |
+
A ref with no subject is a setting or prop and appears only in retention.
|
| 335 |
+
"""
|
| 336 |
+
if not active:
|
| 337 |
+
return ""
|
| 338 |
+
|
| 339 |
+
subjects = subjects or {}
|
| 340 |
+
ords = ordinals(active)
|
| 341 |
+
by_subject = {}
|
| 342 |
+
for r in active:
|
| 343 |
+
if r["subject"] is not None:
|
| 344 |
+
by_subject.setdefault(r["subject"], []).append(r)
|
| 345 |
+
|
| 346 |
+
parts = []
|
| 347 |
+
if by_subject:
|
| 348 |
+
lines = []
|
| 349 |
+
for subj in sorted(by_subject):
|
| 350 |
+
pics = ", ".join(f"<Picture {ords[r['tag']]}>" for r in by_subject[subj])
|
| 351 |
+
info = subjects.get(subj) or {}
|
| 352 |
+
name = info.get("name")
|
| 353 |
+
who = f"{name}, the person in {pics}" if name else f"the person in {pics}"
|
| 354 |
+
lines.append(f"<Subject {subj}> is {who}.")
|
| 355 |
+
for extra in (info.get("locked"), info.get("context")):
|
| 356 |
+
if extra:
|
| 357 |
+
lines.append(f"<Subject {subj}>: {extra}")
|
| 358 |
+
if len(by_subject) > 1:
|
| 359 |
+
names = ", ".join(f"<Subject {s}>" for s in sorted(by_subject))
|
| 360 |
+
lines.append(
|
| 361 |
+
f"{names} are separate people. Each one keeps the face from its "
|
| 362 |
+
f"own pictures throughout."
|
| 363 |
+
)
|
| 364 |
+
parts.append("subject_definitions:\n" + "\n".join(lines))
|
| 365 |
+
|
| 366 |
+
ret = []
|
| 367 |
+
for r in active:
|
| 368 |
+
pic = f"<Picture {ords[r['tag']]}>"
|
| 369 |
+
detail = RETENTION[r["retention"]]
|
| 370 |
+
desc = f" ({r['desc']})" if r["desc"] else ""
|
| 371 |
+
ret.append(f"{pic}{desc}: {detail}.")
|
| 372 |
+
parts.append("retention_analysis:\n" + "\n".join(ret))
|
| 373 |
+
|
| 374 |
+
return "\n\n".join(parts)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def continuity_line(subjects, subject_nums=None):
|
| 378 |
+
"""Identity prose for a hop that cites no photographs.
|
| 379 |
+
|
| 380 |
+
`subject_prose` binds continuity text to picture ordinals, which makes it
|
| 381 |
+
hop-1 material: repeating a `<Subject N> is the person in <Picture M>`
|
| 382 |
+
block on a later hop makes that hop a second Ref2VA generate. But the text
|
| 383 |
+
itself -- `locked` and `context` -- is not about a photograph. It is what
|
| 384 |
+
must not change, and a hop with no reference scheduled is exactly where the
|
| 385 |
+
encoder has nothing else to go on.
|
| 386 |
+
|
| 387 |
+
So this emits the same text with **no ordinals of any kind**. No
|
| 388 |
+
`<Picture N>` (which sends the encoder back to the plates -- chain_00034)
|
| 389 |
+
and no `<Subject N>` (which would dangle, with no definitions block on this
|
| 390 |
+
hop to bind it). One person is "The same person"; several are named, and
|
| 391 |
+
fall back to "one person" only when a subject has no name.
|
| 392 |
+
|
| 393 |
+
Returns "" when nothing is worth saying, so callers can `if p` it away.
|
| 394 |
+
"""
|
| 395 |
+
subjects = subjects or {}
|
| 396 |
+
nums = sorted(subject_nums if subject_nums is not None else subjects)
|
| 397 |
+
nums = [n for n in nums if (subjects.get(n) or {})]
|
| 398 |
+
if not nums:
|
| 399 |
+
return ""
|
| 400 |
+
|
| 401 |
+
solo = len(nums) == 1
|
| 402 |
+
lines = []
|
| 403 |
+
for num in nums:
|
| 404 |
+
info = subjects.get(num) or {}
|
| 405 |
+
bits = [t.strip() for t in (info.get("locked"), info.get("context"))
|
| 406 |
+
if t and t.strip()]
|
| 407 |
+
if not bits:
|
| 408 |
+
continue
|
| 409 |
+
# "The same person" reads as continuation; a bare name reads as an
|
| 410 |
+
# introduction, which on a continuation hop is a new scene. With more
|
| 411 |
+
# than one subject there is no way round naming them, and a `name`
|
| 412 |
+
# carries its own article in practice ("the cook") -- so it is used as
|
| 413 |
+
# written rather than glued behind "The same", which produced "The same
|
| 414 |
+
# the cook continues".
|
| 415 |
+
name = (info.get("name") or "").strip()
|
| 416 |
+
if solo or not name:
|
| 417 |
+
who = "The same person"
|
| 418 |
+
else:
|
| 419 |
+
who = name[0].upper() + name[1:]
|
| 420 |
+
body = "; ".join(b.rstrip(".") for b in bits)
|
| 421 |
+
lines.append(f"{who} continues, with {body}.")
|
| 422 |
+
return " ".join(lines)
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def describe(plan):
|
| 426 |
+
"""Console-auditable summary, printed once at chain start."""
|
| 427 |
+
refs = plan.get("refs") or []
|
| 428 |
+
subjects = plan.get("subjects") or {}
|
| 429 |
+
if not refs:
|
| 430 |
+
return "(no ref plan)"
|
| 431 |
+
out = []
|
| 432 |
+
for num in sorted(subjects):
|
| 433 |
+
info = subjects[num]
|
| 434 |
+
label = info.get("name") or "(unnamed)"
|
| 435 |
+
out.append(f" <Subject {num}> {label}")
|
| 436 |
+
for r in refs:
|
| 437 |
+
subj = f"subject {r['subject']}" if r["subject"] else "setting"
|
| 438 |
+
when = "all shots" if r["shots"] is None else "shots " + ",".join(
|
| 439 |
+
str(s) for s in r["shots"])
|
| 440 |
+
src = r["file"] or (f"(was slot {r['legacy_slot']}, no file)"
|
| 441 |
+
if r["legacy_slot"] else "(no file)")
|
| 442 |
+
out.append(f" @{r['tag']:<16} {src:<28} {subj:<10} "
|
| 443 |
+
f"{r['retention']:<16} {when}")
|
| 444 |
+
return "\n".join(out)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def missing_files(plan, wired_slots):
|
| 448 |
+
"""Refs that name a picture which did not load. Fatal, unlike `check`.
|
| 449 |
+
|
| 450 |
+
A ref with no `file` at all is a legitimate half-built plan -- the Starter
|
| 451 |
+
ships that way on purpose, so it runs before you have supplied anything. A
|
| 452 |
+
ref that *names* `ref_face.jpg` when no such file is in `h3_refs` is a typo
|
| 453 |
+
or a missing asset, and there is no reading of it under which rendering six
|
| 454 |
+
hops without that picture is what the author wanted: it produces exactly the
|
| 455 |
+
uncontrolled output the register exists to prevent, and the only signal used
|
| 456 |
+
to be one console line under the sampler output.
|
| 457 |
+
|
| 458 |
+
Returns a list of `(tag, file)`. The caller raises; this stays free of disk
|
| 459 |
+
access, like `check`.
|
| 460 |
+
"""
|
| 461 |
+
out = []
|
| 462 |
+
for r in plan.get("refs") or []:
|
| 463 |
+
if r["slot"] in wired_slots or not r["file"]:
|
| 464 |
+
continue
|
| 465 |
+
out.append((r["tag"], r["file"]))
|
| 466 |
+
return out
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def check(plan, wired_slots):
|
| 470 |
+
"""Warn (never raise) about refs whose picture is missing or never chosen.
|
| 471 |
+
|
| 472 |
+
`wired_slots` is the set of derived slots whose file actually loaded, so
|
| 473 |
+
the caller does the resolving and this stays free of disk access.
|
| 474 |
+
"""
|
| 475 |
+
refs = plan.get("refs") or []
|
| 476 |
+
subjects = plan.get("subjects") or {}
|
| 477 |
+
warnings = []
|
| 478 |
+
# A person with pictures but no continuity text still renders, but nothing
|
| 479 |
+
# carries their state across the seam -- which is the failure this register
|
| 480 |
+
# exists to fix, so it is worth naming.
|
| 481 |
+
for num in sorted({r["subject"] for r in refs if r["subject"] is not None}):
|
| 482 |
+
if num not in subjects:
|
| 483 |
+
warnings.append(
|
| 484 |
+
f"subject {num} has pictures but no continuity text. Add "
|
| 485 |
+
f"subjects.{num}.locked to carry their identity across seams.")
|
| 486 |
+
for r in refs:
|
| 487 |
+
if r["slot"] in wired_slots:
|
| 488 |
+
continue
|
| 489 |
+
if r["legacy_slot"]:
|
| 490 |
+
warnings.append(
|
| 491 |
+
f"@{r['tag']} was wired to ref_image_{r['legacy_slot']}, which no "
|
| 492 |
+
f"longer exists. Pick its picture in the REFERENCES rail -- this "
|
| 493 |
+
f"ref is inactive until you do.")
|
| 494 |
+
elif not r["file"]:
|
| 495 |
+
warnings.append(
|
| 496 |
+
f"@{r['tag']} has no picture chosen. This ref is inactive.")
|
| 497 |
+
else:
|
| 498 |
+
warnings.append(
|
| 499 |
+
f"@{r['tag']} names '{r['file']}', which is not in the reference "
|
| 500 |
+
f"folder or could not be read. This ref is inactive.")
|
| 501 |
+
return warnings
|
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One read-only endpoint: the vocabulary the editor draws its dropdowns from.
|
| 2 |
+
|
| 3 |
+
The alternative is a copy of `VOCAB` in JavaScript, and a copy is a second source
|
| 4 |
+
of truth that goes stale the first time a phrase is improved. `directives.py`
|
| 5 |
+
exists precisely so that improving a sentence improves every plan ever written;
|
| 6 |
+
duplicating those strings in the UI would undo that.
|
| 7 |
+
|
| 8 |
+
It also ships the *prose* each option compiles to, not just the option names, so
|
| 9 |
+
the editor can show what a directive actually puts in front of the encoder. That
|
| 10 |
+
is the whole answer to "I don't know how any of this works at a glance".
|
| 11 |
+
|
| 12 |
+
Since 2026-08-28 this module also carries the reference *files*: `POST
|
| 13 |
+
/h3_ref_chain/upload` streams dropped media into `<input>/h3_refs`, and `GET
|
| 14 |
+
/h3_ref_chain/files` lists what is there. Those replaced the nine `ref_image_N`
|
| 15 |
+
IMAGE sockets, which were most of a 16-socket column.
|
| 16 |
+
|
| 17 |
+
Everything that decides *where bytes land* lives in `media.py`, not here: one
|
| 18 |
+
prefix-checked resolver used by both the route and the loaders, so there is a
|
| 19 |
+
single place to be wrong about it. The route itself never joins a path.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os as _os
|
| 23 |
+
|
| 24 |
+
from . import directives as _d
|
| 25 |
+
from . import media as _media
|
| 26 |
+
from . import refs as _refs
|
| 27 |
+
|
| 28 |
+
TAG = "HandTieClips"
|
| 29 |
+
ROUTE = "/h3_ref_chain/vocab"
|
| 30 |
+
UPLOAD_ROUTE = "/h3_ref_chain/upload"
|
| 31 |
+
FILES_ROUTE = "/h3_ref_chain/files"
|
| 32 |
+
|
| 33 |
+
# A batch of stills is a handful; this is a guard against a runaway multipart
|
| 34 |
+
# body, not a considered product limit.
|
| 35 |
+
MAX_UPLOAD_FILES = 32
|
| 36 |
+
MAX_UPLOAD_BYTES = 256 * 1024 * 1024
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _payload():
|
| 40 |
+
from .h3_ref_chain import DURATION_FRAMES, OVERLAP_FRAMES, CANVAS, FPS
|
| 41 |
+
|
| 42 |
+
return {
|
| 43 |
+
# axis -> option -> the sentence it compiles to. Order matters: AXES is
|
| 44 |
+
# the order directive_prose concatenates in, so the UI shows them in the
|
| 45 |
+
# order the encoder reads them.
|
| 46 |
+
"axes": list(_d.AXES),
|
| 47 |
+
"vocab": {axis: dict(opts) for axis, opts in _d.VOCAB.items()},
|
| 48 |
+
"defaults": dict(_d.DEFAULTS),
|
| 49 |
+
"establish": _d.ESTABLISH,
|
| 50 |
+
# join has nothing to attach to on the first hop; the editor hides it
|
| 51 |
+
# there to match directive_prose's hop_index == 0 skip.
|
| 52 |
+
"join_axis": "join",
|
| 53 |
+
|
| 54 |
+
"retention": dict(_refs.RETENTION),
|
| 55 |
+
"max_ref_images": _refs.MAX_REF_IMAGES,
|
| 56 |
+
"refs_subdir": _media.REFS_SUBDIR,
|
| 57 |
+
"image_exts": sorted(_media.IMAGE_EXTS),
|
| 58 |
+
"video_exts": sorted(_media.VIDEO_EXTS),
|
| 59 |
+
"audio_exts": sorted(_media.AUDIO_EXTS),
|
| 60 |
+
"ref_fields": list(_refs.REF_FIELDS),
|
| 61 |
+
"subject_fields": list(_refs.SUBJECT_FIELDS),
|
| 62 |
+
|
| 63 |
+
"durations": {k: v for k, v in DURATION_FRAMES.items()},
|
| 64 |
+
"overlaps": {k: v for k, v in OVERLAP_FRAMES.items()},
|
| 65 |
+
"canvas": {res: {asp: list(wh) for asp, wh in by_asp.items()}
|
| 66 |
+
for res, by_asp in CANVAS.items()},
|
| 67 |
+
"fps": FPS,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def register():
|
| 72 |
+
"""Attach the route if a PromptServer exists. Never raises on import."""
|
| 73 |
+
try:
|
| 74 |
+
from aiohttp import web
|
| 75 |
+
from server import PromptServer
|
| 76 |
+
except ImportError:
|
| 77 |
+
return False
|
| 78 |
+
instance = getattr(PromptServer, "instance", None)
|
| 79 |
+
if instance is None or not hasattr(instance, "routes"):
|
| 80 |
+
return False
|
| 81 |
+
|
| 82 |
+
@instance.routes.get(ROUTE)
|
| 83 |
+
async def _vocab(_request):
|
| 84 |
+
try:
|
| 85 |
+
return web.json_response(_payload())
|
| 86 |
+
except Exception as exc: # a broken payload must not take the server down
|
| 87 |
+
print(f"[{TAG}] vocab route failed: {exc!r}", flush=True)
|
| 88 |
+
return web.json_response({"error": str(exc)}, status=500)
|
| 89 |
+
|
| 90 |
+
@instance.routes.get(FILES_ROUTE)
|
| 91 |
+
async def _files(request):
|
| 92 |
+
"""What is already in the reference folder, for the editor's picker."""
|
| 93 |
+
try:
|
| 94 |
+
kinds = request.rel_url.query.get("kinds") or ""
|
| 95 |
+
want = {k.strip() for k in kinds.split(",") if k.strip()} or None
|
| 96 |
+
return web.json_response({"ok": True, "files": _media.listing(want)})
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
print(f"[{TAG}] files route failed: {exc!r}", flush=True)
|
| 99 |
+
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
| 100 |
+
|
| 101 |
+
@instance.routes.post(UPLOAD_ROUTE)
|
| 102 |
+
async def _upload(request):
|
| 103 |
+
"""Stream dropped media into the reference folder.
|
| 104 |
+
|
| 105 |
+
Multipart, not JSON+base64: a batch of stills should not be inflated by
|
| 106 |
+
a third and held in memory twice on the way through the browser. The
|
| 107 |
+
destination is fixed -- `media.refs_dir()` -- so a filename can only
|
| 108 |
+
ever name a file *inside* it, and `media.resolve` re-checks that on the
|
| 109 |
+
way back out.
|
| 110 |
+
"""
|
| 111 |
+
if not (request.content_type or "").startswith("multipart/"):
|
| 112 |
+
return web.json_response(
|
| 113 |
+
{"ok": False, "error": "expected multipart/form-data"}, status=400)
|
| 114 |
+
try:
|
| 115 |
+
reader = await request.multipart()
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
return web.json_response(
|
| 118 |
+
{"ok": False, "error": f"bad multipart: {exc}"}, status=400)
|
| 119 |
+
|
| 120 |
+
dest = _media.refs_dir(create=True)
|
| 121 |
+
saved, skipped, total = [], [], 0
|
| 122 |
+
try:
|
| 123 |
+
while True:
|
| 124 |
+
part = await reader.next()
|
| 125 |
+
if part is None:
|
| 126 |
+
break
|
| 127 |
+
if not getattr(part, "filename", None):
|
| 128 |
+
continue
|
| 129 |
+
if len(saved) >= MAX_UPLOAD_FILES:
|
| 130 |
+
skipped.append(f"{part.filename}: batch limit "
|
| 131 |
+
f"{MAX_UPLOAD_FILES} reached")
|
| 132 |
+
break
|
| 133 |
+
# Basename only, and the extension has to be one we can open.
|
| 134 |
+
# Rejecting here means nothing unreadable is ever written.
|
| 135 |
+
name = _os.path.basename(part.filename or "")
|
| 136 |
+
kind = _media.kind_of(name)
|
| 137 |
+
if kind is None:
|
| 138 |
+
skipped.append(f"{name}: not an image, video or audio file")
|
| 139 |
+
continue
|
| 140 |
+
path = _media.unique_path(dest, name)
|
| 141 |
+
size = 0
|
| 142 |
+
try:
|
| 143 |
+
with open(path, "wb") as fh:
|
| 144 |
+
while True:
|
| 145 |
+
chunk = await part.read_chunk()
|
| 146 |
+
if not chunk:
|
| 147 |
+
break
|
| 148 |
+
size += len(chunk)
|
| 149 |
+
total += len(chunk)
|
| 150 |
+
if total > MAX_UPLOAD_BYTES:
|
| 151 |
+
raise ValueError("upload too large")
|
| 152 |
+
fh.write(chunk)
|
| 153 |
+
except Exception:
|
| 154 |
+
# A partial file is worse than no file: it would list in
|
| 155 |
+
# the picker and fail to open.
|
| 156 |
+
try:
|
| 157 |
+
_os.remove(path)
|
| 158 |
+
except OSError:
|
| 159 |
+
pass
|
| 160 |
+
raise
|
| 161 |
+
w = h = 0
|
| 162 |
+
if kind == "image":
|
| 163 |
+
path, w, h = _media.shrink_image(path)
|
| 164 |
+
saved.append({"name": _os.path.basename(path), "kind": kind,
|
| 165 |
+
"width": w, "height": h, "bytes": size})
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
print(f"[{TAG}] upload failed: {exc!r}", flush=True)
|
| 168 |
+
return web.json_response(
|
| 169 |
+
{"ok": False, "error": str(exc), "files": saved}, status=400)
|
| 170 |
+
|
| 171 |
+
if saved:
|
| 172 |
+
print(f"[{TAG}] uploaded {len(saved)} reference file(s) -> "
|
| 173 |
+
+ ", ".join(f["name"] for f in saved), flush=True)
|
| 174 |
+
for note in skipped:
|
| 175 |
+
print(f"[{TAG}] upload skipped {note}", flush=True)
|
| 176 |
+
return web.json_response({"ok": True, "files": saved, "skipped": skipped})
|
| 177 |
+
|
| 178 |
+
return True
|
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Disk-backed hop store: resume, single-shot re-roll, and lower peak RAM.
|
| 2 |
+
|
| 3 |
+
Hops are causally dependent -- hop N is rendered from hop N-1's tail -- so the
|
| 4 |
+
cache key **chains**: each hop's key mixes in the previous hop's key. Editing
|
| 5 |
+
shot 1 therefore invalidates 2..N automatically, which is correct and must be
|
| 6 |
+
surfaced in the UI, because otherwise it reads as a bug.
|
| 7 |
+
|
| 8 |
+
**Why 16-bit.** A cached hop's last frame becomes the next hop's Qwen pin and
|
| 9 |
+
its AddGuide guide. Round-tripping float32 through 8-bit would make a resumed
|
| 10 |
+
chain diverge from an uninterrupted one -- the cache would change the output,
|
| 11 |
+
which defeats the point. FFV1 at `rgb48le` keeps ~16 bits per channel, which is
|
| 12 |
+
far below the VAE's own noise floor, so a resumed hop is indistinguishable from
|
| 13 |
+
a fresh one. The cost is roughly 2x the bytes of an 8-bit lossless encode, and
|
| 14 |
+
FFV1 still compresses it well.
|
| 15 |
+
|
| 16 |
+
**On the RAM claim.** The node's IMAGE output is the whole clip, so the final
|
| 17 |
+
tensor is unavoidably full size. What the store removes is the *double and
|
| 18 |
+
triple buffering* during the loop: today `master_imgs` grows by concatenation
|
| 19 |
+
(which allocates a new full-size tensor every hop) while `prev_imgs` and `imgs`
|
| 20 |
+
are also live. Streaming to disk keeps one hop plus the overlap tail resident
|
| 21 |
+
and concatenates once at the end.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import hashlib
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import shutil
|
| 28 |
+
import subprocess
|
| 29 |
+
import time
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
TAG = "HandTieClips"
|
| 35 |
+
|
| 36 |
+
VIDEO_EXT = ".mkv"
|
| 37 |
+
AUDIO_EXT = ".npy"
|
| 38 |
+
META_EXT = ".json"
|
| 39 |
+
LATENT_EXT = ".latent.pt"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _ffmpeg():
|
| 43 |
+
exe = shutil.which("ffmpeg")
|
| 44 |
+
if not exe:
|
| 45 |
+
raise RuntimeError(
|
| 46 |
+
f"{TAG}: ffmpeg is not on PATH. The hop store needs it to write "
|
| 47 |
+
f"lossless FFV1. Install ffmpeg or set cache to off."
|
| 48 |
+
)
|
| 49 |
+
return exe
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def tensor_digest(t):
|
| 53 |
+
"""Cheap, order-sensitive digest of a tensor's actual bytes."""
|
| 54 |
+
if t is None:
|
| 55 |
+
return "none"
|
| 56 |
+
a = t.detach().cpu().contiguous().numpy()
|
| 57 |
+
h = hashlib.sha256()
|
| 58 |
+
h.update(str(a.shape).encode())
|
| 59 |
+
h.update(str(a.dtype).encode())
|
| 60 |
+
h.update(a.tobytes())
|
| 61 |
+
return h.hexdigest()[:16]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def audio_digest(a):
|
| 65 |
+
"""Digest an AUDIO input -- ``{"waveform": Tensor, "sample_rate": int}``.
|
| 66 |
+
|
| 67 |
+
AUDIO is a dict, not a tensor, so `tensor_digest` cannot take it: `.detach`
|
| 68 |
+
on a dict raises AttributeError, which is what wiring `voice` with the hop
|
| 69 |
+
cache on used to do before hop 1 ever started. Sample rate is part of the
|
| 70 |
+
identity -- the same waveform at a different rate is different audio.
|
| 71 |
+
"""
|
| 72 |
+
if a is None:
|
| 73 |
+
return "none"
|
| 74 |
+
if not isinstance(a, dict):
|
| 75 |
+
return tensor_digest(a)
|
| 76 |
+
h = hashlib.sha256()
|
| 77 |
+
h.update(tensor_digest(a.get("waveform")).encode())
|
| 78 |
+
h.update(str(a.get("sample_rate")).encode())
|
| 79 |
+
return h.hexdigest()[:16]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def hop_key(prev_key, payload):
|
| 83 |
+
"""Chained content key. `payload` must contain everything that changes pixels.
|
| 84 |
+
|
| 85 |
+
Anything omitted here is something the cache will fail to notice, so err
|
| 86 |
+
toward including it.
|
| 87 |
+
"""
|
| 88 |
+
h = hashlib.sha256()
|
| 89 |
+
h.update((prev_key or "root").encode())
|
| 90 |
+
h.update(json.dumps(payload, sort_keys=True, default=str).encode())
|
| 91 |
+
return h.hexdigest()[:24]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class HopStore:
|
| 95 |
+
def __init__(self, root, budget_gb=20.0, fps=24):
|
| 96 |
+
self.root = str(root)
|
| 97 |
+
self.budget = float(budget_gb) * (1024 ** 3)
|
| 98 |
+
self.fps = int(fps)
|
| 99 |
+
os.makedirs(self.root, exist_ok=True)
|
| 100 |
+
|
| 101 |
+
# -- paths ------------------------------------------------------------
|
| 102 |
+
def _p(self, key, ext):
|
| 103 |
+
return os.path.join(self.root, key + ext)
|
| 104 |
+
|
| 105 |
+
def has(self, key):
|
| 106 |
+
return all(os.path.exists(self._p(key, e))
|
| 107 |
+
for e in (VIDEO_EXT, AUDIO_EXT, META_EXT))
|
| 108 |
+
|
| 109 |
+
# -- write ------------------------------------------------------------
|
| 110 |
+
def put(self, key, imgs, wav, sr, meta=None, latent=None):
|
| 111 |
+
"""imgs: float [N,H,W,3] in 0..1 on cpu. wav: float [.., C, S].
|
| 112 |
+
|
| 113 |
+
`latent` is this hop's sampler output and is optional; see the comment
|
| 114 |
+
at the write below for why storing it is what makes the cache useful
|
| 115 |
+
past hop 1.
|
| 116 |
+
"""
|
| 117 |
+
n, hgt, wid = int(imgs.shape[0]), int(imgs.shape[1]), int(imgs.shape[2])
|
| 118 |
+
vid_tmp = self._p(key, VIDEO_EXT + ".part")
|
| 119 |
+
cmd = [
|
| 120 |
+
_ffmpeg(), "-y", "-v", "error",
|
| 121 |
+
"-f", "rawvideo", "-pix_fmt", "rgb48le",
|
| 122 |
+
"-s", f"{wid}x{hgt}", "-r", str(self.fps),
|
| 123 |
+
"-i", "-",
|
| 124 |
+
"-c:v", "ffv1", "-level", "3", "-coder", "1", "-context", "1",
|
| 125 |
+
"-pix_fmt", "rgb48le",
|
| 126 |
+
# The .part suffix defeats extension-based format detection, so the
|
| 127 |
+
# muxer is named explicitly. Writing to .part and renaming on success
|
| 128 |
+
# keeps a killed render from leaving a half-file that `has()` trusts.
|
| 129 |
+
"-f", "matroska", vid_tmp,
|
| 130 |
+
]
|
| 131 |
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE,
|
| 132 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
| 133 |
+
try:
|
| 134 |
+
# Frame at a time: never materialise a second full-size copy.
|
| 135 |
+
for i in range(n):
|
| 136 |
+
f = (imgs[i].clamp(0, 1) * 65535.0).round().to(torch.int32)
|
| 137 |
+
proc.stdin.write(f.numpy().astype("<u2").tobytes())
|
| 138 |
+
proc.stdin.close()
|
| 139 |
+
except BrokenPipeError:
|
| 140 |
+
pass
|
| 141 |
+
err = proc.stderr.read().decode(errors="replace")
|
| 142 |
+
if proc.wait() != 0:
|
| 143 |
+
raise RuntimeError(f"{TAG}: FFV1 encode failed for hop {key}: {err.strip()}")
|
| 144 |
+
os.replace(vid_tmp, self._p(key, VIDEO_EXT))
|
| 145 |
+
|
| 146 |
+
np.save(self._p(key, AUDIO_EXT),
|
| 147 |
+
wav.detach().cpu().contiguous().numpy().astype(np.float32))
|
| 148 |
+
|
| 149 |
+
# The sampler latent, not just the decoded frames. Without it a cache
|
| 150 |
+
# hit leaves the caller's `prev_sampled` empty, so the *next* hop
|
| 151 |
+
# predicts the AddGuide pixel fallback instead of Motion-Context --
|
| 152 |
+
# which is part of its key, so that key stops matching what was stored.
|
| 153 |
+
# The cache could therefore never hit past hop 1, and the hop after a
|
| 154 |
+
# hit was joined by the inferior mechanism, making `cache_hops=on`
|
| 155 |
+
# actively worse than off. Optional and best-effort: a hop whose latent
|
| 156 |
+
# will not serialise is still worth caching for its frames, it just
|
| 157 |
+
# cannot seed a latent join.
|
| 158 |
+
if latent is not None:
|
| 159 |
+
tmp = self._p(key, LATENT_EXT + ".part")
|
| 160 |
+
try:
|
| 161 |
+
torch.save(latent, tmp)
|
| 162 |
+
os.replace(tmp, self._p(key, LATENT_EXT))
|
| 163 |
+
except Exception as e: # noqa: BLE001
|
| 164 |
+
print(f"[{TAG}] hop {key[:8]}: latent not cached ({e!r}); a hit "
|
| 165 |
+
f"on this hop will fall back to the pixel pin", flush=True)
|
| 166 |
+
try:
|
| 167 |
+
os.remove(tmp)
|
| 168 |
+
except OSError:
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
info = dict(meta or {})
|
| 172 |
+
info.update({"frames": n, "height": hgt, "width": wid,
|
| 173 |
+
"sample_rate": int(sr), "fps": self.fps,
|
| 174 |
+
"written": time.time()})
|
| 175 |
+
with open(self._p(key, META_EXT), "w", encoding="utf-8") as fh:
|
| 176 |
+
json.dump(info, fh, indent=1)
|
| 177 |
+
return info
|
| 178 |
+
|
| 179 |
+
# -- read -------------------------------------------------------------
|
| 180 |
+
def _get_latent(self, key):
|
| 181 |
+
"""The stored sampler latent, or None when this entry has none.
|
| 182 |
+
|
| 183 |
+
None is always safe: the caller falls back to the pixel pin, which is
|
| 184 |
+
what every cache hit did before latents were stored. Entries written by
|
| 185 |
+
an older build simply have no sidecar, so they keep working.
|
| 186 |
+
"""
|
| 187 |
+
path = self._p(key, LATENT_EXT)
|
| 188 |
+
if not os.path.exists(path):
|
| 189 |
+
return None
|
| 190 |
+
try:
|
| 191 |
+
# weights_only=False: this is a latent *dict*, written by our own
|
| 192 |
+
# put() into ComfyUI's temp directory, not a downloaded checkpoint.
|
| 193 |
+
return torch.load(path, map_location="cpu", weights_only=False)
|
| 194 |
+
except Exception as e: # noqa: BLE001
|
| 195 |
+
print(f"[{TAG}] hop {key[:8]}: cached latent unreadable ({e!r}); "
|
| 196 |
+
f"falling back to the pixel pin", flush=True)
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
def get(self, key):
|
| 200 |
+
"""-> (imgs float32 [N,H,W,3], wav, sample_rate, latent|None) or None."""
|
| 201 |
+
if not self.has(key):
|
| 202 |
+
return None
|
| 203 |
+
with open(self._p(key, META_EXT), encoding="utf-8") as fh:
|
| 204 |
+
info = json.load(fh)
|
| 205 |
+
n, hgt, wid = int(info["frames"]), int(info["height"]), int(info["width"])
|
| 206 |
+
cmd = [
|
| 207 |
+
_ffmpeg(), "-v", "error", "-i", self._p(key, VIDEO_EXT),
|
| 208 |
+
"-f", "rawvideo", "-pix_fmt", "rgb48le", "-",
|
| 209 |
+
]
|
| 210 |
+
want = n * hgt * wid * 3 * 2
|
| 211 |
+
# communicate(), not sequential reads: draining stdout to EOF while
|
| 212 |
+
# stderr is an unread pipe deadlocks the moment ffmpeg emits more than
|
| 213 |
+
# the pipe buffer on stderr -- which is exactly what a corrupt FFV1
|
| 214 |
+
# does, i.e. the one case where the error actually matters.
|
| 215 |
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
| 216 |
+
raw, err_raw = proc.communicate()
|
| 217 |
+
err = err_raw.decode(errors="replace")
|
| 218 |
+
if proc.returncode != 0:
|
| 219 |
+
raise RuntimeError(f"{TAG}: FFV1 decode failed for hop {key}: {err.strip()}")
|
| 220 |
+
if len(raw) != want:
|
| 221 |
+
raise RuntimeError(
|
| 222 |
+
f"{TAG}: cached hop {key} is {len(raw)} bytes, expected {want}. "
|
| 223 |
+
f"Delete the cache entry and re-render.")
|
| 224 |
+
arr = np.frombuffer(raw, dtype="<u2").reshape(n, hgt, wid, 3)
|
| 225 |
+
imgs = torch.from_numpy(arr.astype(np.float32) / 65535.0)
|
| 226 |
+
wav = torch.from_numpy(np.load(self._p(key, AUDIO_EXT)))
|
| 227 |
+
os.utime(self._p(key, VIDEO_EXT), None) # LRU touch
|
| 228 |
+
return imgs, wav, int(info["sample_rate"]), self._get_latent(key)
|
| 229 |
+
|
| 230 |
+
# -- shot locks -------------------------------------------------------
|
| 231 |
+
# A locked shot reuses whatever it last rendered even when its inputs
|
| 232 |
+
# change, so it needs a name that survives an edit -- the content key by
|
| 233 |
+
# definition does not. The pointer maps a stable shot name to the key that
|
| 234 |
+
# shot last produced.
|
| 235 |
+
def _ptr(self, name):
|
| 236 |
+
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(name))
|
| 237 |
+
return os.path.join(self.root, "ptr_" + safe + ".json")
|
| 238 |
+
|
| 239 |
+
def set_pointer(self, name, key):
|
| 240 |
+
with open(self._ptr(name), "w", encoding="utf-8") as fh:
|
| 241 |
+
json.dump({"key": key, "written": time.time()}, fh)
|
| 242 |
+
|
| 243 |
+
def get_pointer(self, name):
|
| 244 |
+
try:
|
| 245 |
+
with open(self._ptr(name), encoding="utf-8") as fh:
|
| 246 |
+
key = json.load(fh).get("key")
|
| 247 |
+
except (OSError, ValueError):
|
| 248 |
+
return None
|
| 249 |
+
return key if key and self.has(key) else None
|
| 250 |
+
|
| 251 |
+
# -- housekeeping -----------------------------------------------------
|
| 252 |
+
def entries(self):
|
| 253 |
+
out = []
|
| 254 |
+
for fn in os.listdir(self.root):
|
| 255 |
+
if not fn.endswith(VIDEO_EXT):
|
| 256 |
+
continue
|
| 257 |
+
key = fn[: -len(VIDEO_EXT)]
|
| 258 |
+
try:
|
| 259 |
+
st = os.stat(self._p(key, VIDEO_EXT))
|
| 260 |
+
except OSError:
|
| 261 |
+
continue
|
| 262 |
+
size = st.st_size
|
| 263 |
+
for e in (AUDIO_EXT, META_EXT, LATENT_EXT):
|
| 264 |
+
try:
|
| 265 |
+
size += os.path.getsize(self._p(key, e))
|
| 266 |
+
except OSError:
|
| 267 |
+
pass
|
| 268 |
+
out.append((key, size, st.st_mtime))
|
| 269 |
+
return out
|
| 270 |
+
|
| 271 |
+
def sweep(self, keep=()):
|
| 272 |
+
"""Evict least-recently-used entries until under budget.
|
| 273 |
+
|
| 274 |
+
`keep` names hops this run still needs, so a budget smaller than one
|
| 275 |
+
chain cannot delete the hop that is about to be read back.
|
| 276 |
+
"""
|
| 277 |
+
items = self.entries()
|
| 278 |
+
total = sum(s for _, s, _ in items)
|
| 279 |
+
if total <= self.budget:
|
| 280 |
+
return 0
|
| 281 |
+
keep = set(keep)
|
| 282 |
+
freed = 0
|
| 283 |
+
for key, size, _ in sorted(items, key=lambda r: r[2]):
|
| 284 |
+
if total - freed <= self.budget:
|
| 285 |
+
break
|
| 286 |
+
if key in keep:
|
| 287 |
+
continue
|
| 288 |
+
for e in (VIDEO_EXT, AUDIO_EXT, META_EXT, LATENT_EXT):
|
| 289 |
+
try:
|
| 290 |
+
os.remove(self._p(key, e))
|
| 291 |
+
except OSError:
|
| 292 |
+
pass
|
| 293 |
+
freed += size
|
| 294 |
+
if freed:
|
| 295 |
+
print(f"[{TAG}] cache: evicted {freed / 1024 ** 3:.2f} GB "
|
| 296 |
+
f"(budget {self.budget / 1024 ** 3:.1f} GB)", flush=True)
|
| 297 |
+
return freed
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tone compensation for chained H3 segments.
|
| 2 |
+
|
| 3 |
+
The H3 denoiser applies a tone bias to each generated segment, which shows up as
|
| 4 |
+
a brightness step at the seam between hops. This module estimates that bias on
|
| 5 |
+
the overlap -- the segment's first frames against the source frames they
|
| 6 |
+
reconstruct -- and undoes it across the whole segment.
|
| 7 |
+
|
| 8 |
+
The estimator, and the reasoning behind the three modes, is ported from
|
| 9 |
+
`rkfg/ComfyUI-MiniMaxH3-ToneCompensate` (MIT). This pack is MIT too, so the code
|
| 10 |
+
travels; the credit does not travel by itself, hence this paragraph. Changes
|
| 11 |
+
from upstream: the overlap default is our 22 frames rather than his 48, `"off"`
|
| 12 |
+
is a real mode so callers need no branch, and the math is split from the node so
|
| 13 |
+
it can be tested without a running server (same reason `plan.py` and `refs.py`
|
| 14 |
+
have no ComfyUI imports).
|
| 15 |
+
|
| 16 |
+
The three modes, most to least specific:
|
| 17 |
+
|
| 18 |
+
- frame_shift: per-frame per-channel additive shift. The target's first frames
|
| 19 |
+
are the model's regeneration of the source (similar content, not a pixel-wise
|
| 20 |
+
transform), so the bias is best captured as a per-frame shift of the mean.
|
| 21 |
+
Matches the overlap exactly and applies the last overlap frame's shift to the
|
| 22 |
+
continuation (no seam).
|
| 23 |
+
- gain_bias: global per-channel affine s = A*g + C. Robust, extrapolates
|
| 24 |
+
cleanly; good when the drift is a roughly uniform shift/compression.
|
| 25 |
+
- lut: per-pixel piecewise-linear tone curve. Captures nonlinear drift;
|
| 26 |
+
flexible, but overfits when the target is regenerated content (pixels differ
|
| 27 |
+
from the source).
|
| 28 |
+
|
| 29 |
+
Alignment: source's last `overlap` frames are paired with target's first
|
| 30 |
+
`overlap` frames, so the whole previous segment can be passed as `source` and it
|
| 31 |
+
auto-crops to the tail.
|
| 32 |
+
"""
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import torch
|
| 36 |
+
|
| 37 |
+
TAG = "HTCTone"
|
| 38 |
+
MODES = ["off", "frame_shift", "gain_bias", "lut"]
|
| 39 |
+
|
| 40 |
+
_TABLE = 4096 # dense LUT resolution used when applying the lut mode
|
| 41 |
+
|
| 42 |
+
# Our native overlap: 0.9 s at 24 fps, the H3 continuation length. Upstream
|
| 43 |
+
# defaults to 48 (2 s) because that is what his workflow pins with.
|
| 44 |
+
DEFAULT_OVERLAP = 22
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _fit_affine(src, tgt):
|
| 48 |
+
"""Regress source on generated per channel: s = A*g + C. Return (A, C) as [1,1,1,C]."""
|
| 49 |
+
c_out = src.shape[-1]
|
| 50 |
+
gain = torch.ones(1, 1, 1, c_out, dtype=torch.float32, device=src.device)
|
| 51 |
+
bias = torch.zeros(1, 1, 1, c_out, dtype=torch.float32, device=src.device)
|
| 52 |
+
for c in range(c_out):
|
| 53 |
+
s = src[..., c].reshape(-1).float()
|
| 54 |
+
g = tgt[..., c].reshape(-1).float()
|
| 55 |
+
gm = g.mean()
|
| 56 |
+
sm = s.mean()
|
| 57 |
+
dg = g - gm
|
| 58 |
+
den = (dg * dg).sum()
|
| 59 |
+
if den < 1e-12:
|
| 60 |
+
A, C = 1.0, float(sm - gm)
|
| 61 |
+
else:
|
| 62 |
+
A = float((dg * (s - sm)).sum() / den)
|
| 63 |
+
C = float(sm - A * gm)
|
| 64 |
+
if abs(A) < 1e-6:
|
| 65 |
+
A = 1.0
|
| 66 |
+
gain[0, 0, 0, c] = A
|
| 67 |
+
bias[0, 0, 0, c] = C
|
| 68 |
+
return gain, bias
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _monotone(ys):
|
| 72 |
+
"""Make ys non-decreasing (guards against bin-mean inversions from noise)."""
|
| 73 |
+
v = ys.tolist()
|
| 74 |
+
best = v[0]
|
| 75 |
+
out = []
|
| 76 |
+
for y in v:
|
| 77 |
+
if y > best:
|
| 78 |
+
best = y
|
| 79 |
+
out.append(best)
|
| 80 |
+
return torch.tensor(out, dtype=torch.float32, device=ys.device)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _lut_control(s, g, bins):
|
| 84 |
+
"""Build per-channel control points from paired pixels.
|
| 85 |
+
|
| 86 |
+
For each generated-value bin that actually occurs, store (mean generated,
|
| 87 |
+
mean source). Using the means as x keeps boundary segments exact -- bin
|
| 88 |
+
centres would skew the outer slopes. Returns sorted (xs, ys).
|
| 89 |
+
"""
|
| 90 |
+
dev = g.device
|
| 91 |
+
idx = torch.clamp(torch.floor(g * bins), 0, bins - 1).long()
|
| 92 |
+
sums_s = torch.zeros(bins, dtype=torch.float32, device=dev)
|
| 93 |
+
sums_g = torch.zeros(bins, dtype=torch.float32, device=dev)
|
| 94 |
+
counts = torch.zeros(bins, dtype=torch.float32, device=dev)
|
| 95 |
+
sums_s.index_add_(0, idx, s)
|
| 96 |
+
sums_g.index_add_(0, idx, g)
|
| 97 |
+
counts.index_add_(0, idx, torch.ones_like(idx, dtype=torch.float32))
|
| 98 |
+
nz = counts > 0
|
| 99 |
+
xs = (sums_g / counts)[nz]
|
| 100 |
+
ys = (sums_s / counts)[nz]
|
| 101 |
+
return xs, _monotone(ys)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _linfit(x, y):
|
| 105 |
+
"""Least-squares line y = slope*x + intercept. Return (slope, intercept)."""
|
| 106 |
+
xm, ym = x.mean(), y.mean()
|
| 107 |
+
dx = x - xm
|
| 108 |
+
den = (dx * dx).sum()
|
| 109 |
+
if den < 1e-12:
|
| 110 |
+
return 0.0, ym.item()
|
| 111 |
+
slope = float((dx * (y - ym)).sum() / den)
|
| 112 |
+
return slope, float(ym - slope * xm)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _pwl(query, xs, ys):
|
| 116 |
+
"""Piecewise-linear evaluation of the (xs, ys) control points at query.
|
| 117 |
+
|
| 118 |
+
The interior is interpolated; the ends extrapolate with a robust slope
|
| 119 |
+
(least-squares on the outermost K points) so a single noisy boundary bin
|
| 120 |
+
cannot skew the extrapolation.
|
| 121 |
+
"""
|
| 122 |
+
n = xs.numel()
|
| 123 |
+
if n == 1:
|
| 124 |
+
return torch.full_like(query, ys.item())
|
| 125 |
+
k = min(5, n)
|
| 126 |
+
ls, lb = _linfit(xs[:k], ys[:k])
|
| 127 |
+
rs, rb = _linfit(xs[-k:], ys[-k:])
|
| 128 |
+
i = torch.clamp(torch.searchsorted(xs, query), 1, n - 1)
|
| 129 |
+
xl, xr = xs[i - 1], xs[i]
|
| 130 |
+
yl, yr = ys[i - 1], ys[i]
|
| 131 |
+
out = yl + (yr - yl) * (query - xl) / (xr - xl)
|
| 132 |
+
left, right = query < xs[0], query > xs[-1]
|
| 133 |
+
out = torch.where(left, ls * query + lb, out)
|
| 134 |
+
out = torch.where(right, rs * query + rb, out)
|
| 135 |
+
return out
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _apply_lut(x, xs, ys, table=_TABLE):
|
| 139 |
+
"""Apply a per-channel control LUT to x (values in [0,1])."""
|
| 140 |
+
dense = _pwl(torch.linspace(0, 1, table, device=x.device), xs, ys)
|
| 141 |
+
idx = torch.clamp(torch.floor(x * table), 0, table - 1).long()
|
| 142 |
+
return dense[idx]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def compensate(source, target, mode, overlap=DEFAULT_OVERLAP, lut_bins=64):
|
| 146 |
+
"""Correct `target`'s tone to match `source`. -> (images, note).
|
| 147 |
+
|
| 148 |
+
`source` is the previous segment (or just its tail); `target` is the whole
|
| 149 |
+
generated segment. Both float [N,H,W,3] in 0..1. Returns the corrected
|
| 150 |
+
target and a short human-readable note for the log, or (target, "") when
|
| 151 |
+
there is nothing to do.
|
| 152 |
+
|
| 153 |
+
`mode="off"` returns `target` untouched, so a caller can pass the widget
|
| 154 |
+
value straight through without branching on it. Anything unrecognised is
|
| 155 |
+
treated the same way rather than raising: a bad mode should not lose a
|
| 156 |
+
render that has already been sampled.
|
| 157 |
+
"""
|
| 158 |
+
if mode is None or str(mode) == "off" or str(mode) not in MODES:
|
| 159 |
+
return target, ""
|
| 160 |
+
if source is None or target is None:
|
| 161 |
+
return target, ""
|
| 162 |
+
|
| 163 |
+
src = source.float()
|
| 164 |
+
tgt = target.float()
|
| 165 |
+
n = min(int(overlap), int(src.shape[0]), int(tgt.shape[0]))
|
| 166 |
+
if n <= 0:
|
| 167 |
+
return target, ""
|
| 168 |
+
fit_src = src[-n:]
|
| 169 |
+
fit_tgt = tgt[:n]
|
| 170 |
+
|
| 171 |
+
mode = str(mode)
|
| 172 |
+
if mode == "frame_shift":
|
| 173 |
+
# Per-frame per-channel drift (mean target - mean source), applied
|
| 174 |
+
# per-frame on the overlap and as the last overlap frame's value on the
|
| 175 |
+
# continuation -- which is what makes the seam itself exact.
|
| 176 |
+
drift = fit_tgt.mean(dim=(1, 2), keepdim=True) - fit_src.mean(dim=(1, 2), keepdim=True)
|
| 177 |
+
out = tgt.clone()
|
| 178 |
+
out[:n] = out[:n] - drift
|
| 179 |
+
out[n:] = out[n:] - drift[-1]
|
| 180 |
+
d = drift[-1].reshape(-1)
|
| 181 |
+
note = ("frame_shift " + " ".join(f"{c}{v:+.4f}" for c, v in zip("rgb", d.tolist())))
|
| 182 |
+
elif mode == "gain_bias":
|
| 183 |
+
gain, bias = _fit_affine(fit_src, fit_tgt)
|
| 184 |
+
out = gain * tgt + bias
|
| 185 |
+
g = gain.reshape(-1).tolist()
|
| 186 |
+
b = bias.reshape(-1).tolist()
|
| 187 |
+
note = ("gain_bias " + " ".join(f"{c}x{gv:.4f}{bv:+.4f}"
|
| 188 |
+
for c, gv, bv in zip("rgb", g, b)))
|
| 189 |
+
else: # lut
|
| 190 |
+
out = torch.empty_like(tgt)
|
| 191 |
+
for c in range(tgt.shape[-1]):
|
| 192 |
+
xs, ys = _lut_control(fit_src[..., c].reshape(-1),
|
| 193 |
+
fit_tgt[..., c].reshape(-1), int(lut_bins))
|
| 194 |
+
out[..., c] = _apply_lut(tgt[..., c], xs, ys)
|
| 195 |
+
before = float(tgt.mean())
|
| 196 |
+
after = float(out.mean())
|
| 197 |
+
note = f"lut mean {before:.4f} -> {after:.4f} ({int(lut_bins)} bins)"
|
| 198 |
+
|
| 199 |
+
out = out.clamp_(0.0, 1.0).to(target.dtype)
|
| 200 |
+
return out, note
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class HTCToneCompensate:
|
| 204 |
+
"""Undo the denoiser's tone bias on a generated H3 segment.
|
| 205 |
+
|
| 206 |
+
Wire it between two hand-chained H3 generations: `source` is the previous
|
| 207 |
+
segment, `target` the one to correct.
|
| 208 |
+
|
| 209 |
+
Note this cannot do the same job downstream of `HandTieClips`. That node joins
|
| 210 |
+
its hops internally and drops each hop's first `overlap` frames at the seam,
|
| 211 |
+
so the regenerated copies this estimator needs no longer exist by the time
|
| 212 |
+
images leave it. Use the chain node's own `tone_compensate` widget for that;
|
| 213 |
+
this node is for hand-built chains, and for A/B-ing the correction.
|
| 214 |
+
"""
|
| 215 |
+
|
| 216 |
+
@classmethod
|
| 217 |
+
def INPUT_TYPES(cls):
|
| 218 |
+
return {
|
| 219 |
+
"required": {
|
| 220 |
+
"source": ("IMAGE", {"tooltip": "The previous segment. The whole thing is fine -- only its last `overlap` frames are read."}),
|
| 221 |
+
"target": ("IMAGE", {"tooltip": "The generated segment to correct."}),
|
| 222 |
+
"mode": (MODES, {
|
| 223 |
+
"default": "frame_shift",
|
| 224 |
+
"tooltip": (
|
| 225 |
+
"frame_shift: per-frame additive shift; best when the target is "
|
| 226 |
+
"regenerated content, which it is here. gain_bias: global affine, "
|
| 227 |
+
"robust. lut: tone curve, captures nonlinear drift but overfits."
|
| 228 |
+
),
|
| 229 |
+
}),
|
| 230 |
+
"overlap": ("INT", {
|
| 231 |
+
"default": DEFAULT_OVERLAP, "min": 1, "max": 4096,
|
| 232 |
+
"tooltip": (
|
| 233 |
+
"Number of keyframe frames: last `overlap` of source vs first "
|
| 234 |
+
"`overlap` of target. Must equal the keyframe count used for "
|
| 235 |
+
"generation (22 = 0.9 s @ 24 fps), NOT the whole segment."
|
| 236 |
+
),
|
| 237 |
+
}),
|
| 238 |
+
"lut_bins": ("INT", {
|
| 239 |
+
"default": 64, "min": 16, "max": 512,
|
| 240 |
+
"tooltip": "Aggregation bins for lut mode. Ignored otherwise.",
|
| 241 |
+
}),
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
RETURN_TYPES = ("IMAGE",)
|
| 246 |
+
RETURN_NAMES = ("images",)
|
| 247 |
+
FUNCTION = "run"
|
| 248 |
+
CATEGORY = "Hand Tie Clips"
|
| 249 |
+
|
| 250 |
+
def run(self, source, target, mode, overlap, lut_bins):
|
| 251 |
+
out, note = compensate(source, target, mode, overlap, lut_bins)
|
| 252 |
+
if note:
|
| 253 |
+
print(f"[{TAG}] {note}", flush=True)
|
| 254 |
+
return (out,)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# -- pre-rename ids ----------------------------------------------------------
|
| 260 |
+
# A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
|
| 261 |
+
# lists the node a second time in search: ComfyUI falls back to the mapping key
|
| 262 |
+
# when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
|
| 263 |
+
# DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
|
| 264 |
+
# frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
|
| 265 |
+
# while leaving it fully functional in workflows that name it.
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class _LegacyH3ToneCompensate(HTCToneCompensate):
|
| 269 |
+
DEPRECATED = True
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
NODE_CLASS_MAPPINGS = {
|
| 273 |
+
"HTCToneCompensate": HTCToneCompensate,
|
| 274 |
+
"H3ToneCompensate": _LegacyH3ToneCompensate,
|
| 275 |
+
}
|
| 276 |
+
NODE_DISPLAY_NAME_MAPPINGS = {"HTCToneCompensate": "H3 Tone Compensate"}
|
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Write the instruction board into the Starter workflow, in place.
|
| 2 |
+
|
| 3 |
+
Idempotent: cards are identified by `properties.htc_card`, so re-running
|
| 4 |
+
replaces the board rather than stacking a second copy beside it. Operates on the
|
| 5 |
+
shipped `.json` itself rather than rebuilding from a private source workflow, so
|
| 6 |
+
anyone with the pack can run it.
|
| 7 |
+
|
| 8 |
+
It also strips `widgets_values_named` from every node. Both shipped workflows
|
| 9 |
+
carried a stale copy inherited from the dev workflow they were cloned out of --
|
| 10 |
+
`chains: 3`, `duration: 10 s`, `control_after_generate: randomize`, and a legacy
|
| 11 |
+
`ref_plan` naming pictures that do not ship, 25 entries against a 28-widget node.
|
| 12 |
+
It is dormant while `Comfy.Workflow.NamedValuesRestore` stays off (experimental,
|
| 13 |
+
default false), but anyone who turns that on would load a Starter that randomizes
|
| 14 |
+
its seed and dies on a missing reference. The frontend re-emits the block
|
| 15 |
+
correctly on the next save.
|
| 16 |
+
"""
|
| 17 |
+
import io
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import shutil
|
| 21 |
+
import sys
|
| 22 |
+
|
| 23 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 24 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 25 |
+
|
| 26 |
+
import notes # noqa: E402
|
| 27 |
+
|
| 28 |
+
WF = os.path.join(HERE, "workflows", "HandTieClips_Starter.json")
|
| 29 |
+
# The board is Starter-only, but the stale-named-values bug is in both shipped
|
| 30 |
+
# workflows, so the strip runs over both.
|
| 31 |
+
ALSO_STRIP = [os.path.join(HERE, "workflows", "HandTieClips_Showcase.json")]
|
| 32 |
+
MARKER = "htc_card"
|
| 33 |
+
FIRST_ID = 20
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def strip_named(path):
|
| 37 |
+
"""Drop `widgets_values_named`, and refresh the Showcase's plain Note.
|
| 38 |
+
|
| 39 |
+
The Showcase carries one `Note` rather than the card board; its text lives
|
| 40 |
+
in `notes.SHOWCASE_NOTE` so there is still a single source for it.
|
| 41 |
+
"""
|
| 42 |
+
wf = json.load(io.open(path, encoding="utf-8"))
|
| 43 |
+
n = sum(1 for node in wf["nodes"]
|
| 44 |
+
if node.pop("widgets_values_named", None) is not None)
|
| 45 |
+
for node in wf["nodes"]:
|
| 46 |
+
if node["type"] == "Note" and node.get("widgets_values") != [notes.SHOWCASE_NOTE]:
|
| 47 |
+
node["widgets_values"] = [notes.SHOWCASE_NOTE]
|
| 48 |
+
# The dependency block pushed it past what 400x700 shows without
|
| 49 |
+
# scrolling.
|
| 50 |
+
node["size"] = [440, 900]
|
| 51 |
+
n += 1
|
| 52 |
+
if n:
|
| 53 |
+
bak = path + ".bak-notes"
|
| 54 |
+
if not os.path.exists(bak):
|
| 55 |
+
shutil.copyfile(path, bak)
|
| 56 |
+
io.open(path, "w", encoding="utf-8", newline="\n").write(
|
| 57 |
+
json.dumps(wf, indent=2) + "\n")
|
| 58 |
+
return n
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main():
|
| 62 |
+
wf = json.load(io.open(WF, encoding="utf-8"))
|
| 63 |
+
|
| 64 |
+
bak = WF + ".bak-notes"
|
| 65 |
+
if not os.path.exists(bak):
|
| 66 |
+
shutil.copyfile(WF, bak)
|
| 67 |
+
|
| 68 |
+
# Out with the old board, and with the single legacy `Note` whose text the
|
| 69 |
+
# cards now carry between them.
|
| 70 |
+
before = len(wf["nodes"])
|
| 71 |
+
wf["nodes"] = [n for n in wf["nodes"]
|
| 72 |
+
if MARKER not in (n.get("properties") or {})
|
| 73 |
+
and n["type"] != "Note"]
|
| 74 |
+
dropped = before - len(wf["nodes"])
|
| 75 |
+
|
| 76 |
+
order = max((n.get("order", 0) for n in wf["nodes"]), default=0)
|
| 77 |
+
for i, (key, title, pos, size, colour, text) in enumerate(notes.CARDS):
|
| 78 |
+
order += 1
|
| 79 |
+
wf["nodes"].append({
|
| 80 |
+
"id": FIRST_ID + i,
|
| 81 |
+
"type": "MarkdownNote",
|
| 82 |
+
"pos": list(pos),
|
| 83 |
+
"size": list(size),
|
| 84 |
+
"flags": {},
|
| 85 |
+
"order": order,
|
| 86 |
+
"mode": 0,
|
| 87 |
+
"inputs": [],
|
| 88 |
+
"outputs": [],
|
| 89 |
+
"title": title,
|
| 90 |
+
"properties": {MARKER: key},
|
| 91 |
+
"widgets_values": [text],
|
| 92 |
+
"color": colour[0],
|
| 93 |
+
"bgcolor": colour[1],
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
wf["groups"] = [dict(notes.GROUP)]
|
| 97 |
+
wf["extra"]["ds"] = dict(notes.DS)
|
| 98 |
+
wf["last_node_id"] = max(wf["last_node_id"], FIRST_ID + len(notes.CARDS) - 1)
|
| 99 |
+
|
| 100 |
+
stripped = 0
|
| 101 |
+
for n in wf["nodes"]:
|
| 102 |
+
if n.pop("widgets_values_named", None) is not None:
|
| 103 |
+
stripped += 1
|
| 104 |
+
|
| 105 |
+
io.open(WF, "w", encoding="utf-8", newline="\n").write(
|
| 106 |
+
json.dumps(wf, indent=2) + "\n")
|
| 107 |
+
|
| 108 |
+
print("%s\n dropped %d old note/card node(s), wrote %d card(s)"
|
| 109 |
+
% (os.path.basename(WF), dropped, len(notes.CARDS)))
|
| 110 |
+
print(" stripped widgets_values_named from %d node(s)" % stripped)
|
| 111 |
+
print(" %d nodes, %d bytes" % (len(wf["nodes"]), os.path.getsize(WF)))
|
| 112 |
+
|
| 113 |
+
for path in ALSO_STRIP:
|
| 114 |
+
print("%s\n stripped widgets_values_named from %d node(s)"
|
| 115 |
+
% (os.path.basename(path), strip_named(path)))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wire the turbo stack onto both shipped workflows, matching the dev graph.
|
| 2 |
+
|
| 3 |
+
UNETLoader -> LoRA Loader Stack -> H3 AdaLN LoRA Fix
|
| 4 |
+
-> MiniMax H3 Low VRAM Attention
|
| 5 |
+
-> H3 SLA Attention
|
| 6 |
+
-> Model Preview Override (KJ) -> Hand Tie Clips
|
| 7 |
+
|
| 8 |
+
and, off the same LoRA loader, **CLIP goes to the chain from the loader, not
|
| 9 |
+
from the encoder** -- that is what makes the text half of every LoRA land.
|
| 10 |
+
|
| 11 |
+
None of these five nodes belong to this pack. They are here because this is the
|
| 12 |
+
graph the node is actually run with: `steps` is 7, which only works with a turbo
|
| 13 |
+
LoRA, and the AdaLN fix exists because the LoRA needs it. Shipping the examples
|
| 14 |
+
without them ships a graph nobody uses. Every dependency is named on the START
|
| 15 |
+
HERE card, in the Showcase note and in the README.
|
| 16 |
+
|
| 17 |
+
Idempotent: inserted nodes carry `properties.htc_speed`, so a re-run rewires
|
| 18 |
+
rather than stacking a second copy. Widget values are POSITIONAL (CLAUDE.md) and
|
| 19 |
+
are copied from the proven dev workflow rather than reconstructed.
|
| 20 |
+
"""
|
| 21 |
+
import io
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import shutil
|
| 25 |
+
|
| 26 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 27 |
+
WORKFLOWS = ["HandTieClips_Starter.json", "HandTieClips_Showcase.json"]
|
| 28 |
+
MARKER = "htc_speed"
|
| 29 |
+
|
| 30 |
+
TURBO_LORA = "minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors"
|
| 31 |
+
LORA_STACK = json.dumps([{"on": True, "lora": TURBO_LORA,
|
| 32 |
+
"str": 1, "v": 1, "a": 1, "t": 1}],
|
| 33 |
+
separators=(",", ":"))
|
| 34 |
+
|
| 35 |
+
# id, type, title, pos, size, widgets
|
| 36 |
+
#
|
| 37 |
+
# Widget order is INPUT_TYPES order with sockets skipped. The trailing "" on the
|
| 38 |
+
# LoRA loader and the preview override is carried over from the dev workflow --
|
| 39 |
+
# a value past the last widget is ignored, and dropping one that turns out to
|
| 40 |
+
# belong to a widget would silently shift every value after it.
|
| 41 |
+
STACK = [
|
| 42 |
+
("lora", "LTX_lora_loader", "LoRA Loader Stack (turbo)",
|
| 43 |
+
[520, -40], [420, 240], ["minimax", LORA_STACK, ""]),
|
| 44 |
+
("adaln", "H3AdaLNLoRAFix", "H3 AdaLN LoRA Fix",
|
| 45 |
+
[520, 240], [340, 150], ["port"]),
|
| 46 |
+
("lowvram", "MiniMaxLowVRAMAttention", "MiniMax H3 Low VRAM Attention",
|
| 47 |
+
[520, 430], [330, 58], [4]),
|
| 48 |
+
# sparsity_ratio, block_size, min_seq_len, dense_last_steps, protect_audio,
|
| 49 |
+
# enabled, dense_steps, dense_backend, disable_fp16_accum, stabilize_motion,
|
| 50 |
+
# reference_protection -- the last one post-dates the dev workflow's saved
|
| 51 |
+
# values, so it is written out explicitly here.
|
| 52 |
+
("sla", "H3SLAAttention", "H3 SLA Attention",
|
| 53 |
+
[520, 530], [340, 322],
|
| 54 |
+
[0.9, "64", 8192, 0, True, True, "0", "comfy_kitchen", True, True, "Light"]),
|
| 55 |
+
("preview", "ModelPreviewOverrideKJ", "Model Preview Override",
|
| 56 |
+
[520, 900], [360, 480], [512, 80, True, 100, 8, "taeh3.safetensors", ""]),
|
| 57 |
+
]
|
| 58 |
+
FIRST_ID = 30
|
| 59 |
+
|
| 60 |
+
MODEL_PATH = ["UNETLoader"] + [t for _, t, _, _, _, _ in STACK] + ["HandTieClips"]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main():
|
| 64 |
+
for fn in WORKFLOWS:
|
| 65 |
+
p = os.path.join(HERE, "workflows", fn)
|
| 66 |
+
bak = p + ".bak-speed"
|
| 67 |
+
if not os.path.exists(bak):
|
| 68 |
+
shutil.copyfile(p, bak)
|
| 69 |
+
wf = json.load(io.open(p, encoding="utf-8"))
|
| 70 |
+
|
| 71 |
+
# Drop any previous stack and every link that touched it.
|
| 72 |
+
old = {n["id"] for n in wf["nodes"]
|
| 73 |
+
if MARKER in (n.get("properties") or {})}
|
| 74 |
+
wf["nodes"] = [n for n in wf["nodes"] if n["id"] not in old]
|
| 75 |
+
wf["links"] = [l for l in wf["links"]
|
| 76 |
+
if l[1] not in old and l[3] not in old]
|
| 77 |
+
|
| 78 |
+
unet = next(n for n in wf["nodes"] if n["type"] == "UNETLoader")
|
| 79 |
+
clipl = next(n for n in wf["nodes"] if n["type"] == "CLIPLoader")
|
| 80 |
+
chain = next(n for n in wf["nodes"] if n["type"] == "HandTieClips")
|
| 81 |
+
lid = max([l[0] for l in wf["links"]] or [0])
|
| 82 |
+
|
| 83 |
+
# Removing the stack also removed the wires that fed it, so rebuild the
|
| 84 |
+
# plain loader -> chain shape first. That makes this reentrant from
|
| 85 |
+
# either state: a fresh workflow or one already wired.
|
| 86 |
+
wf["links"] = [l for l in wf["links"]
|
| 87 |
+
if not (l[3] == chain["id"] and l[4] in (0, 1))]
|
| 88 |
+
lid += 1
|
| 89 |
+
model_link = [lid, unet["id"], 0, chain["id"], 0, "MODEL"]
|
| 90 |
+
lid += 1
|
| 91 |
+
clip_link = [lid, clipl["id"], 0, chain["id"], 1, "CLIP"]
|
| 92 |
+
wf["links"] += [model_link, clip_link]
|
| 93 |
+
|
| 94 |
+
ids = {}
|
| 95 |
+
for i, (key, ntype, title, pos, size, widgets) in enumerate(STACK):
|
| 96 |
+
ids[key] = FIRST_ID + i
|
| 97 |
+
wf["nodes"].append({
|
| 98 |
+
"id": FIRST_ID + i, "type": ntype,
|
| 99 |
+
"pos": list(pos), "size": list(size),
|
| 100 |
+
"flags": {}, "order": 0, "mode": 0,
|
| 101 |
+
"inputs": [], "outputs": [],
|
| 102 |
+
"title": title,
|
| 103 |
+
"properties": {MARKER: key, "Node name for S&R": ntype},
|
| 104 |
+
"widgets_values": list(widgets),
|
| 105 |
+
})
|
| 106 |
+
byid = {n["id"]: n for n in wf["nodes"]}
|
| 107 |
+
|
| 108 |
+
def link(src, src_slot, dst, dst_slot, kind):
|
| 109 |
+
"""Append one link and record it on both endpoints."""
|
| 110 |
+
nonlocal lid
|
| 111 |
+
lid += 1
|
| 112 |
+
wf["links"].append([lid, src, src_slot, dst, dst_slot, kind])
|
| 113 |
+
return lid
|
| 114 |
+
|
| 115 |
+
# MODEL, straight down the column.
|
| 116 |
+
model_link[3], model_link[4] = ids["lora"], 0
|
| 117 |
+
chain_model = model_link[0]
|
| 118 |
+
prev = ids["lora"]
|
| 119 |
+
for key in ("adaln", "lowvram", "sla", "preview"):
|
| 120 |
+
chain_model = link(prev, 0, ids[key], 0, "MODEL")
|
| 121 |
+
prev = ids[key]
|
| 122 |
+
chain_model = link(prev, 0, chain["id"], 0, "MODEL")
|
| 123 |
+
|
| 124 |
+
# CLIP through the LoRA loader, so the text half of every LoRA lands.
|
| 125 |
+
clip_link[3], clip_link[4] = ids["lora"], 1
|
| 126 |
+
chain_clip = link(ids["lora"], 1, chain["id"], 1, "CLIP")
|
| 127 |
+
|
| 128 |
+
# Sockets and slots, now that every link id exists.
|
| 129 |
+
byid[ids["lora"]]["inputs"] = [
|
| 130 |
+
{"name": "model", "type": "MODEL", "link": model_link[0]},
|
| 131 |
+
{"name": "clip", "type": "CLIP", "link": clip_link[0]},
|
| 132 |
+
]
|
| 133 |
+
for key in ("adaln", "lowvram", "sla", "preview"):
|
| 134 |
+
byid[ids[key]]["inputs"] = [
|
| 135 |
+
{"name": "model", "type": "MODEL", "link": None}]
|
| 136 |
+
byid[ids["preview"]]["inputs"].append(
|
| 137 |
+
{"name": "vae", "type": "VAE", "link": None})
|
| 138 |
+
|
| 139 |
+
outs = {"lora": [("model", "MODEL"), ("clip", "CLIP")],
|
| 140 |
+
"adaln": [("MODEL", "MODEL")],
|
| 141 |
+
"lowvram": [("model", "MODEL")],
|
| 142 |
+
"sla": [("MODEL", "MODEL")],
|
| 143 |
+
"preview": [("MODEL", "MODEL")]}
|
| 144 |
+
for key, spec in outs.items():
|
| 145 |
+
byid[ids[key]]["outputs"] = [
|
| 146 |
+
{"name": nm, "type": ty, "links": []} for nm, ty in spec]
|
| 147 |
+
|
| 148 |
+
# Walk the finished link table back onto the endpoints.
|
| 149 |
+
for n in wf["nodes"]:
|
| 150 |
+
for o in n.get("outputs", []):
|
| 151 |
+
o["links"] = []
|
| 152 |
+
for l in wf["links"]:
|
| 153 |
+
src, sslot, dst, dslot = l[1], l[2], l[3], l[4]
|
| 154 |
+
byid[src]["outputs"][sslot]["links"].append(l[0])
|
| 155 |
+
byid[dst]["inputs"][dslot]["link"] = l[0]
|
| 156 |
+
|
| 157 |
+
wf["last_link_id"] = lid
|
| 158 |
+
wf["last_node_id"] = max(wf["last_node_id"], FIRST_ID + len(STACK) - 1)
|
| 159 |
+
|
| 160 |
+
io.open(p, "w", encoding="utf-8", newline="\n").write(
|
| 161 |
+
json.dumps(wf, indent=2) + "\n")
|
| 162 |
+
print("%s\n MODEL: %s\n CLIP : CLIPLoader -> LoRA Loader Stack -> "
|
| 163 |
+
"Hand Tie Clips (link %d)"
|
| 164 |
+
% (fn, " -> ".join(MODEL_PATH), chain_clip))
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
main()
|
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-ship sanity: walk both shipped workflows' plans through the real code.
|
| 2 |
+
|
| 3 |
+
Not a unit test -- it drives the same helpers `run()` drives, hop by hop, and
|
| 4 |
+
asserts the invariants that have actually broken before:
|
| 5 |
+
|
| 6 |
+
* every `@tag` resolves, and nothing literal survives into the prompt;
|
| 7 |
+
* every `<Picture N>` on a hop is either the live frame or a plate that is
|
| 8 |
+
genuinely scheduled onto that hop;
|
| 9 |
+
* identity text is present on every hop 2+ of a chain with a register (the
|
| 10 |
+
chain_00057 failure);
|
| 11 |
+
* shot 1 carries subject definitions when there is a register, and no later
|
| 12 |
+
shot is a full H3 block;
|
| 13 |
+
* the last shot closes on settle/hold.
|
| 14 |
+
|
| 15 |
+
It also REPORTS, without failing, any `<Subject N>` that reaches a continuation
|
| 16 |
+
hop. `subject_definitions` is hop-1 material and each hop is an independent text
|
| 17 |
+
encode, so such a token has no antecedent in its own conditioning.
|
| 18 |
+
"""
|
| 19 |
+
import io
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import re
|
| 23 |
+
import sys
|
| 24 |
+
import types
|
| 25 |
+
|
| 26 |
+
PACK = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 27 |
+
ROOT = os.path.dirname(os.path.dirname(PACK))
|
| 28 |
+
sys.path.insert(0, ROOT)
|
| 29 |
+
pkg = types.ModuleType("h3p")
|
| 30 |
+
pkg.__path__ = [PACK]
|
| 31 |
+
sys.modules["h3p"] = pkg
|
| 32 |
+
|
| 33 |
+
from h3p import refs as R # noqa: E402
|
| 34 |
+
from h3p import plan as PL # noqa: E402
|
| 35 |
+
from h3p import directives as D # noqa: E402
|
| 36 |
+
from h3p import h3_ref_chain as H3 # noqa: E402
|
| 37 |
+
|
| 38 |
+
FAIL = []
|
| 39 |
+
NOTE = []
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def ck(label, ok, detail=""):
|
| 43 |
+
print(" %s %s%s" % ("ok " if ok else "FAIL", label,
|
| 44 |
+
(" " + detail) if detail else ""))
|
| 45 |
+
if not ok:
|
| 46 |
+
FAIL.append(label)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def widget_map(node):
|
| 50 |
+
names = [i["name"] for i in node["inputs"] if i.get("widget")]
|
| 51 |
+
names.insert(names.index("seed") + 1, "control_after_generate")
|
| 52 |
+
return dict(zip(names, node["widgets_values"]))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
for fn in ("HandTieClips_Starter.json", "HandTieClips_Showcase.json"):
|
| 56 |
+
p = os.path.join(PACK, "workflows", fn)
|
| 57 |
+
wf = json.load(io.open(p, encoding="utf-8"))
|
| 58 |
+
chain = next(n for n in wf["nodes"] if n["type"] == "HandTieClips")
|
| 59 |
+
wv = widget_map(chain)
|
| 60 |
+
print("\n%s" % fn)
|
| 61 |
+
|
| 62 |
+
plan = PL.parse_plan(wv["shot_plan"])
|
| 63 |
+
shots = plan["shots"] if isinstance(plan, dict) else plan
|
| 64 |
+
rp = R.parse_ref_plan(wv["ref_plan"])
|
| 65 |
+
refs, subs = rp["refs"], rp["subjects"]
|
| 66 |
+
wired = {r["slot"] for r in refs if r["file"]}
|
| 67 |
+
has_people = any(r["subject"] is not None for r in refs)
|
| 68 |
+
|
| 69 |
+
ck("shot count == chains", len(shots) == int(wv["chains"]),
|
| 70 |
+
"%d vs %s" % (len(shots), wv["chains"]))
|
| 71 |
+
warn = PL.check_coherence(shots)
|
| 72 |
+
ck("coherence clean", not warn, " | ".join(warn))
|
| 73 |
+
last_tail = (shots[-1].get("directives") or {}).get("tail")
|
| 74 |
+
ck("last shot closes", last_tail in ("settle", "hold"), repr(last_tail))
|
| 75 |
+
|
| 76 |
+
known = {r["tag"] for r in refs}
|
| 77 |
+
for i, shot in enumerate(shots):
|
| 78 |
+
beat = shot.get("beat") or ""
|
| 79 |
+
used = set(re.findall(r"@([A-Za-z0-9_]+)", beat))
|
| 80 |
+
active = R.active_refs(refs, i, wired)
|
| 81 |
+
ords = R.ordinals(active)
|
| 82 |
+
shift = 1 if i > 0 else 0 # live frame takes ordinal 1
|
| 83 |
+
hop_ords = {t: n + shift for t, n in ords.items()}
|
| 84 |
+
legal_pics = set(hop_ords.values()) | ({1} if i > 0 else set())
|
| 85 |
+
|
| 86 |
+
ck("hop %d every tag declared" % (i + 1), used <= known,
|
| 87 |
+
"unknown=%s" % sorted(used - known) if used - known else "")
|
| 88 |
+
try:
|
| 89 |
+
# Mirrors the node: names, not ordinals, from hop 2 on.
|
| 90 |
+
resolved = R.resolve_tags(
|
| 91 |
+
beat, hop_ords, R.subjects(refs), where="shot %d" % (i + 1),
|
| 92 |
+
declared=known,
|
| 93 |
+
subject_names=({k: (v or {}).get("name")
|
| 94 |
+
for k, v in (subs or {}).items()}
|
| 95 |
+
if i > 0 else None))
|
| 96 |
+
except Exception as exc: # noqa: BLE001
|
| 97 |
+
ck("hop %d tags resolve" % (i + 1), False, str(exc))
|
| 98 |
+
continue
|
| 99 |
+
ck("hop %d leaves no literal @tag" % (i + 1),
|
| 100 |
+
not re.search(r"@[A-Za-z0-9_]+", resolved))
|
| 101 |
+
|
| 102 |
+
# Same list tools/check_templates.py enforces. At cfg 1.0 with no
|
| 103 |
+
# negative branch every named concept is ADDED, so "None of the kitchen
|
| 104 |
+
# is visible" puts a kitchen in the encoder, and "she stops" is law 2.
|
| 105 |
+
hits = [w for w in (" no ", " not ", " never ", "n't", "without ",
|
| 106 |
+
"stops ", "stop ", "silent", "silence", "none of ")
|
| 107 |
+
if w in (" " + beat.lower() + " ")]
|
| 108 |
+
ck("hop %d names nothing it wants absent" % (i + 1), not hits,
|
| 109 |
+
str(hits))
|
| 110 |
+
|
| 111 |
+
if i == 0:
|
| 112 |
+
prose = R.subject_prose(active, subs)
|
| 113 |
+
block = (prose + "\n\n" + resolved) if prose else resolved
|
| 114 |
+
if refs:
|
| 115 |
+
ck("hop 1 defines its subjects",
|
| 116 |
+
("subject_definitions:" in prose) if has_people else True,
|
| 117 |
+
"%d chars of prose" % len(prose))
|
| 118 |
+
else:
|
| 119 |
+
id_ords = None
|
| 120 |
+
n_subj = None
|
| 121 |
+
if active:
|
| 122 |
+
id_ords = [hop_ords[r["tag"]] for r in active
|
| 123 |
+
if r["subject"] is not None]
|
| 124 |
+
n_subj = len({r["subject"] for r in active
|
| 125 |
+
if r["subject"] is not None}) or None
|
| 126 |
+
elif refs:
|
| 127 |
+
id_ords = []
|
| 128 |
+
cont = R.continuity_line(
|
| 129 |
+
subs, {r["subject"] for r in refs
|
| 130 |
+
if r["subject"] is not None}) if refs else ""
|
| 131 |
+
block = H3._assemble_next(
|
| 132 |
+
resolved, live_picture=1, live_video=1,
|
| 133 |
+
n_stills=len(active), state_header="",
|
| 134 |
+
identity_ordinals=id_ords, n_subjects=n_subj,
|
| 135 |
+
tail=(shot.get("directives") or {}).get("tail"),
|
| 136 |
+
continuity=cont)
|
| 137 |
+
|
| 138 |
+
pics = {int(m) for m in re.findall(r"<Picture (\d+)>", block)}
|
| 139 |
+
ck("hop %d cites only scheduled plates" % (i + 1),
|
| 140 |
+
pics <= legal_pics,
|
| 141 |
+
"cited=%s legal=%s" % (sorted(pics), sorted(legal_pics)))
|
| 142 |
+
ck("hop %d is not a full H3 block" % (i + 1),
|
| 143 |
+
not D.is_full_h3_prompt(resolved))
|
| 144 |
+
if has_people:
|
| 145 |
+
ck("hop %d carries identity text" % (i + 1), bool(cont.strip()),
|
| 146 |
+
"%d chars" % len(cont))
|
| 147 |
+
dangling = sorted(set(re.findall(r"<Subject (\d+)>", block)))
|
| 148 |
+
if dangling:
|
| 149 |
+
NOTE.append("%s hop %d: <Subject %s> with no definitions block "
|
| 150 |
+
"on this hop" % (fn, i + 1, ">, <Subject ".join(dangling)))
|
| 151 |
+
|
| 152 |
+
print()
|
| 153 |
+
for n in NOTE:
|
| 154 |
+
print("NOTE " + n)
|
| 155 |
+
print()
|
| 156 |
+
if FAIL:
|
| 157 |
+
print("%d FAILURE(S):\n %s" % (len(FAIL), "\n ".join(FAIL)))
|
| 158 |
+
raise SystemExit(1)
|
| 159 |
+
print("SHIP CHECK: all clear (%d note%s)" % (len(NOTE), "" if len(NOTE) == 1 else "s"))
|
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Every shipped template must survive the real Python validators.
|
| 2 |
+
|
| 3 |
+
A template that produced a plan the node rejects would be worse than no
|
| 4 |
+
templates at all -- a first-time author would blame their own writing. This
|
| 5 |
+
extracts the patterns out of templates.js rather than restating them, so the
|
| 6 |
+
test cannot pass against a copy that has drifted.
|
| 7 |
+
"""
|
| 8 |
+
import io
|
| 9 |
+
import json
|
| 10 |
+
import re
|
| 11 |
+
import sys
|
| 12 |
+
import types
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(HERE)))
|
| 17 |
+
pkg = types.ModuleType("h3p")
|
| 18 |
+
pkg.__path__ = [HERE]
|
| 19 |
+
sys.modules["h3p"] = pkg
|
| 20 |
+
|
| 21 |
+
from h3p import plan as PL # noqa: E402
|
| 22 |
+
from h3p import directives as D # noqa: E402
|
| 23 |
+
|
| 24 |
+
SRC = io.open(os.path.join(HERE, "js", "editor", "templates.js"), encoding="utf-8").read()
|
| 25 |
+
|
| 26 |
+
# shot("...beat, possibly + concatenated...", { key: "value", ... })
|
| 27 |
+
# The required leading quote skips `function shot(beat, directives)`, which
|
| 28 |
+
# otherwise matched and swallowed the first pattern's metadata as a beat.
|
| 29 |
+
CALL = re.compile(r"\bshot\(\s*(\".+?)\s*,\s*(\{[^}]*\})\s*\)", re.S)
|
| 30 |
+
STR = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
| 31 |
+
NAME = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\s*:")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def js_string(expr):
|
| 35 |
+
"""Join a run of concatenated double-quoted literals."""
|
| 36 |
+
parts = STR.findall(expr)
|
| 37 |
+
if not parts:
|
| 38 |
+
raise SystemExit("could not read a beat from: " + expr[:60])
|
| 39 |
+
return "".join(p.replace('\\"', '"').replace("\\\\", "\\") for p in parts)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def js_object(expr):
|
| 43 |
+
return json.loads(NAME.sub(r'"\1":', expr))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main():
|
| 47 |
+
names = re.findall(r'name:\s*"([^"]+)"', SRC)
|
| 48 |
+
calls = CALL.findall(SRC)
|
| 49 |
+
print("templates.js: %d patterns, %d shots\n" % (len(names), len(calls)))
|
| 50 |
+
if not names or not calls:
|
| 51 |
+
print("FAIL: extracted nothing -- the file's shape changed")
|
| 52 |
+
return 1
|
| 53 |
+
|
| 54 |
+
fails = []
|
| 55 |
+
all_shots = []
|
| 56 |
+
for beat_expr, dir_expr in calls:
|
| 57 |
+
beat = js_string(beat_expr)
|
| 58 |
+
dirs = js_object(dir_expr)
|
| 59 |
+
all_shots.append({"beat": beat, "directives": dirs})
|
| 60 |
+
|
| 61 |
+
for axis, val in dirs.items():
|
| 62 |
+
if axis not in D.AXES:
|
| 63 |
+
fails.append("unknown axis %r" % axis)
|
| 64 |
+
elif val not in D.VOCAB[axis]:
|
| 65 |
+
fails.append("%s=%r is not in VOCAB" % (axis, val))
|
| 66 |
+
|
| 67 |
+
# Law 1: the prompt is additive, so a negation is never correct.
|
| 68 |
+
for bad in (" no ", " not ", " never ", "n't", "without ", "stops ",
|
| 69 |
+
"stop ", "silent", "silence"):
|
| 70 |
+
if bad in beat.lower():
|
| 71 |
+
fails.append("beat contains %r: %s" % (bad, beat[:70]))
|
| 72 |
+
|
| 73 |
+
# Every pattern must parse as a plan on its own AND stacked together.
|
| 74 |
+
for label, shots in [("each pattern stacked", all_shots)]:
|
| 75 |
+
text = json.dumps({"shots": [
|
| 76 |
+
dict(s, id="s%d" % (i + 1)) for i, s in enumerate(shots)]})
|
| 77 |
+
try:
|
| 78 |
+
parsed = PL.parse_plan(text)
|
| 79 |
+
print(" ok %s parses (%d shots)" % (label, len(parsed)))
|
| 80 |
+
except Exception as e:
|
| 81 |
+
fails.append("%s: %s" % (label, e))
|
| 82 |
+
print(" FAIL %s: %s" % (label, e))
|
| 83 |
+
continue
|
| 84 |
+
warns = PL.check_coherence(parsed)
|
| 85 |
+
if warns:
|
| 86 |
+
print(" FAIL coherence warnings:")
|
| 87 |
+
for w in warns:
|
| 88 |
+
print(" " + w)
|
| 89 |
+
fails.extend(warns)
|
| 90 |
+
else:
|
| 91 |
+
print(" ok no coherence warnings across the whole stack")
|
| 92 |
+
|
| 93 |
+
# The last shot of any pattern that ends a chain must not be left `ongoing`.
|
| 94 |
+
closers = [s for s in all_shots
|
| 95 |
+
if (s["directives"].get("tail") in ("settle", "hold"))]
|
| 96 |
+
print(" ok %d of %d shots close with settle/hold"
|
| 97 |
+
% (len(closers), len(all_shots)))
|
| 98 |
+
|
| 99 |
+
print()
|
| 100 |
+
if fails:
|
| 101 |
+
print("%d FAILURE(S):" % len(fails))
|
| 102 |
+
for f in fails:
|
| 103 |
+
print(" -", f)
|
| 104 |
+
return 1
|
| 105 |
+
print("all template checks passed")
|
| 106 |
+
return 0
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate the two shipped workflows against the live node, not against a guess.
|
| 2 |
+
|
| 3 |
+
Checks the things that actually break on someone else's machine: a node type
|
| 4 |
+
that is not registered, a widget count that disagrees with INPUT_TYPES, a link
|
| 5 |
+
that points at nothing, and a plan that does not survive the real parsers.
|
| 6 |
+
"""
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import types
|
| 12 |
+
|
| 13 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 14 |
+
# ComfyUI root is two levels above the pack. Derived, not hardcoded, so this
|
| 15 |
+
# survives the folder being renamed or the checkout living somewhere else.
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(HERE)))
|
| 17 |
+
pkg = types.ModuleType("h3p")
|
| 18 |
+
pkg.__path__ = [HERE]
|
| 19 |
+
sys.modules["h3p"] = pkg
|
| 20 |
+
|
| 21 |
+
from h3p import refs as R # noqa: E402
|
| 22 |
+
from h3p import plan as PL # noqa: E402
|
| 23 |
+
|
| 24 |
+
# The widget list straight from INPUT_TYPES, so this cannot drift from the node.
|
| 25 |
+
from h3p import h3_ref_chain as H3 # noqa: E402
|
| 26 |
+
|
| 27 |
+
CORE = {"UNETLoader", "CLIPLoader", "VAELoader", "CreateVideo", "SaveVideo",
|
| 28 |
+
"Note", "MarkdownNote"}
|
| 29 |
+
OURS = {"HandTieClips", "HTCChainPreview"}
|
| 30 |
+
# Declared dependencies, not accidents: the turbo stack this node is actually
|
| 31 |
+
# run with. Anything outside these three sets is a pack the reader never asked
|
| 32 |
+
# for and must not appear in a shipped example.
|
| 33 |
+
DEPS = {"LTX_lora_loader": "ComfyUI-PlagueKind-Nodes",
|
| 34 |
+
"H3AdaLNLoRAFix": "ComfyUI-PlagueKind-Nodes",
|
| 35 |
+
"H3SLAAttention": "ComfyUI-PlagueKind-Nodes",
|
| 36 |
+
"MiniMaxLowVRAMAttention": "ComfyUI-KJNodes (experimental)",
|
| 37 |
+
"ModelPreviewOverrideKJ": "ComfyUI-KJNodes"}
|
| 38 |
+
# The MODEL wire, in order, from the loader to the chain.
|
| 39 |
+
MODEL_PATH = ["UNETLoader", "LTX_lora_loader", "H3AdaLNLoRAFix",
|
| 40 |
+
"MiniMaxLowVRAMAttention", "H3SLAAttention",
|
| 41 |
+
"ModelPreviewOverrideKJ", "HandTieClips"]
|
| 42 |
+
# CLIP must reach the chain THROUGH the LoRA loader, or the text half of every
|
| 43 |
+
# LoRA is silently dropped -- a wire that looks fine and costs you the LoRA.
|
| 44 |
+
CLIP_PATH = ["CLIPLoader", "LTX_lora_loader", "HandTieClips"]
|
| 45 |
+
FAIL = []
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def ck(label, ok, detail=""):
|
| 49 |
+
print(" %s %s%s" % ("ok " if ok else "FAIL", label,
|
| 50 |
+
(" " + detail) if detail else ""))
|
| 51 |
+
if not ok:
|
| 52 |
+
FAIL.append(label)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
SOCKET_TYPES = {"MODEL", "CLIP", "VAE", "IMAGE", "AUDIO", "LATENT",
|
| 56 |
+
"CONDITIONING", "VIDEO"}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def widget_names():
|
| 60 |
+
"""The flat widget list, in the order widgets_values is indexed by.
|
| 61 |
+
|
| 62 |
+
A widget is any input whose type is a primitive or a combo AND which is not
|
| 63 |
+
marked `forceInput` -- `continuity_state` is a STRING but arrives on a wire.
|
| 64 |
+
`seed` occupies TWO entries because the frontend appends
|
| 65 |
+
control_after_generate directly after it.
|
| 66 |
+
"""
|
| 67 |
+
it = H3.HandTieClips.INPUT_TYPES()
|
| 68 |
+
names = []
|
| 69 |
+
for section in ("required", "optional"):
|
| 70 |
+
for name, spec in (it.get(section) or {}).items():
|
| 71 |
+
t = spec[0]
|
| 72 |
+
cfg = spec[1] if len(spec) > 1 else {}
|
| 73 |
+
if isinstance(t, str) and t in SOCKET_TYPES:
|
| 74 |
+
continue
|
| 75 |
+
if cfg.get("forceInput"):
|
| 76 |
+
continue
|
| 77 |
+
names.append(name)
|
| 78 |
+
if name == "seed":
|
| 79 |
+
names.append("control_after_generate")
|
| 80 |
+
return names
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def main():
|
| 84 |
+
expect = widget_names()
|
| 85 |
+
print("node declares %d widgets (control_after_generate included)\n"
|
| 86 |
+
% len(expect))
|
| 87 |
+
|
| 88 |
+
for fn in ("HandTieClips_Starter.json", "HandTieClips_Showcase.json"):
|
| 89 |
+
p = os.path.join(HERE, "workflows", fn)
|
| 90 |
+
print(fn)
|
| 91 |
+
wf = json.load(io.open(p, encoding="utf-8"))
|
| 92 |
+
nodes = wf["nodes"]
|
| 93 |
+
byid = {n["id"]: n for n in nodes}
|
| 94 |
+
types_used = {n["type"] for n in nodes}
|
| 95 |
+
|
| 96 |
+
allowed = CORE | OURS | set(DEPS)
|
| 97 |
+
ck("only core, this pack and declared deps", types_used <= allowed,
|
| 98 |
+
str(sorted(types_used - allowed)) if types_used - allowed
|
| 99 |
+
else "%d types" % len(types_used))
|
| 100 |
+
ck("no LoadImage", not any(n["type"] == "LoadImage" for n in nodes))
|
| 101 |
+
|
| 102 |
+
chain = next(n for n in nodes if n["type"] == "HandTieClips")
|
| 103 |
+
|
| 104 |
+
# The wires, walked link by link. A patch node that is present but
|
| 105 |
+
# bypassed round the side is the failure this catches -- it looks right
|
| 106 |
+
# on the canvas and does nothing.
|
| 107 |
+
def walk_wire(path, kind):
|
| 108 |
+
seen = [path[0]]
|
| 109 |
+
cur = next(n for n in nodes if n["type"] == path[0])
|
| 110 |
+
for _ in range(len(path)):
|
| 111 |
+
nxt = [l for l in wf["links"]
|
| 112 |
+
if l[1] == cur["id"] and l[5] == kind]
|
| 113 |
+
if not nxt:
|
| 114 |
+
break
|
| 115 |
+
cur = byid[nxt[0][3]]
|
| 116 |
+
seen.append(cur["type"])
|
| 117 |
+
return seen
|
| 118 |
+
|
| 119 |
+
m = walk_wire(MODEL_PATH, "MODEL")
|
| 120 |
+
ck("MODEL wire runs the whole turbo stack", m == MODEL_PATH,
|
| 121 |
+
" -> ".join(m))
|
| 122 |
+
c = walk_wire(CLIP_PATH, "CLIP")
|
| 123 |
+
ck("CLIP reaches the chain through the LoRA loader", c == CLIP_PATH,
|
| 124 |
+
" -> ".join(c))
|
| 125 |
+
ins = [i["name"] for i in chain["inputs"]]
|
| 126 |
+
dead = [i for i in ins
|
| 127 |
+
if i.startswith("ref_image_") and i != "ref_image_size"
|
| 128 |
+
or i in ("reference_video", "voice", "start_image")]
|
| 129 |
+
ck("no removed sockets", not dead, str(dead))
|
| 130 |
+
ck("widget count matches INPUT_TYPES",
|
| 131 |
+
len(chain["widgets_values"]) == len(expect),
|
| 132 |
+
"%d vs %d" % (len(chain["widgets_values"]), len(expect)))
|
| 133 |
+
|
| 134 |
+
wv = dict(zip(expect, chain["widgets_values"]))
|
| 135 |
+
ck("control_after_generate=fixed", wv["control_after_generate"] == "fixed")
|
| 136 |
+
|
| 137 |
+
# Links: every id referenced by a node must exist, and vice versa.
|
| 138 |
+
lids = {l[0] for l in wf["links"]}
|
| 139 |
+
used = set()
|
| 140 |
+
for n in nodes:
|
| 141 |
+
for i in n.get("inputs", []):
|
| 142 |
+
if i.get("link") is not None:
|
| 143 |
+
used.add(i["link"])
|
| 144 |
+
for o in n.get("outputs", []):
|
| 145 |
+
used.update(o.get("links") or [])
|
| 146 |
+
ck("link table consistent", lids == used,
|
| 147 |
+
"table=%s used=%s" % (sorted(lids), sorted(used)))
|
| 148 |
+
ck("every link endpoint exists",
|
| 149 |
+
all(l[1] in byid and l[3] in byid for l in wf["links"]))
|
| 150 |
+
ck("all four sockets fed",
|
| 151 |
+
all(chain["inputs"][k]["link"] is not None for k in range(4)))
|
| 152 |
+
|
| 153 |
+
# The plans, through the real parsers.
|
| 154 |
+
shots = PL.parse_plan(wv["shot_plan"])
|
| 155 |
+
n_shots = len(shots["shots"]) if isinstance(shots, dict) else len(shots)
|
| 156 |
+
ck("shot plan parses", n_shots == int(wv["chains"]),
|
| 157 |
+
"%d shots, chains=%s" % (n_shots, wv["chains"]))
|
| 158 |
+
|
| 159 |
+
rp = R.parse_ref_plan(wv["ref_plan"])
|
| 160 |
+
refs = rp["refs"]
|
| 161 |
+
ck("ref plan parses", True, "%d refs" % len(refs))
|
| 162 |
+
|
| 163 |
+
# Every @tag in every beat must be declared, or the run dies at hop N.
|
| 164 |
+
declared = {r["tag"] for r in refs}
|
| 165 |
+
import re
|
| 166 |
+
body = [s["beat"] for s in (shots["shots"] if isinstance(shots, dict)
|
| 167 |
+
else shots)]
|
| 168 |
+
tags = set()
|
| 169 |
+
for b in body:
|
| 170 |
+
tags |= set(re.findall(r"@([A-Za-z0-9_]+)", b or ""))
|
| 171 |
+
ck("every @tag is declared", tags <= declared,
|
| 172 |
+
"undeclared=%s" % sorted(tags - declared) if tags - declared
|
| 173 |
+
else "tags=%s" % sorted(tags))
|
| 174 |
+
|
| 175 |
+
# And every declared tag must be active on the hops that use it.
|
| 176 |
+
bad = []
|
| 177 |
+
for hop, b in enumerate(body):
|
| 178 |
+
want = set(re.findall(r"@([A-Za-z0-9_]+)", b or ""))
|
| 179 |
+
active = R.active_refs(refs, hop, {r["slot"] for r in refs
|
| 180 |
+
if r["file"]})
|
| 181 |
+
have = set(R.ordinals(active)) | set(R.subjects(refs))
|
| 182 |
+
miss = want - have
|
| 183 |
+
if miss:
|
| 184 |
+
bad.append("hop %d: %s" % (hop + 1, sorted(miss)))
|
| 185 |
+
ck("every tag is scheduled onto the hops that use it", not bad,
|
| 186 |
+
"; ".join(bad))
|
| 187 |
+
|
| 188 |
+
warn = R.check(rp, {r["slot"] for r in refs if r["file"]})
|
| 189 |
+
ck("no register warnings", not warn, " | ".join(warn))
|
| 190 |
+
|
| 191 |
+
# Stale named values. Both shipped workflows once carried a copy
|
| 192 |
+
# inherited from the dev workflow they were cloned out of, describing a
|
| 193 |
+
# 3x10 s chain with a randomizing seed and references that do not ship.
|
| 194 |
+
# It is dormant while Comfy.Workflow.NamedValuesRestore stays off, which
|
| 195 |
+
# is exactly why it sat there unnoticed.
|
| 196 |
+
named = [n["type"] for n in nodes if "widgets_values_named" in n]
|
| 197 |
+
ck("no stale widgets_values_named", not named, str(sorted(set(named))))
|
| 198 |
+
|
| 199 |
+
# The on-canvas board, where there is one.
|
| 200 |
+
cards = [n for n in nodes if n["type"] == "MarkdownNote"]
|
| 201 |
+
if cards:
|
| 202 |
+
ck("every card is marked and non-empty",
|
| 203 |
+
all((n.get("properties") or {}).get("htc_card")
|
| 204 |
+
and (n.get("widgets_values") or [""])[0].strip()
|
| 205 |
+
for n in cards),
|
| 206 |
+
"%d cards" % len(cards))
|
| 207 |
+
groups = wf.get("groups") or []
|
| 208 |
+
ck("a group wraps the board", len(groups) == 1,
|
| 209 |
+
"%d groups" % len(groups))
|
| 210 |
+
if groups:
|
| 211 |
+
gx, gy, gw, gh = groups[0]["bounding"]
|
| 212 |
+
outside = [n["title"] for n in cards
|
| 213 |
+
if not (gx <= n["pos"][0]
|
| 214 |
+
and gy <= n["pos"][1]
|
| 215 |
+
and n["pos"][0] + n["size"][0] <= gx + gw
|
| 216 |
+
and n["pos"][1] + n["size"][1] <= gy + gh)]
|
| 217 |
+
ck("the group encloses every card", not outside, str(outside))
|
| 218 |
+
# A card reaching past x=0 would sit on top of the loaders.
|
| 219 |
+
ck("the board stays left of the graph",
|
| 220 |
+
all(n["pos"][0] + n["size"][0] <= 0 for n in cards))
|
| 221 |
+
|
| 222 |
+
print(" %d nodes, %d links, %d KB\n"
|
| 223 |
+
% (len(nodes), len(wf["links"]), os.path.getsize(p) // 1024))
|
| 224 |
+
|
| 225 |
+
if FAIL:
|
| 226 |
+
print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
|
| 227 |
+
return 1
|
| 228 |
+
print("ALL PASS")
|
| 229 |
+
return 0
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
if __name__ == "__main__":
|
| 233 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Emit the prompt pack's worked example FROM the showcase workflow.
|
| 2 |
+
|
| 3 |
+
Typing the example out by hand would give the pack a third copy of the same
|
| 4 |
+
plan, free to drift from the one that is actually validated. This reads the
|
| 5 |
+
shipped workflow, so the example and the example workflow are the same bytes.
|
| 6 |
+
"""
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 12 |
+
WF = os.path.join(HERE, "workflows", "HandTieClips_Showcase.json")
|
| 13 |
+
OUT = os.path.join(HERE, "prompt_pack", "EXAMPLE_6_HOP.md")
|
| 14 |
+
|
| 15 |
+
wf = json.load(io.open(WF, encoding="utf-8"))
|
| 16 |
+
# Both ids: the pack registers the pre-rename `HandTieClips` as an alias, so a
|
| 17 |
+
# workflow saved before 2026-08-29 is still a legal input here.
|
| 18 |
+
CHAIN_TYPES = ("HandTieClips", "HandTieClips")
|
| 19 |
+
chain = next(n for n in wf["nodes"] if n["type"] in CHAIN_TYPES)
|
| 20 |
+
names = [i["name"] for i in chain["inputs"] if i.get("widget")]
|
| 21 |
+
names.insert(names.index("seed") + 1, "control_after_generate")
|
| 22 |
+
wv = dict(zip(names, chain["widgets_values"]))
|
| 23 |
+
|
| 24 |
+
shot = json.loads(wv["shot_plan"])
|
| 25 |
+
ref = json.loads(wv["ref_plan"])
|
| 26 |
+
|
| 27 |
+
# The schedule, derived rather than described, so the table cannot lie.
|
| 28 |
+
rows = []
|
| 29 |
+
for i, s in enumerate(shot["shots"], start=1):
|
| 30 |
+
active = [r["tag"] for r in ref["refs"]
|
| 31 |
+
if r.get("shots") is None or i in r["shots"]]
|
| 32 |
+
rows.append("| %d | %s | %s |" % (
|
| 33 |
+
i, s["id"], ", ".join("@" + t for t in active) or "**none**"))
|
| 34 |
+
|
| 35 |
+
body = """# Worked example: six hops, three pictures
|
| 36 |
+
|
| 37 |
+
This is the plan inside `workflows/HandTieClips_Showcase.json`, reproduced here so
|
| 38 |
+
it can be shown to a model as an example of the shape and the reasoning. It is
|
| 39 |
+
generated from that workflow, so the two cannot drift apart.
|
| 40 |
+
|
| 41 |
+
The scene: a cook in a kitchen speaks a line, crosses the room, leaves through a
|
| 42 |
+
doorway into a hallway the register has no picture of, speaks again there, and
|
| 43 |
+
comes back.
|
| 44 |
+
|
| 45 |
+
## What each hop is for
|
| 46 |
+
|
| 47 |
+
| hop | id | references active |
|
| 48 |
+
|---|---|---|
|
| 49 |
+
%s
|
| 50 |
+
|
| 51 |
+
Three things in that table are the whole point:
|
| 52 |
+
|
| 53 |
+
- **Hop 3** carries the kitchen while she is leaving it, and hop 4 does not.
|
| 54 |
+
The hallway is a space no reference describes, so the model must invent it —
|
| 55 |
+
and a kitchen plate riding hop 4 would drag her back into the kitchen.
|
| 56 |
+
- **Hop 4** re-asserts the face. Entering an unseen space is where identity
|
| 57 |
+
drift starts, and re-asserting there is cheaper than recovering on 5 and 6.
|
| 58 |
+
- **Hop 5 has no references at all.** Identity, wardrobe and voice ride on the
|
| 59 |
+
frame pin plus `subjects.1.locked` and `.context` alone. If she is still the
|
| 60 |
+
same person in the same apron with the same voice, the register works.
|
| 61 |
+
|
| 62 |
+
Note also that the dialogue lands on hops 1 and 5 — the establishing shot and
|
| 63 |
+
the one with no pictures — and that both use single quotes.
|
| 64 |
+
|
| 65 |
+
## shot_plan
|
| 66 |
+
|
| 67 |
+
```json
|
| 68 |
+
%s
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## ref_plan
|
| 72 |
+
|
| 73 |
+
```json
|
| 74 |
+
%s
|
| 75 |
+
```
|
| 76 |
+
""" % ("\n".join(rows),
|
| 77 |
+
json.dumps(shot, indent=2, ensure_ascii=False),
|
| 78 |
+
json.dumps(ref, indent=2, ensure_ascii=False))
|
| 79 |
+
|
| 80 |
+
io.open(OUT, "w", encoding="utf-8", newline="\n").write(body)
|
| 81 |
+
print("wrote %s (%d bytes)" % (OUT, len(body)))
|
| 82 |
+
non_ascii = [c for c in body if ord(c) > 127]
|
| 83 |
+
print("non-ASCII in output:", sorted(set(non_ascii)) or "none")
|
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate `prompt_pack/SCHEMA.json` from the node's own constants.
|
| 2 |
+
|
| 3 |
+
A hand-written schema is a second source of truth that starts drifting the day
|
| 4 |
+
someone adds a camera move. This one reads `directives.VOCAB`, `refs.RETENTION`,
|
| 5 |
+
`plan._SHOT_KEYS` and the duration table directly, so it can only ever describe
|
| 6 |
+
the node that is actually installed.
|
| 7 |
+
|
| 8 |
+
python tools/gen_schema.py # write prompt_pack/SCHEMA.json
|
| 9 |
+
python tools/gen_schema.py --check # exit 1 if it is out of date (CI)
|
| 10 |
+
|
| 11 |
+
Run it with ComfyUI's interpreter -- importing the node pulls in torch.
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import io
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import types
|
| 19 |
+
|
| 20 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 21 |
+
OUT = os.path.join(HERE, "prompt_pack", "SCHEMA.json")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load():
|
| 25 |
+
"""Import the pack as a package without needing it to be installed."""
|
| 26 |
+
root = os.path.dirname(os.path.dirname(HERE)) # .../ComfyUI
|
| 27 |
+
if root not in sys.path:
|
| 28 |
+
sys.path.insert(0, root)
|
| 29 |
+
name = "_h3schema"
|
| 30 |
+
pkg = types.ModuleType(name)
|
| 31 |
+
pkg.__path__ = [HERE]
|
| 32 |
+
sys.modules[name] = pkg
|
| 33 |
+
mod = __import__(name + ".directives", fromlist=["directives"])
|
| 34 |
+
d = sys.modules[name + ".directives"]
|
| 35 |
+
r = __import__(name + ".refs", fromlist=["refs"])
|
| 36 |
+
r = sys.modules[name + ".refs"]
|
| 37 |
+
p = __import__(name + ".plan", fromlist=["plan"])
|
| 38 |
+
p = sys.modules[name + ".plan"]
|
| 39 |
+
return d, r, p, mod
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def build():
|
| 43 |
+
d, r, p, _ = _load()
|
| 44 |
+
|
| 45 |
+
# The duration labels, read from the node so the two cannot disagree. The
|
| 46 |
+
# import is late and guarded because it pulls torch in.
|
| 47 |
+
try:
|
| 48 |
+
from _h3schema import h3_ref_chain as node # noqa: F401
|
| 49 |
+
durations = list(node.DURATION_FRAMES)
|
| 50 |
+
frames = dict(node.DURATION_FRAMES)
|
| 51 |
+
except Exception: # pragma: no cover
|
| 52 |
+
durations, frames = [], {}
|
| 53 |
+
|
| 54 |
+
directives = {
|
| 55 |
+
axis: {
|
| 56 |
+
"type": "string",
|
| 57 |
+
"enum": sorted(vals),
|
| 58 |
+
"description": " | ".join(f"{k}: {v}" for k, v in vals.items()),
|
| 59 |
+
}
|
| 60 |
+
for axis, vals in d.VOCAB.items()
|
| 61 |
+
}
|
| 62 |
+
for axis, spec in directives.items():
|
| 63 |
+
if axis in d.DEFAULTS:
|
| 64 |
+
spec["default"] = d.DEFAULTS[axis]
|
| 65 |
+
|
| 66 |
+
shot = {
|
| 67 |
+
"type": "object",
|
| 68 |
+
"required": ["beat"],
|
| 69 |
+
"additionalProperties": False,
|
| 70 |
+
"properties": {
|
| 71 |
+
"id": {"type": "string",
|
| 72 |
+
"description": "Stable name. Generated if absent."},
|
| 73 |
+
"beat": {"type": "string",
|
| 74 |
+
"description": "What happens THIS hop. Shot 1 is the whole "
|
| 75 |
+
"opening; every later shot is only the new "
|
| 76 |
+
"beat."},
|
| 77 |
+
"directives": {
|
| 78 |
+
"type": "object", "additionalProperties": False,
|
| 79 |
+
"properties": directives,
|
| 80 |
+
"description": "Compiled in the order " + ", ".join(d.AXES) +
|
| 81 |
+
". `join` is ignored on shot 1.",
|
| 82 |
+
},
|
| 83 |
+
"prose": {"type": "string",
|
| 84 |
+
"description": "Appended verbatim, for anything the "
|
| 85 |
+
"vocabulary lacks. Phrase it affirmatively."},
|
| 86 |
+
"seed": {"type": "integer"},
|
| 87 |
+
"steps": {"type": "integer"},
|
| 88 |
+
"duration": {"type": "string", "enum": durations},
|
| 89 |
+
"locked": {"type": "boolean",
|
| 90 |
+
"description": "Reuse this hop's cached render even when "
|
| 91 |
+
"its inputs changed. Needs cache_hops=on."},
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
assert set(shot["properties"]) == set(p._SHOT_KEYS), (
|
| 95 |
+
"schema and plan._SHOT_KEYS disagree: "
|
| 96 |
+
f"{sorted(set(shot['properties']) ^ set(p._SHOT_KEYS))}")
|
| 97 |
+
|
| 98 |
+
ref = {
|
| 99 |
+
"type": "object",
|
| 100 |
+
"required": ["tag"],
|
| 101 |
+
"additionalProperties": False,
|
| 102 |
+
"properties": {
|
| 103 |
+
"tag": {"type": "string", "pattern": "^[A-Za-z0-9_]+$",
|
| 104 |
+
"description": "Unique. Written into beats as @tag."},
|
| 105 |
+
"file": {"type": "string",
|
| 106 |
+
"description": "Basename under ComfyUI/input/h3_refs. "
|
| 107 |
+
"Never a path."},
|
| 108 |
+
"slot": {"type": "integer",
|
| 109 |
+
"description": "Derived from list position. Do not author "
|
| 110 |
+
"it; present only for legacy plans."},
|
| 111 |
+
"subject": {"type": "integer", "minimum": 1,
|
| 112 |
+
"description": "Groups pictures OF THE SAME PERSON. Two "
|
| 113 |
+
"different people under one number makes "
|
| 114 |
+
"the model render the average of their "
|
| 115 |
+
"faces."},
|
| 116 |
+
"retention": {"type": "string", "enum": sorted(r.RETENTION),
|
| 117 |
+
"description": " | ".join(f"{k}: {v}" for k, v
|
| 118 |
+
in r.RETENTION.items())},
|
| 119 |
+
"desc": {"type": "string"},
|
| 120 |
+
"shots": {
|
| 121 |
+
"type": "array", "items": {"type": "integer", "minimum": 1},
|
| 122 |
+
"minItems": 1,
|
| 123 |
+
"description": "1-based hops this picture rides on. OMITTING IT "
|
| 124 |
+
"MEANS HOP 1 ONLY on a continuation chain -- a "
|
| 125 |
+
"plate shot elsewhere riding a later hop beats "
|
| 126 |
+
"the frame pin.",
|
| 127 |
+
},
|
| 128 |
+
},
|
| 129 |
+
}
|
| 130 |
+
assert set(ref["properties"]) == set(r.REF_FIELDS), (
|
| 131 |
+
"schema and refs.REF_FIELDS disagree: "
|
| 132 |
+
f"{sorted(set(ref['properties']) ^ set(r.REF_FIELDS))}")
|
| 133 |
+
|
| 134 |
+
subject = {
|
| 135 |
+
"type": "object", "additionalProperties": False,
|
| 136 |
+
"properties": {
|
| 137 |
+
"name": {"type": "string"},
|
| 138 |
+
"locked": {"type": "string",
|
| 139 |
+
"description": "What must not change. This is what "
|
| 140 |
+
"survives on a hop where the photograph "
|
| 141 |
+
"is absent."},
|
| 142 |
+
"context": {"type": "string",
|
| 143 |
+
"description": "Situational state that should persist "
|
| 144 |
+
"-- wardrobe, what they are holding."},
|
| 145 |
+
},
|
| 146 |
+
}
|
| 147 |
+
assert set(subject["properties"]) == set(r.SUBJECT_FIELDS)
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 151 |
+
"title": "H3 Ref Chain plans",
|
| 152 |
+
"description":
|
| 153 |
+
"Two documents. `shot_plan` goes in the shot_plan widget, `ref_plan` "
|
| 154 |
+
"in the ref_plan widget. Generated by tools/gen_schema.py from the "
|
| 155 |
+
"installed node -- do not edit by hand.",
|
| 156 |
+
"type": "object",
|
| 157 |
+
"properties": {
|
| 158 |
+
"shot_plan": {
|
| 159 |
+
"type": "object",
|
| 160 |
+
"required": ["shots"],
|
| 161 |
+
"additionalProperties": False,
|
| 162 |
+
"properties": {
|
| 163 |
+
"shots": {"type": "array", "minItems": 1, "items": shot,
|
| 164 |
+
"description": "One shot per hop. The shot count "
|
| 165 |
+
"IS the hop count."},
|
| 166 |
+
},
|
| 167 |
+
},
|
| 168 |
+
"ref_plan": {
|
| 169 |
+
"type": "object",
|
| 170 |
+
"required": ["refs"],
|
| 171 |
+
"additionalProperties": False,
|
| 172 |
+
"properties": {
|
| 173 |
+
"refs": {"type": "array", "items": ref,
|
| 174 |
+
"maxItems": r.MAX_REF_IMAGES,
|
| 175 |
+
"description": f"At most {r.MAX_REF_IMAGES} "
|
| 176 |
+
f"pictures on any one hop."},
|
| 177 |
+
"subjects": {
|
| 178 |
+
"type": "object",
|
| 179 |
+
"patternProperties": {"^[0-9]+$": subject},
|
| 180 |
+
"additionalProperties": False,
|
| 181 |
+
"description": "Keyed by subject number, as a STRING. "
|
| 182 |
+
"Every subject here must be claimed by "
|
| 183 |
+
"at least one ref, or the plan is "
|
| 184 |
+
"rejected.",
|
| 185 |
+
},
|
| 186 |
+
},
|
| 187 |
+
},
|
| 188 |
+
},
|
| 189 |
+
"x-duration-frames": frames,
|
| 190 |
+
"x-rules": [
|
| 191 |
+
"cfg is 1.0 with no negative branch: the prompt is ADDITIVE. "
|
| 192 |
+
"Anything named is added; nothing can be removed by mentioning it. "
|
| 193 |
+
"Never write a negation.",
|
| 194 |
+
"Never name the thing you want to end. 'stops talking' keeps them "
|
| 195 |
+
"talking. Write the state as a pose plus a sound.",
|
| 196 |
+
"H3 always generates audio. Silence must be written AS a sound -- "
|
| 197 |
+
"room tone, a refrigerator, a single click.",
|
| 198 |
+
"Ambience must be narrowband. 'faint street noise' renders as hiss; "
|
| 199 |
+
"'the low hum of the refrigerator' renders as a refrigerator.",
|
| 200 |
+
"A state change belongs at the END of the previous shot. Every hop "
|
| 201 |
+
"opens holding the frames it was handed.",
|
| 202 |
+
"Shot 1 establishes everything. Later shots carry ONLY the new "
|
| 203 |
+
"beat -- never re-describe the face, the clothes or the room.",
|
| 204 |
+
"Write @tags into the action line. Describing a reference in the "
|
| 205 |
+
"register does not make the model use it.",
|
| 206 |
+
"Set `tail` to settle or hold on the FINAL shot, or the model is "
|
| 207 |
+
"told action is still underway at the last frame and invents "
|
| 208 |
+
"something to satisfy it.",
|
| 209 |
+
],
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def main():
|
| 214 |
+
ap = argparse.ArgumentParser()
|
| 215 |
+
ap.add_argument("--check", action="store_true",
|
| 216 |
+
help="exit 1 if the file on disk is out of date")
|
| 217 |
+
args = ap.parse_args()
|
| 218 |
+
|
| 219 |
+
text = json.dumps(build(), indent=2, ensure_ascii=False) + "\n"
|
| 220 |
+
|
| 221 |
+
if args.check:
|
| 222 |
+
try:
|
| 223 |
+
have = io.open(OUT, encoding="utf-8").read()
|
| 224 |
+
except OSError:
|
| 225 |
+
print("SCHEMA.json is missing")
|
| 226 |
+
return 1
|
| 227 |
+
if have != text:
|
| 228 |
+
print("SCHEMA.json is out of date; run tools/gen_schema.py")
|
| 229 |
+
return 1
|
| 230 |
+
print("SCHEMA.json is current")
|
| 231 |
+
return 0
|
| 232 |
+
|
| 233 |
+
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
| 234 |
+
io.open(OUT, "w", encoding="utf-8", newline="\n").write(text)
|
| 235 |
+
print("wrote %s (%d bytes)" % (OUT, len(text)))
|
| 236 |
+
return 0
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The instruction board that ships on the Starter workflow's canvas.
|
| 2 |
+
|
| 3 |
+
One source for six `MarkdownNote` cards laid out to the left of the loaders.
|
| 4 |
+
`build_notes.py` writes them into `workflows/HandTieClips_Starter.json`.
|
| 5 |
+
|
| 6 |
+
Why on the canvas at all: the craft lives in `PROMPTING.md`, `prompt_pack/` and
|
| 7 |
+
the editor's Templates panel, and all three require leaving the graph. The rules
|
| 8 |
+
that decide whether a first render works are needed at the moment beats are being
|
| 9 |
+
written, which is on the canvas.
|
| 10 |
+
|
| 11 |
+
This is a condensation, not a copy. `PROMPTING.md` stays the long-form authority
|
| 12 |
+
and every card says so; what is here is the part needed to get a first render
|
| 13 |
+
right. Keep the cards short enough to read at a glance -- a wall of text on the
|
| 14 |
+
canvas is the same as no text on the canvas.
|
| 15 |
+
|
| 16 |
+
Layout: two columns of three at x=-960 and x=-480, 440 wide, so the board ends at
|
| 17 |
+
x=-40 and the loaders (which start at x=0) are untouched. Reading order is down
|
| 18 |
+
column A, then down column B.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
YELLOW = ("#432", "#653") # what the existing Note uses
|
| 22 |
+
GREEN = ("#232", "#353") # ComfyUI's green -- card 1 only, so the entry
|
| 23 |
+
# point is unmistakable in a wall of yellow
|
| 24 |
+
|
| 25 |
+
GROUP = {
|
| 26 |
+
"id": 1,
|
| 27 |
+
"title": "READ ME -- writing for this node",
|
| 28 |
+
"bounding": [-990, -465, 980, 2560],
|
| 29 |
+
"color": "#3f789e",
|
| 30 |
+
"font_size": 24,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# `extra.ds` is restored on load rather than fitted, so without this the board
|
| 34 |
+
# sits off-screen to the left and is never found. Screen = (world + offset) *
|
| 35 |
+
# scale, so an offset of 500 puts world x=-500 at the left edge: column B, the
|
| 36 |
+
# loaders and the left of the chain node all in view at once.
|
| 37 |
+
DS = {"scale": 0.8264462809917354, "offset": [500, -281.1386834420914]}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
START_HERE = """\
|
| 41 |
+
## Start here
|
| 42 |
+
|
| 43 |
+
Two hops of 5 s, joined into one 10 s clip.
|
| 44 |
+
|
| 45 |
+
### Requires
|
| 46 |
+
|
| 47 |
+
The MODEL wire is a turbo stack, not a bare loader:
|
| 48 |
+
|
| 49 |
+
```
|
| 50 |
+
UNETLoader
|
| 51 |
+
-> LoRA Loader Stack (turbo LoRA)
|
| 52 |
+
-> H3 AdaLN LoRA Fix
|
| 53 |
+
-> MiniMax H3 Low VRAM Attention
|
| 54 |
+
-> H3 SLA Attention
|
| 55 |
+
-> Model Preview Override
|
| 56 |
+
-> this node
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
| pack | nodes |
|
| 60 |
+
|---|---|
|
| 61 |
+
| **ComfyUI-PlagueKind-Nodes** | LoRA Loader Stack, AdaLN Fix, SLA Attention |
|
| 62 |
+
| **ComfyUI-KJNodes** | Low VRAM Attention (experimental), Model Preview Override |
|
| 63 |
+
|
| 64 |
+
Also on disk: the **turbo LoRA** named in the loader, and `taeh3.safetensors`
|
| 65 |
+
for the live preview (or set `tiny_vae` to `none`).
|
| 66 |
+
|
| 67 |
+
**CLIP goes to this node from the LoRA loader, not from the encoder.** That is
|
| 68 |
+
what makes the text half of every LoRA land. Do not rewire it back.
|
| 69 |
+
|
| 70 |
+
`steps` is **7**, which only works with the turbo LoRA. Missing a pack? Its
|
| 71 |
+
nodes load as red boxes -- delete them, wire the loader straight into `model`
|
| 72 |
+
and the encoder into `clip`, and raise `steps` to 20 or so.
|
| 73 |
+
|
| 74 |
+
1. Point the four **loaders** at your H3 files.
|
| 75 |
+
2. Queue.
|
| 76 |
+
3. Read the **SHOTS** cards on the node.
|
| 77 |
+
|
| 78 |
+
**Shot 1 is the whole opening. Every later shot is only the new beat.** The node
|
| 79 |
+
writes the identity lock, the live-frame citation and the join itself -- so
|
| 80 |
+
re-describing the face, the clothes or the room after shot 1 competes with the
|
| 81 |
+
frame pin instead of reinforcing it.
|
| 82 |
+
|
| 83 |
+
This plan ships with **no references on purpose**, so it runs before you have
|
| 84 |
+
supplied any pictures.
|
| 85 |
+
|
| 86 |
+
### Adding pictures
|
| 87 |
+
|
| 88 |
+
- Open **REFERENCES**, add a row, drop a photo on its thumbnail. Files land in
|
| 89 |
+
`ComfyUI/input/h3_refs`. There is no Load Image node to wire.
|
| 90 |
+
- Give the row a `@tag`, and group photos of the same person under one subject.
|
| 91 |
+
- Write the tag into the beat: `@hero_face stands at the counter in @kitchen`.
|
| 92 |
+
|
| 93 |
+
The long-form guide is `PROMPTING.md` in the pack folder.\
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
RULES = """\
|
| 98 |
+
## The rules that decide whether it works
|
| 99 |
+
|
| 100 |
+
Not style preferences. This is how this model fails.
|
| 101 |
+
|
| 102 |
+
### 1. The prompt is additive
|
| 103 |
+
|
| 104 |
+
Sampling runs at **cfg 1.0 with no negative branch**. Every concept you name is
|
| 105 |
+
added, and nothing can be removed by mentioning it -- `no cut` puts the word
|
| 106 |
+
*cut* in front of the encoder. **Never write a negation.**
|
| 107 |
+
|
| 108 |
+
### 2. Never name the thing you want to end
|
| 109 |
+
|
| 110 |
+
"The cook stops talking" keeps her talking. Write the state you want as **a pose
|
| 111 |
+
plus a sound**:
|
| 112 |
+
|
| 113 |
+
> leans back against the counter with her lips closed, and lets her eyes move
|
| 114 |
+
> slowly across the room. The kitchen is quiet apart from the low hum of the
|
| 115 |
+
> refrigerator.
|
| 116 |
+
|
| 117 |
+
Audio is always generated. Silence written as an absence comes back as speech,
|
| 118 |
+
so **silence has to be written as a sound** -- room tone, a fridge, a single
|
| 119 |
+
click. Keep it narrowband: "faint street noise" renders as a five-second hiss.
|
| 120 |
+
|
| 121 |
+
**The ban is on the idea, not on a word list.** *Fades, passes, wanes, subsides,
|
| 122 |
+
dies down, eases off* all name an ending as surely as *stops* does, and all of
|
| 123 |
+
them add the thing they describe. Ask of each sentence: is this happening, or
|
| 124 |
+
has it finished happening?
|
| 125 |
+
|
| 126 |
+
### 3. A state change belongs at the END of the previous shot
|
| 127 |
+
|
| 128 |
+
Every hop opens holding the frames it was handed, and the audio pin carries the
|
| 129 |
+
previous hop's tail across the join. Nothing you write in shot 3 can make shot 3
|
| 130 |
+
start quiet. **Arrive there before the previous shot ends.**
|
| 131 |
+
|
| 132 |
+
### 4. A hop that ends on dialogue keeps talking
|
| 133 |
+
|
| 134 |
+
Speech at the end of hop N opens hop N+1 and propagates down the whole chain.
|
| 135 |
+
Land each line **mid-hop** and leave a non-verbal action running into the seam --
|
| 136 |
+
slicing, walking, a hand on a doorframe. Give every hop with no dialogue a sound
|
| 137 |
+
bed of its own.
|
| 138 |
+
|
| 139 |
+
### 5. A walk between two rooms is `match_cut`
|
| 140 |
+
|
| 141 |
+
`join: continuous` across a real location change makes the model morph one room
|
| 142 |
+
into the other mid-movement.
|
| 143 |
+
|
| 144 |
+
### 6. Set `tail` on your last shot
|
| 145 |
+
|
| 146 |
+
Left at `ongoing`, the model is told action is still underway at the final frame
|
| 147 |
+
and will invent something to satisfy it. Use `settle` or `hold`.\
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
REFERENCES = """\
|
| 152 |
+
## References and @tags
|
| 153 |
+
|
| 154 |
+
A picture in the register does not make the model use it. **The beat is what
|
| 155 |
+
drives the frame** -- write the tag into the action line.
|
| 156 |
+
|
| 157 |
+
Phrase a place as a place that is *depicted*, not as a container to be placed
|
| 158 |
+
inside: "at the counter in `@kitchen`", not "steps into `@kitchen`".
|
| 159 |
+
|
| 160 |
+
### Fields
|
| 161 |
+
|
| 162 |
+
| field | what it does |
|
| 163 |
+
|---|---|
|
| 164 |
+
| `tag` | what you write in beats as `@tag` |
|
| 165 |
+
| `file` | a bare filename in `input/h3_refs` |
|
| 166 |
+
| `subject` | groups pictures **of the same person** |
|
| 167 |
+
| `retention` | `fully_preserved` / `partially_copy` / `reference` |
|
| 168 |
+
| `shots` | the 1-based hops this picture rides |
|
| 169 |
+
|
| 170 |
+
**One subject number per person.** Two different people under one number makes
|
| 171 |
+
the model render the average of their faces.
|
| 172 |
+
|
| 173 |
+
### `shots` is the part that decides continuity
|
| 174 |
+
|
| 175 |
+
**A reference with no `shots` list rides hop 1 only.** Right for a **place**
|
| 176 |
+
plate: a room still riding a hop set somewhere else beats the frame pin, and the
|
| 177 |
+
model follows the still. **List every hop a picture belongs on.**
|
| 178 |
+
|
| 179 |
+
**A face plate is the opposite — put it on every hop.** A hop scheduled with no
|
| 180 |
+
face reference came back a different person, and no later hop recovered.
|
| 181 |
+
Identity drift does not self-correct.
|
| 182 |
+
|
| 183 |
+
- Every **face** ref gets every hop: `"shots": [1, 2, 3, 4, 5, 6]`.
|
| 184 |
+
- Keep a **place** ref on the hops set in that place, and off the rest.
|
| 185 |
+
|
| 186 |
+
### `locked` and `context`
|
| 187 |
+
|
| 188 |
+
Per-subject prose that rides **every hop**, with no picture citation, so it
|
| 189 |
+
carries identity across a hop where the photograph is absent. Give every subject
|
| 190 |
+
a `locked`, and a `name` -- from hop 2 on, `@tag` for a person resolves to that
|
| 191 |
+
name.
|
| 192 |
+
|
| 193 |
+
**Name a colour or it drifts.** A noun with no adjective is unanchored: each hop
|
| 194 |
+
is an independent encode, so "the bowl" on hop 4 came back stainless steel. Put
|
| 195 |
+
properties in `context` -- "the bowl is white porcelain" -- never locations, and
|
| 196 |
+
repeat the adjective in every beat.\
|
| 197 |
+
"""
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
DIRECTIVES = """\
|
| 201 |
+
## Directives
|
| 202 |
+
|
| 203 |
+
Five axes, all optional, set per shot. An unset axis emits nothing at all and
|
| 204 |
+
costs no tokens.
|
| 205 |
+
|
| 206 |
+
| axis | values |
|
| 207 |
+
|---|---|
|
| 208 |
+
| `join` | `continuous`, `match_cut`, `hard_cut` -- **omit on shot 1** |
|
| 209 |
+
| `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
|
| 210 |
+
| `framing` | `keep`, `wide`, `medium`, `close` |
|
| 211 |
+
| `pace` | `slow`, `steady`, `brisk` |
|
| 212 |
+
| `tail` | `ongoing` (default), `settle`, `hold` |
|
| 213 |
+
|
| 214 |
+
They compile in that order -- `join` first, because it describes how this hop
|
| 215 |
+
meets the previous one.
|
| 216 |
+
|
| 217 |
+
### Two combinations the node warns about
|
| 218 |
+
|
| 219 |
+
**`join: continuous` + a framing change + `camera: hold`.** A framing change asks
|
| 220 |
+
the audience to be somewhere new; with the camera still, the only way there is a
|
| 221 |
+
cut. Earn it on the move (`push_in`, `pull_back`, `pan_follow`) or use
|
| 222 |
+
`framing: keep`.
|
| 223 |
+
|
| 224 |
+
**`push_in` + `wide`, or `pull_back` + `close`.** The move points the opposite way
|
| 225 |
+
from the destination.
|
| 226 |
+
|
| 227 |
+
Both are warnings, not errors. They are legitimate things to want -- they just
|
| 228 |
+
rarely read the way you meant.
|
| 229 |
+
|
| 230 |
+
### Duration
|
| 231 |
+
|
| 232 |
+
Per-shot `duration` overrides the chain, using exactly these labels:
|
| 233 |
+
`"5 s"`, `"7 s"`, `"8 s"`, `"10 s"`, `"15 s"`.\
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
TROUBLE = """\
|
| 238 |
+
## When it goes wrong
|
| 239 |
+
|
| 240 |
+
| symptom | cause | fix |
|
| 241 |
+
|---|---|---|
|
| 242 |
+
| 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 |
|
| 243 |
+
| A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |
|
| 244 |
+
| She keeps talking after you asked for quiet | You named the ending | Pose plus a sound |
|
| 245 |
+
| 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 |
|
| 246 |
+
| A character walks between two rooms and one morphs into the other | `continuous` across a location change | `match_cut` |
|
| 247 |
+
| Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |
|
| 248 |
+
| Ambience is a five-second hiss | Broadband wording | Narrowband, or one discrete event |
|
| 249 |
+
| Two characters' faces merge | Both declared as the same `subject` | One subject number per person |
|
| 250 |
+
| 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 |
|
| 251 |
+
| 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 |
|
| 252 |
+
| The film gets darker every hop | Luminance drifts one way and nothing pushes back | Restate the light as a positive property in every beat |
|
| 253 |
+
| 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 |
|
| 254 |
+
| 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` |
|
| 255 |
+
| 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 |
|
| 256 |
+
| A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |
|
| 257 |
+
| 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 |
|
| 258 |
+
| A reference has no effect on some hop | Its `shots` list leaves that hop out | List every hop the picture should ride |
|
| 259 |
+
| A pasted plan is rejected as invalid JSON | Escaped double quotes mangled in transit | Single quotes around dialogue |
|
| 260 |
+
|
| 261 |
+
Every error message names the shot or the reference it came from. Nothing
|
| 262 |
+
guesses.\
|
| 263 |
+
"""
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
AUTHOR = """\
|
| 267 |
+
## Let a model write your plan
|
| 268 |
+
|
| 269 |
+
`prompt_pack/` in the pack folder turns any chat model into a plan writer. In
|
| 270 |
+
LM Studio, or anything with a system-prompt box:
|
| 271 |
+
|
| 272 |
+
1. Load a model with **context 8192 or more**. The prompt is ~2,600 tokens and
|
| 273 |
+
the reply another 1,000-2,000; a 4k window truncates the rules and you get
|
| 274 |
+
invented directive names.
|
| 275 |
+
2. Paste **`prompt_pack/SYSTEM_PROMPT.md`** into the **System Prompt** box.
|
| 276 |
+
Nothing else goes in that box.
|
| 277 |
+
3. **Temperature 0.3-0.5.** Higher and the JSON grows trailing commas and smart
|
| 278 |
+
quotes.
|
| 279 |
+
4. Describe the scene, and say how many hops and what pictures you have:
|
| 280 |
+
|
| 281 |
+
> Six hops. A cook in a kitchen; she says one line, walks out into a hallway,
|
| 282 |
+
> waits by a window, then comes back. I have a face photo, a photo of her
|
| 283 |
+
> apron, and a photo of the kitchen.
|
| 284 |
+
|
| 285 |
+
5. Each panel section has its own **JSON** disclosure at the bottom. The first
|
| 286 |
+
```json``` block goes in the one under **SCRIPT** (`shot_plan`), the second
|
| 287 |
+
in the one under **REFERENCES** (`ref_plan`). Bad JSON keeps the last good
|
| 288 |
+
version on screen and says so, rather than discarding your paste.
|
| 289 |
+
6. **If the node rejects it, paste the error straight back into the chat.** One
|
| 290 |
+
round trip usually fixes it.
|
| 291 |
+
|
| 292 |
+
Want it to match a shape? Paste `prompt_pack/EXAMPLE_6_HOP.md` first.
|
| 293 |
+
|
| 294 |
+
Small models (7B-8B) hold the JSON schema but drift on the prose rules -- they
|
| 295 |
+
write negations. Skim the beats before queueing.\
|
| 296 |
+
"""
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
SHOWCASE_NOTE = """\
|
| 300 |
+
Hand Tie Clips -- SHOWCASE (6 hops x 7 s = 39.2 s)
|
| 301 |
+
|
| 302 |
+
A continuity stress test, and the honest demonstration of what the reference
|
| 303 |
+
register buys you.
|
| 304 |
+
|
| 305 |
+
REQUIRES a turbo stack on the MODEL wire, which is how this node is actually
|
| 306 |
+
run here:
|
| 307 |
+
|
| 308 |
+
UNETLoader -> LoRA Loader Stack (turbo LoRA) -> H3 AdaLN LoRA Fix
|
| 309 |
+
-> MiniMax H3 Low VRAM Attention -> H3 SLA Attention
|
| 310 |
+
-> Model Preview Override -> this node
|
| 311 |
+
|
| 312 |
+
ComfyUI-PlagueKind-Nodes -> LoRA Loader Stack, AdaLN Fix, SLA Attention
|
| 313 |
+
ComfyUI-KJNodes -> Low VRAM Attention (experimental),
|
| 314 |
+
Model Preview Override
|
| 315 |
+
|
| 316 |
+
On disk as well: the turbo LoRA named in the loader, and taeh3.safetensors for
|
| 317 |
+
the live preview (or set tiny_vae to none).
|
| 318 |
+
|
| 319 |
+
CLIP reaches this node FROM THE LORA LOADER, not from the encoder. That is what
|
| 320 |
+
makes the text half of every LoRA land. Do not rewire it back.
|
| 321 |
+
|
| 322 |
+
`steps` is 7, which only works with the turbo LoRA. Missing a pack? Its nodes
|
| 323 |
+
load as red boxes -- delete them, wire the loader straight into `model` and the
|
| 324 |
+
encoder into `clip`, and raise `steps` to 20 or so.
|
| 325 |
+
|
| 326 |
+
BEFORE YOU RUN IT, supply three pictures. Open REFERENCES on the node and drop
|
| 327 |
+
your own onto each thumbnail, or put files in ComfyUI/input/h3_refs named:
|
| 328 |
+
|
| 329 |
+
ref_face.jpg head-and-shoulders, even light -> @hero_face
|
| 330 |
+
ref_outfit.jpg full length, same person -> @hero_outfit
|
| 331 |
+
ref_room.jpg the room, wide -> @kitchen
|
| 332 |
+
|
| 333 |
+
Without them the run stops and names the reference it could not find. Nothing
|
| 334 |
+
guesses.
|
| 335 |
+
|
| 336 |
+
What each hop is testing:
|
| 337 |
+
|
| 338 |
+
1 kitchen, dialogue all three references active
|
| 339 |
+
2 lateral move to the window kitchen only
|
| 340 |
+
3 exits through the doorway kitchen only
|
| 341 |
+
4 hallway - UNSEEN space face re-asserted; no room reference exists
|
| 342 |
+
5 dialogue, ZERO references identity, wardrobe and voice ride on the frame
|
| 343 |
+
pin plus subjects.1.locked/context alone
|
| 344 |
+
6 returns to the kitchen on a MATCH CUT, not a continuous join -- one
|
| 345 |
+
unbroken take across two rooms makes the model
|
| 346 |
+
morph one into the other mid-movement
|
| 347 |
+
|
| 348 |
+
Hop 5 is the point of the whole thing. If the cook is still the same person in
|
| 349 |
+
the same apron with the same voice, with no picture in front of the encoder,
|
| 350 |
+
the register is doing its job.
|
| 351 |
+
|
| 352 |
+
`shots` on each reference is what schedules this. On a continuation chain,
|
| 353 |
+
omitting `shots` means HOP 1 ONLY. Right for a place plate, wrong for a face:
|
| 354 |
+
put every face reference on every hop, or the identity drifts and stays
|
| 355 |
+
drifted. List every hop a still should appear on.
|
| 356 |
+
|
| 357 |
+
Settings that are deliberate, not defaults:
|
| 358 |
+
control_after_generate = fixed or no two runs are comparable
|
| 359 |
+
tone_compensate = frame_shift counters the drift that accumulates over six
|
| 360 |
+
hops. Set it to `off` and re-queue for a
|
| 361 |
+
same-seed A/B: tone mode is not in the hop
|
| 362 |
+
key, so every hop replays from cache in
|
| 363 |
+
seconds instead of re-rendering.
|
| 364 |
+
cache_hops = on edit shot 4 and only shots 4-6 re-render
|
| 365 |
+
"""
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
CARDS = [
|
| 369 |
+
# key, title, pos, size, colour
|
| 370 |
+
("start", "START HERE", [-960, -400], [440, 940], GREEN, START_HERE),
|
| 371 |
+
("rules", "The rules", [-960, 600], [440, 760], YELLOW, RULES),
|
| 372 |
+
("refs", "References and @tags", [-960, 1420], [440, 640], YELLOW, REFERENCES),
|
| 373 |
+
("directives", "Directives", [-480, -400], [440, 640], YELLOW, DIRECTIVES),
|
| 374 |
+
("trouble", "When it goes wrong", [-480, 280], [440, 760], YELLOW, TROUBLE),
|
| 375 |
+
("author", "Let a model write it", [-480, 1080], [440, 620], YELLOW, AUTHOR),
|
| 376 |
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Measure the brightness step at each seam of a finished master.
|
| 2 |
+
|
| 3 |
+
This is the *after* instrument. `tone_probe.py` reads the hop cache, which
|
| 4 |
+
stores raw pre-correction hops, so it always reports the same raw drift whether
|
| 5 |
+
or not compensation is on -- by design (the mode stays out of the hop key).
|
| 6 |
+
The correction is only visible in the delivered video, which is what this reads.
|
| 7 |
+
|
| 8 |
+
A single file's seam step is contaminated by real content change across the cut:
|
| 9 |
+
the frames either side are ~0.9 s apart in scene time. Pass two masters rendered
|
| 10 |
+
from the same seed and cache and that contamination is identical in both, so the
|
| 11 |
+
DIFFERENCE between them is a clean read on what the correction did.
|
| 12 |
+
|
| 13 |
+
D:\ComfyUI\venv\Scripts\python.exe tools\seam_probe.py --hops 3 before.mp4 after.mp4
|
| 14 |
+
|
| 15 |
+
Seam positions are derived, not guessed: hop 1 contributes its whole length and
|
| 16 |
+
every later hop contributes length - overlap, so
|
| 17 |
+
hop_len = (total_frames + (hops - 1) * overlap) / hops
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import os
|
| 23 |
+
|
| 24 |
+
import av
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def frame_means(path):
|
| 29 |
+
"""Per-frame mean RGB. -> [N,3] float64."""
|
| 30 |
+
out = []
|
| 31 |
+
with av.open(path) as container:
|
| 32 |
+
for frame in container.decode(video=0):
|
| 33 |
+
out.append(frame.to_ndarray(format="rgb24").mean(axis=(0, 1)))
|
| 34 |
+
return np.asarray(out, dtype=np.float64) / 255.0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def seams(total, hops, overlap):
|
| 38 |
+
"""Frame index of the first frame belonging to each later hop."""
|
| 39 |
+
if hops < 2:
|
| 40 |
+
return []
|
| 41 |
+
hop_len = (total + (hops - 1) * overlap) / hops
|
| 42 |
+
return [int(round(hop_len + k * (hop_len - overlap))) for k in range(hops - 1)]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def report(path, hops, overlap, window):
|
| 46 |
+
means = frame_means(path)
|
| 47 |
+
n = len(means)
|
| 48 |
+
cuts = seams(n, hops, overlap)
|
| 49 |
+
print(f"{os.path.basename(path)}: {n}f, seams at {cuts}")
|
| 50 |
+
steps = []
|
| 51 |
+
for k, c in enumerate(cuts, 1):
|
| 52 |
+
a = means[max(0, c - window):c]
|
| 53 |
+
b = means[c:c + window]
|
| 54 |
+
if not len(a) or not len(b):
|
| 55 |
+
print(f" seam {k} @ {c}: too close to an edge, skipped")
|
| 56 |
+
continue
|
| 57 |
+
step = b.mean(axis=0) - a.mean(axis=0)
|
| 58 |
+
luma = float(step.mean())
|
| 59 |
+
steps.append(luma)
|
| 60 |
+
print(f" seam {k} @ {c}: "
|
| 61 |
+
+ " ".join(f"{ch}{v:+.5f}" for ch, v in zip("rgb", step))
|
| 62 |
+
+ f" luma {luma:+.5f} ({luma * 255:+.2f}/255)")
|
| 63 |
+
if steps:
|
| 64 |
+
tot = sum(steps)
|
| 65 |
+
print(f" sum of steps: {tot:+.5f} ({tot * 255:+.2f}/255) "
|
| 66 |
+
f"mean |step| {np.mean(np.abs(steps)) * 255:.2f}/255")
|
| 67 |
+
return steps
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def main():
|
| 71 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 72 |
+
ap.add_argument("files", nargs="+", help="master mp4(s), oldest first")
|
| 73 |
+
ap.add_argument("--hops", type=int, required=True)
|
| 74 |
+
ap.add_argument("--overlap", type=int, default=22)
|
| 75 |
+
ap.add_argument("--window", type=int, default=6,
|
| 76 |
+
help="Frames averaged either side of the cut.")
|
| 77 |
+
args = ap.parse_args()
|
| 78 |
+
|
| 79 |
+
all_steps = []
|
| 80 |
+
for p in args.files:
|
| 81 |
+
if not os.path.isfile(p):
|
| 82 |
+
print(f"{p}: not found")
|
| 83 |
+
return 1
|
| 84 |
+
all_steps.append(report(p, args.hops, args.overlap, args.window))
|
| 85 |
+
print()
|
| 86 |
+
|
| 87 |
+
if len(all_steps) == 2 and all_steps[0] and all_steps[1]:
|
| 88 |
+
print("A/B (same seed + cache, so content change cancels):")
|
| 89 |
+
for k, (a, b) in enumerate(zip(*all_steps), 1):
|
| 90 |
+
print(f" seam {k}: {a * 255:+.2f}/255 -> {b * 255:+.2f}/255 "
|
| 91 |
+
f"({(abs(b) - abs(a)) * 255:+.2f}/255 magnitude)")
|
| 92 |
+
return 0
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Measure the real per-hop tone drift, from the hop cache.
|
| 2 |
+
|
| 3 |
+
Why the cache and not the rendered master: the estimate has to pair hop N's last
|
| 4 |
+
`overlap` frames with hop N+1's *first* `overlap` frames, which are the model's
|
| 5 |
+
regeneration of the same content. The join drops that second copy
|
| 6 |
+
(`h3_ref_chain.py`, `master_imgs[...] = imgs[overlap_n:]`), so it does not exist
|
| 7 |
+
in the output video. It does exist in the hop cache, which stores each hop's
|
| 8 |
+
full pre-trim frames as lossless 16-bit FFV1 -- which makes the cache the only
|
| 9 |
+
honest instrument for this measurement.
|
| 10 |
+
|
| 11 |
+
Comparing frames either side of the seam in a finished master is NOT the same
|
| 12 |
+
measurement: those frames are ~0.9 s apart in scene time, so the number includes
|
| 13 |
+
whatever the scene did in between.
|
| 14 |
+
|
| 15 |
+
Usage, from the pack directory:
|
| 16 |
+
|
| 17 |
+
D:\\ComfyUI\\venv\\Scripts\\python.exe tools\\tone_probe.py [--overlap 22] [--root PATH]
|
| 18 |
+
|
| 19 |
+
ComfyUI wipes `temp/` on startup, so this only sees a cache written by the
|
| 20 |
+
currently running session: render with `cache_hops=on` and probe before you
|
| 21 |
+
restart.
|
| 22 |
+
|
| 23 |
+
Read-only apart from one side effect worth knowing: `HopStore.get()` touches
|
| 24 |
+
each entry's mtime for LRU, so probing marks every hop as recently used.
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import argparse
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import sys
|
| 32 |
+
import types
|
| 33 |
+
|
| 34 |
+
# Derived from this file's position (pack/tools/ -> custom_nodes/pack ->
|
| 35 |
+
# ComfyUI root), so the probe works on a checkout that is not this machine's.
|
| 36 |
+
# Matches the root h3_ref_chain.py builds from folder_paths.get_temp_directory().
|
| 37 |
+
_COMFY_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
| 38 |
+
os.path.dirname(os.path.abspath(__file__)))))
|
| 39 |
+
DEFAULT_ROOT = os.path.join(_COMFY_ROOT, "temp", "h3_ref_chain_hops")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _load_store():
|
| 43 |
+
"""Import the pack's own store.py without installing the pack.
|
| 44 |
+
|
| 45 |
+
The directory has dashes, so it is not a legal module name; register a
|
| 46 |
+
synthetic package pointing at it. Same trick the offline tests use.
|
| 47 |
+
"""
|
| 48 |
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 49 |
+
pkg = types.ModuleType("h3pack")
|
| 50 |
+
pkg.__path__ = [here]
|
| 51 |
+
sys.modules["h3pack"] = pkg
|
| 52 |
+
from h3pack import store # noqa: PLC0415
|
| 53 |
+
return store
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _hops(store, root):
|
| 57 |
+
"""Cached hops as [(hop_index, key)], ordered, skipping unreadable meta."""
|
| 58 |
+
out = []
|
| 59 |
+
for key, _size, _mtime in store.HopStore(root).entries():
|
| 60 |
+
meta_path = os.path.join(root, key + store.META_EXT)
|
| 61 |
+
try:
|
| 62 |
+
with open(meta_path, encoding="utf-8") as fh:
|
| 63 |
+
meta = json.load(fh)
|
| 64 |
+
except (OSError, ValueError) as e:
|
| 65 |
+
print(f" ! {key[:8]}: unreadable meta ({e!r}), skipped")
|
| 66 |
+
continue
|
| 67 |
+
hop = meta.get("hop")
|
| 68 |
+
if hop is None:
|
| 69 |
+
print(f" ! {key[:8]}: meta has no hop index, skipped")
|
| 70 |
+
continue
|
| 71 |
+
out.append((int(hop), key))
|
| 72 |
+
return sorted(out)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def main():
|
| 76 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 77 |
+
ap.add_argument("--root", default=DEFAULT_ROOT)
|
| 78 |
+
ap.add_argument("--overlap", type=int, default=22,
|
| 79 |
+
help="Frames pinned per join. Must match the render.")
|
| 80 |
+
args = ap.parse_args()
|
| 81 |
+
|
| 82 |
+
if not os.path.isdir(args.root):
|
| 83 |
+
print(f"No hop cache at {args.root}.\n"
|
| 84 |
+
f"Render with cache_hops=on and probe before restarting ComfyUI "
|
| 85 |
+
f"(temp/ is wiped on start).")
|
| 86 |
+
return 1
|
| 87 |
+
|
| 88 |
+
store = _load_store()
|
| 89 |
+
import torch # noqa: PLC0415 -- after store, so the venv check fails first
|
| 90 |
+
|
| 91 |
+
hops = _hops(store, args.root)
|
| 92 |
+
if len(hops) < 2:
|
| 93 |
+
print(f"Found {len(hops)} cached hop(s) in {args.root}; need at least 2.")
|
| 94 |
+
return 1
|
| 95 |
+
|
| 96 |
+
print(f"{len(hops)} hops in {args.root}, overlap {args.overlap}f\n")
|
| 97 |
+
st = store.HopStore(args.root)
|
| 98 |
+
ov = int(args.overlap)
|
| 99 |
+
|
| 100 |
+
prev = None
|
| 101 |
+
cumulative = torch.zeros(3)
|
| 102 |
+
for hop, key in hops:
|
| 103 |
+
got = st.get(key)
|
| 104 |
+
if got is None:
|
| 105 |
+
print(f"hop {hop}: entry {key[:8]} incomplete, skipped")
|
| 106 |
+
continue
|
| 107 |
+
imgs = got[0]
|
| 108 |
+
if prev is None:
|
| 109 |
+
print(f"hop {hop} ({key[:8]}): {imgs.shape[0]}f "
|
| 110 |
+
f"mean {float(imgs.mean()):.4f} [reference]")
|
| 111 |
+
prev = imgs
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
n = min(ov, prev.shape[0], imgs.shape[0])
|
| 115 |
+
src = prev[-n:] # what hop N ended on
|
| 116 |
+
tgt = imgs[:n] # hop N+1's regeneration of it
|
| 117 |
+
# Per channel over the matched overlap: this is exactly what
|
| 118 |
+
# tone.compensate's frame_shift mode fits.
|
| 119 |
+
drift = tgt.mean(dim=(0, 1, 2)) - src.mean(dim=(0, 1, 2))
|
| 120 |
+
cumulative = cumulative + drift
|
| 121 |
+
luma = float(drift.mean())
|
| 122 |
+
print(f"hop {hop} ({key[:8]}): {imgs.shape[0]}f "
|
| 123 |
+
f"mean {float(imgs.mean()):.4f}")
|
| 124 |
+
print(f" drift vs hop {hop - 1} over {n}f: "
|
| 125 |
+
+ " ".join(f"{c}{v:+.5f}" for c, v in zip("rgb", drift.tolist()))
|
| 126 |
+
+ f" luma {luma:+.5f} ({luma * 255:+.2f}/255)")
|
| 127 |
+
print(f" cumulative from hop 1: "
|
| 128 |
+
+ " ".join(f"{c}{v:+.5f}" for c, v in zip("rgb", cumulative.tolist()))
|
| 129 |
+
+ f" luma {float(cumulative.mean()):+.5f} "
|
| 130 |
+
f"({float(cumulative.mean()) * 255:+.2f}/255)")
|
| 131 |
+
prev = imgs
|
| 132 |
+
|
| 133 |
+
total = float(cumulative.mean())
|
| 134 |
+
print(f"\nTotal drift across the chain: {total:+.5f} ({total * 255:+.2f}/255)")
|
| 135 |
+
print("Positive = later hops are brighter. A step below ~1/255 is not worth "
|
| 136 |
+
"correcting; the correction itself costs a clamp.")
|
| 137 |
+
return 0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,1213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "h3-ref2va-chain",
|
| 3 |
+
"revision": 1,
|
| 4 |
+
"last_node_id": 34,
|
| 5 |
+
"last_link_id": 18,
|
| 6 |
+
"nodes": [
|
| 7 |
+
{
|
| 8 |
+
"id": 2,
|
| 9 |
+
"type": "UNETLoader",
|
| 10 |
+
"pos": [
|
| 11 |
+
0,
|
| 12 |
+
340
|
| 13 |
+
],
|
| 14 |
+
"size": [
|
| 15 |
+
420,
|
| 16 |
+
82
|
| 17 |
+
],
|
| 18 |
+
"flags": {},
|
| 19 |
+
"order": 0,
|
| 20 |
+
"mode": 0,
|
| 21 |
+
"inputs": [
|
| 22 |
+
{
|
| 23 |
+
"localized_name": "unet_name",
|
| 24 |
+
"name": "unet_name",
|
| 25 |
+
"type": "COMBO",
|
| 26 |
+
"widget": {
|
| 27 |
+
"name": "unet_name"
|
| 28 |
+
},
|
| 29 |
+
"link": null
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"localized_name": "weight_dtype",
|
| 33 |
+
"name": "weight_dtype",
|
| 34 |
+
"type": "COMBO",
|
| 35 |
+
"widget": {
|
| 36 |
+
"name": "weight_dtype"
|
| 37 |
+
},
|
| 38 |
+
"link": null
|
| 39 |
+
}
|
| 40 |
+
],
|
| 41 |
+
"outputs": [
|
| 42 |
+
{
|
| 43 |
+
"localized_name": "MODEL",
|
| 44 |
+
"name": "MODEL",
|
| 45 |
+
"type": "MODEL",
|
| 46 |
+
"slot_index": 0,
|
| 47 |
+
"links": [
|
| 48 |
+
11
|
| 49 |
+
]
|
| 50 |
+
}
|
| 51 |
+
],
|
| 52 |
+
"title": "DiT (ref2va)",
|
| 53 |
+
"properties": {
|
| 54 |
+
"cnr_id": "comfy-core",
|
| 55 |
+
"ver": "0.34.0",
|
| 56 |
+
"Node name for S&R": "UNETLoader"
|
| 57 |
+
},
|
| 58 |
+
"widgets_values": [
|
| 59 |
+
"minimax_h3_hybrid_fl2va_ref2va_b30-49-int8.safetensors",
|
| 60 |
+
"default"
|
| 61 |
+
]
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": 3,
|
| 65 |
+
"type": "CLIPLoader",
|
| 66 |
+
"pos": [
|
| 67 |
+
0,
|
| 68 |
+
460
|
| 69 |
+
],
|
| 70 |
+
"size": [
|
| 71 |
+
420,
|
| 72 |
+
106
|
| 73 |
+
],
|
| 74 |
+
"flags": {},
|
| 75 |
+
"order": 1,
|
| 76 |
+
"mode": 0,
|
| 77 |
+
"inputs": [
|
| 78 |
+
{
|
| 79 |
+
"localized_name": "clip_name",
|
| 80 |
+
"name": "clip_name",
|
| 81 |
+
"type": "COMBO",
|
| 82 |
+
"widget": {
|
| 83 |
+
"name": "clip_name"
|
| 84 |
+
},
|
| 85 |
+
"link": null
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"localized_name": "type",
|
| 89 |
+
"name": "type",
|
| 90 |
+
"type": "COMBO",
|
| 91 |
+
"widget": {
|
| 92 |
+
"name": "type"
|
| 93 |
+
},
|
| 94 |
+
"link": null
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"localized_name": "device",
|
| 98 |
+
"name": "device",
|
| 99 |
+
"shape": 7,
|
| 100 |
+
"type": "COMBO",
|
| 101 |
+
"widget": {
|
| 102 |
+
"name": "device"
|
| 103 |
+
},
|
| 104 |
+
"link": null
|
| 105 |
+
}
|
| 106 |
+
],
|
| 107 |
+
"outputs": [
|
| 108 |
+
{
|
| 109 |
+
"localized_name": "CLIP",
|
| 110 |
+
"name": "CLIP",
|
| 111 |
+
"type": "CLIP",
|
| 112 |
+
"slot_index": 0,
|
| 113 |
+
"links": [
|
| 114 |
+
12
|
| 115 |
+
]
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"title": "Text encoder",
|
| 119 |
+
"properties": {
|
| 120 |
+
"cnr_id": "comfy-core",
|
| 121 |
+
"ver": "0.34.0",
|
| 122 |
+
"Node name for S&R": "CLIPLoader"
|
| 123 |
+
},
|
| 124 |
+
"widgets_values": [
|
| 125 |
+
"qwen3vl_32b_minimax_h3_int8_convrot.safetensors",
|
| 126 |
+
"minimax",
|
| 127 |
+
"default"
|
| 128 |
+
]
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"id": 4,
|
| 132 |
+
"type": "VAELoader",
|
| 133 |
+
"pos": [
|
| 134 |
+
0,
|
| 135 |
+
600
|
| 136 |
+
],
|
| 137 |
+
"size": [
|
| 138 |
+
420,
|
| 139 |
+
82
|
| 140 |
+
],
|
| 141 |
+
"flags": {},
|
| 142 |
+
"order": 2,
|
| 143 |
+
"mode": 0,
|
| 144 |
+
"inputs": [
|
| 145 |
+
{
|
| 146 |
+
"localized_name": "vae_name",
|
| 147 |
+
"name": "vae_name",
|
| 148 |
+
"type": "COMBO",
|
| 149 |
+
"widget": {
|
| 150 |
+
"name": "vae_name"
|
| 151 |
+
},
|
| 152 |
+
"link": null
|
| 153 |
+
}
|
| 154 |
+
],
|
| 155 |
+
"outputs": [
|
| 156 |
+
{
|
| 157 |
+
"localized_name": "VAE",
|
| 158 |
+
"name": "VAE",
|
| 159 |
+
"type": "VAE",
|
| 160 |
+
"slot_index": 0,
|
| 161 |
+
"links": [
|
| 162 |
+
3
|
| 163 |
+
]
|
| 164 |
+
}
|
| 165 |
+
],
|
| 166 |
+
"title": "Video VAE",
|
| 167 |
+
"properties": {
|
| 168 |
+
"cnr_id": "comfy-core",
|
| 169 |
+
"ver": "0.34.0",
|
| 170 |
+
"Node name for S&R": "VAELoader"
|
| 171 |
+
},
|
| 172 |
+
"widgets_values": [
|
| 173 |
+
"minimax_h3_video_vae_int8_convrot.safetensors"
|
| 174 |
+
]
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"id": 5,
|
| 178 |
+
"type": "VAELoader",
|
| 179 |
+
"pos": [
|
| 180 |
+
0,
|
| 181 |
+
720
|
| 182 |
+
],
|
| 183 |
+
"size": [
|
| 184 |
+
420,
|
| 185 |
+
82
|
| 186 |
+
],
|
| 187 |
+
"flags": {},
|
| 188 |
+
"order": 3,
|
| 189 |
+
"mode": 0,
|
| 190 |
+
"inputs": [
|
| 191 |
+
{
|
| 192 |
+
"localized_name": "vae_name",
|
| 193 |
+
"name": "vae_name",
|
| 194 |
+
"type": "COMBO",
|
| 195 |
+
"widget": {
|
| 196 |
+
"name": "vae_name"
|
| 197 |
+
},
|
| 198 |
+
"link": null
|
| 199 |
+
}
|
| 200 |
+
],
|
| 201 |
+
"outputs": [
|
| 202 |
+
{
|
| 203 |
+
"localized_name": "VAE",
|
| 204 |
+
"name": "VAE",
|
| 205 |
+
"type": "VAE",
|
| 206 |
+
"slot_index": 0,
|
| 207 |
+
"links": [
|
| 208 |
+
4
|
| 209 |
+
]
|
| 210 |
+
}
|
| 211 |
+
],
|
| 212 |
+
"title": "Audio VAE",
|
| 213 |
+
"properties": {
|
| 214 |
+
"cnr_id": "comfy-core",
|
| 215 |
+
"ver": "0.34.0",
|
| 216 |
+
"Node name for S&R": "VAELoader"
|
| 217 |
+
},
|
| 218 |
+
"widgets_values": [
|
| 219 |
+
"minimax_h3_audio_vae_fp32.safetensors"
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"id": 7,
|
| 224 |
+
"type": "HandTieClips",
|
| 225 |
+
"pos": [
|
| 226 |
+
994,
|
| 227 |
+
427
|
| 228 |
+
],
|
| 229 |
+
"size": [
|
| 230 |
+
968.3162169696975,
|
| 231 |
+
1426.2006199999998
|
| 232 |
+
],
|
| 233 |
+
"flags": {},
|
| 234 |
+
"order": 4,
|
| 235 |
+
"mode": 0,
|
| 236 |
+
"inputs": [
|
| 237 |
+
{
|
| 238 |
+
"localized_name": "model",
|
| 239 |
+
"name": "model",
|
| 240 |
+
"type": "MODEL",
|
| 241 |
+
"link": 17
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"localized_name": "clip",
|
| 245 |
+
"name": "clip",
|
| 246 |
+
"type": "CLIP",
|
| 247 |
+
"link": 18
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"localized_name": "vae",
|
| 251 |
+
"name": "vae",
|
| 252 |
+
"type": "VAE",
|
| 253 |
+
"link": 3
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"localized_name": "audio_vae",
|
| 257 |
+
"name": "audio_vae",
|
| 258 |
+
"type": "VAE",
|
| 259 |
+
"link": 4
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"localized_name": "continuity_state",
|
| 263 |
+
"name": "continuity_state",
|
| 264 |
+
"shape": 7,
|
| 265 |
+
"type": "STRING",
|
| 266 |
+
"link": null
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"localized_name": "prompt",
|
| 270 |
+
"name": "prompt",
|
| 271 |
+
"type": "STRING",
|
| 272 |
+
"widget": {
|
| 273 |
+
"name": "prompt"
|
| 274 |
+
},
|
| 275 |
+
"link": null
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"localized_name": "chains",
|
| 279 |
+
"name": "chains",
|
| 280 |
+
"type": "COMBO",
|
| 281 |
+
"widget": {
|
| 282 |
+
"name": "chains"
|
| 283 |
+
},
|
| 284 |
+
"link": null
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
"localized_name": "resolution",
|
| 288 |
+
"name": "resolution",
|
| 289 |
+
"type": "COMBO",
|
| 290 |
+
"widget": {
|
| 291 |
+
"name": "resolution"
|
| 292 |
+
},
|
| 293 |
+
"link": null
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"localized_name": "aspect",
|
| 297 |
+
"name": "aspect",
|
| 298 |
+
"type": "COMBO",
|
| 299 |
+
"widget": {
|
| 300 |
+
"name": "aspect"
|
| 301 |
+
},
|
| 302 |
+
"link": null
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"localized_name": "duration",
|
| 306 |
+
"name": "duration",
|
| 307 |
+
"type": "COMBO",
|
| 308 |
+
"widget": {
|
| 309 |
+
"name": "duration"
|
| 310 |
+
},
|
| 311 |
+
"link": null
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"localized_name": "overlap",
|
| 315 |
+
"name": "overlap",
|
| 316 |
+
"type": "COMBO",
|
| 317 |
+
"widget": {
|
| 318 |
+
"name": "overlap"
|
| 319 |
+
},
|
| 320 |
+
"link": null
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"localized_name": "seed",
|
| 324 |
+
"name": "seed",
|
| 325 |
+
"type": "INT",
|
| 326 |
+
"widget": {
|
| 327 |
+
"name": "seed"
|
| 328 |
+
},
|
| 329 |
+
"link": null
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"localized_name": "seed_per_shot",
|
| 333 |
+
"name": "seed_per_shot",
|
| 334 |
+
"type": "BOOLEAN",
|
| 335 |
+
"widget": {
|
| 336 |
+
"name": "seed_per_shot"
|
| 337 |
+
},
|
| 338 |
+
"link": null
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"localized_name": "steps",
|
| 342 |
+
"name": "steps",
|
| 343 |
+
"type": "INT",
|
| 344 |
+
"widget": {
|
| 345 |
+
"name": "steps"
|
| 346 |
+
},
|
| 347 |
+
"link": null
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"localized_name": "sampler_name",
|
| 351 |
+
"name": "sampler_name",
|
| 352 |
+
"type": "COMBO",
|
| 353 |
+
"widget": {
|
| 354 |
+
"name": "sampler_name"
|
| 355 |
+
},
|
| 356 |
+
"link": null
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"localized_name": "scheduler",
|
| 360 |
+
"name": "scheduler",
|
| 361 |
+
"type": "COMBO",
|
| 362 |
+
"widget": {
|
| 363 |
+
"name": "scheduler"
|
| 364 |
+
},
|
| 365 |
+
"link": null
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"localized_name": "shift_video",
|
| 369 |
+
"name": "shift_video",
|
| 370 |
+
"type": "FLOAT",
|
| 371 |
+
"widget": {
|
| 372 |
+
"name": "shift_video"
|
| 373 |
+
},
|
| 374 |
+
"link": null
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"localized_name": "shift_audio",
|
| 378 |
+
"name": "shift_audio",
|
| 379 |
+
"type": "FLOAT",
|
| 380 |
+
"widget": {
|
| 381 |
+
"name": "shift_audio"
|
| 382 |
+
},
|
| 383 |
+
"link": null
|
| 384 |
+
},
|
| 385 |
+
{
|
| 386 |
+
"localized_name": "ref_image_size",
|
| 387 |
+
"name": "ref_image_size",
|
| 388 |
+
"type": "COMBO",
|
| 389 |
+
"widget": {
|
| 390 |
+
"name": "ref_image_size"
|
| 391 |
+
},
|
| 392 |
+
"link": null
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"localized_name": "hop_script",
|
| 396 |
+
"name": "hop_script",
|
| 397 |
+
"shape": 7,
|
| 398 |
+
"type": "COMBO",
|
| 399 |
+
"widget": {
|
| 400 |
+
"name": "hop_script"
|
| 401 |
+
},
|
| 402 |
+
"link": null
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"localized_name": "pin_to_qwen",
|
| 406 |
+
"name": "pin_to_qwen",
|
| 407 |
+
"shape": 7,
|
| 408 |
+
"type": "COMBO",
|
| 409 |
+
"widget": {
|
| 410 |
+
"name": "pin_to_qwen"
|
| 411 |
+
},
|
| 412 |
+
"link": null
|
| 413 |
+
},
|
| 414 |
+
{
|
| 415 |
+
"localized_name": "shot_plan",
|
| 416 |
+
"name": "shot_plan",
|
| 417 |
+
"shape": 7,
|
| 418 |
+
"type": "STRING",
|
| 419 |
+
"widget": {
|
| 420 |
+
"name": "shot_plan"
|
| 421 |
+
},
|
| 422 |
+
"link": null
|
| 423 |
+
},
|
| 424 |
+
{
|
| 425 |
+
"localized_name": "ref_plan",
|
| 426 |
+
"name": "ref_plan",
|
| 427 |
+
"shape": 7,
|
| 428 |
+
"type": "STRING",
|
| 429 |
+
"widget": {
|
| 430 |
+
"name": "ref_plan"
|
| 431 |
+
},
|
| 432 |
+
"link": null
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"localized_name": "cache_hops",
|
| 436 |
+
"name": "cache_hops",
|
| 437 |
+
"shape": 7,
|
| 438 |
+
"type": "COMBO",
|
| 439 |
+
"widget": {
|
| 440 |
+
"name": "cache_hops"
|
| 441 |
+
},
|
| 442 |
+
"link": null
|
| 443 |
+
},
|
| 444 |
+
{
|
| 445 |
+
"localized_name": "cache_budget_gb",
|
| 446 |
+
"name": "cache_budget_gb",
|
| 447 |
+
"shape": 7,
|
| 448 |
+
"type": "FLOAT",
|
| 449 |
+
"widget": {
|
| 450 |
+
"name": "cache_budget_gb"
|
| 451 |
+
},
|
| 452 |
+
"link": null
|
| 453 |
+
},
|
| 454 |
+
{
|
| 455 |
+
"localized_name": "audio_pin_frames",
|
| 456 |
+
"name": "audio_pin_frames",
|
| 457 |
+
"shape": 7,
|
| 458 |
+
"type": "INT",
|
| 459 |
+
"widget": {
|
| 460 |
+
"name": "audio_pin_frames"
|
| 461 |
+
},
|
| 462 |
+
"link": null
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"localized_name": "pin_renorm",
|
| 466 |
+
"name": "pin_renorm",
|
| 467 |
+
"shape": 7,
|
| 468 |
+
"type": "COMBO",
|
| 469 |
+
"widget": {
|
| 470 |
+
"name": "pin_renorm"
|
| 471 |
+
},
|
| 472 |
+
"link": null
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"localized_name": "pin_noise",
|
| 476 |
+
"name": "pin_noise",
|
| 477 |
+
"shape": 7,
|
| 478 |
+
"type": "FLOAT",
|
| 479 |
+
"widget": {
|
| 480 |
+
"name": "pin_noise"
|
| 481 |
+
},
|
| 482 |
+
"link": null
|
| 483 |
+
},
|
| 484 |
+
{
|
| 485 |
+
"localized_name": "tone_compensate",
|
| 486 |
+
"name": "tone_compensate",
|
| 487 |
+
"shape": 7,
|
| 488 |
+
"type": "COMBO",
|
| 489 |
+
"widget": {
|
| 490 |
+
"name": "tone_compensate"
|
| 491 |
+
},
|
| 492 |
+
"link": null
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"localized_name": "start_image_file",
|
| 496 |
+
"name": "start_image_file",
|
| 497 |
+
"shape": 7,
|
| 498 |
+
"type": "STRING",
|
| 499 |
+
"widget": {
|
| 500 |
+
"name": "start_image_file"
|
| 501 |
+
},
|
| 502 |
+
"link": null
|
| 503 |
+
},
|
| 504 |
+
{
|
| 505 |
+
"localized_name": "reference_video_file",
|
| 506 |
+
"name": "reference_video_file",
|
| 507 |
+
"shape": 7,
|
| 508 |
+
"type": "STRING",
|
| 509 |
+
"widget": {
|
| 510 |
+
"name": "reference_video_file"
|
| 511 |
+
},
|
| 512 |
+
"link": null
|
| 513 |
+
},
|
| 514 |
+
{
|
| 515 |
+
"localized_name": "voice_file",
|
| 516 |
+
"name": "voice_file",
|
| 517 |
+
"shape": 7,
|
| 518 |
+
"type": "STRING",
|
| 519 |
+
"widget": {
|
| 520 |
+
"name": "voice_file"
|
| 521 |
+
},
|
| 522 |
+
"link": null
|
| 523 |
+
}
|
| 524 |
+
],
|
| 525 |
+
"outputs": [
|
| 526 |
+
{
|
| 527 |
+
"localized_name": "images",
|
| 528 |
+
"name": "images",
|
| 529 |
+
"type": "IMAGE",
|
| 530 |
+
"slot_index": 0,
|
| 531 |
+
"links": [
|
| 532 |
+
5
|
| 533 |
+
]
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
"localized_name": "audio",
|
| 537 |
+
"name": "audio",
|
| 538 |
+
"type": "AUDIO",
|
| 539 |
+
"slot_index": 1,
|
| 540 |
+
"links": [
|
| 541 |
+
6
|
| 542 |
+
]
|
| 543 |
+
},
|
| 544 |
+
{
|
| 545 |
+
"localized_name": "info",
|
| 546 |
+
"name": "info",
|
| 547 |
+
"type": "STRING",
|
| 548 |
+
"slot_index": 2,
|
| 549 |
+
"links": [
|
| 550 |
+
7
|
| 551 |
+
]
|
| 552 |
+
}
|
| 553 |
+
],
|
| 554 |
+
"properties": {
|
| 555 |
+
"Node name for S&R": "H3RefChain",
|
| 556 |
+
"h3_plan_backup": "{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"Live-action, natural indoor light. The cook stands at the counter in @kitchen, looks up from the chopping board, and speaks one short line to someone off-frame.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"The cook sets the knife down, turns toward the window, and finishes her last word as she looks out.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"push_in\",\n \"framing\": \"close\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s3\",\n \"beat\": \"The cook leans back against the counter with her lips closed, and lets her eyes move slowly across the room. The room is still, with only the low hum of the refrigerator.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pull_back\",\n \"framing\": \"medium\",\n \"pace\": \"slow\",\n \"tail\": \"settle\"\n }\n }\n ]\n}",
|
| 557 |
+
"h3_editor_mode": "shots"
|
| 558 |
+
},
|
| 559 |
+
"widgets_values": [
|
| 560 |
+
"Live-action, natural indoor light. The person looks exactly as in the reference photographs.\n\nThey sit at a table, look up, and speak one short line. Then they settle, watching the room.",
|
| 561 |
+
"6",
|
| 562 |
+
"0.3 MP",
|
| 563 |
+
"16:9 landscape",
|
| 564 |
+
"7 s",
|
| 565 |
+
"0.9 s",
|
| 566 |
+
427058607873049,
|
| 567 |
+
"fixed",
|
| 568 |
+
true,
|
| 569 |
+
7,
|
| 570 |
+
"res_multistep",
|
| 571 |
+
"simple",
|
| 572 |
+
12,
|
| 573 |
+
3,
|
| 574 |
+
"match",
|
| 575 |
+
"next",
|
| 576 |
+
"last frame",
|
| 577 |
+
"{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"@hero_face stands at the counter in @kitchen, a grey apron over a grey t-shirt. She looks up from the chopping board and says, 'You are early. I have barely started.' She turns back to the board and goes on slicing, the knife tapping steadily against the wood.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"She sets the knife down and walks the length of the counter to the window of @kitchen, looking out at the street. Her steps are soft on the tiles and the refrigerator hums behind her.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pan_follow\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s3\",\n \"beat\": \"She turns from the window, crosses @kitchen and pushes through the doorway into the hallway beyond, the room falling away behind her. Her footsteps carry on the tiles.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pan_follow\",\n \"framing\": \"wide\",\n \"pace\": \"brisk\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s4\",\n \"beat\": \"@hero_face walks down a narrow hallway hung with coats, one hand trailing along the wall, her footsteps muffled on the runner. The hallway walls stand close on either side of her and the coats brush past her shoulder.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"handheld\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s5\",\n \"beat\": \"She reaches the window at the end of the hall and rests one hand on the frame, then half turns back over her shoulder and says, 'It is still raining. We will have to do it inside.' She looks back out at the glass with her lips closed, and the rain taps steadily on the pane.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"push_in\",\n \"framing\": \"close\",\n \"pace\": \"slow\",\n \"tail\": \"settle\"\n }\n },\n {\n \"id\": \"s6\",\n \"beat\": \"She walks back along the hallway and through the doorway to the counter in @kitchen, picking the knife up again. The refrigerator hums and the knife starts on the board.\",\n \"directives\": {\n \"join\": \"match_cut\",\n \"camera\": \"pull_back\",\n \"framing\": \"wide\",\n \"pace\": \"steady\",\n \"tail\": \"hold\"\n }\n }\n ]\n}",
|
| 578 |
+
"{\n \"refs\": [\n {\n \"tag\": \"hero_face\",\n \"file\": \"ref_face.jpg\",\n \"subject\": 1,\n \"retention\": \"fully_preserved\",\n \"shots\": [\n 1,\n 2,\n 3,\n 4,\n 5,\n 6\n ],\n \"desc\": \"head-and-shoulders photograph of the cook, even light\"\n },\n {\n \"tag\": \"hero_outfit\",\n \"file\": \"ref_outfit.jpg\",\n \"subject\": 1,\n \"retention\": \"partially_copy\",\n \"shots\": [\n 1\n ],\n \"desc\": \"full-length photograph of the same cook, grey apron over a grey t-shirt\"\n },\n {\n \"tag\": \"kitchen\",\n \"file\": \"ref_room.jpg\",\n \"retention\": \"reference\",\n \"shots\": [\n 1,\n 2,\n 3,\n 6\n ],\n \"desc\": \"the kitchen: counter, window, and the light coming through it\"\n }\n ],\n \"subjects\": {\n \"1\": {\n \"name\": \"the cook\",\n \"locked\": \"the same face, the same short dark hair, the same silver stud earrings\",\n \"context\": \"the grey apron stays tied over the grey t-shirt\"\n }\n }\n}",
|
| 579 |
+
"on",
|
| 580 |
+
19,
|
| 581 |
+
24,
|
| 582 |
+
"off",
|
| 583 |
+
0,
|
| 584 |
+
"frame_shift",
|
| 585 |
+
"",
|
| 586 |
+
"",
|
| 587 |
+
"",
|
| 588 |
+
"Live-action, natural light, one continuous take."
|
| 589 |
+
],
|
| 590 |
+
"title": "H3 Ref2VA Chain - showcase 6x7s"
|
| 591 |
+
},
|
| 592 |
+
{
|
| 593 |
+
"id": 16,
|
| 594 |
+
"type": "HTCChainPreview",
|
| 595 |
+
"pos": [
|
| 596 |
+
2258.827272727274,
|
| 597 |
+
-90.16060606060621
|
| 598 |
+
],
|
| 599 |
+
"size": [
|
| 600 |
+
400,
|
| 601 |
+
460
|
| 602 |
+
],
|
| 603 |
+
"flags": {},
|
| 604 |
+
"order": 5,
|
| 605 |
+
"mode": 0,
|
| 606 |
+
"inputs": [
|
| 607 |
+
{
|
| 608 |
+
"localized_name": "images",
|
| 609 |
+
"name": "images",
|
| 610 |
+
"type": "IMAGE",
|
| 611 |
+
"link": 5
|
| 612 |
+
},
|
| 613 |
+
{
|
| 614 |
+
"localized_name": "audio",
|
| 615 |
+
"name": "audio",
|
| 616 |
+
"shape": 7,
|
| 617 |
+
"type": "AUDIO",
|
| 618 |
+
"link": 6
|
| 619 |
+
},
|
| 620 |
+
{
|
| 621 |
+
"localized_name": "info",
|
| 622 |
+
"name": "info",
|
| 623 |
+
"shape": 7,
|
| 624 |
+
"type": "STRING",
|
| 625 |
+
"link": 7
|
| 626 |
+
}
|
| 627 |
+
],
|
| 628 |
+
"outputs": [
|
| 629 |
+
{
|
| 630 |
+
"localized_name": "images",
|
| 631 |
+
"name": "images",
|
| 632 |
+
"type": "IMAGE",
|
| 633 |
+
"slot_index": 0,
|
| 634 |
+
"links": [
|
| 635 |
+
8
|
| 636 |
+
]
|
| 637 |
+
},
|
| 638 |
+
{
|
| 639 |
+
"localized_name": "audio",
|
| 640 |
+
"name": "audio",
|
| 641 |
+
"type": "AUDIO",
|
| 642 |
+
"slot_index": 1,
|
| 643 |
+
"links": [
|
| 644 |
+
9
|
| 645 |
+
]
|
| 646 |
+
}
|
| 647 |
+
],
|
| 648 |
+
"properties": {
|
| 649 |
+
"Node name for S&R": "H3ChainPreview",
|
| 650 |
+
"h3rcPanelH": 40
|
| 651 |
+
},
|
| 652 |
+
"widgets_values": [
|
| 653 |
+
""
|
| 654 |
+
]
|
| 655 |
+
},
|
| 656 |
+
{
|
| 657 |
+
"id": 8,
|
| 658 |
+
"type": "CreateVideo",
|
| 659 |
+
"pos": [
|
| 660 |
+
2991.7272727272743,
|
| 661 |
+
37.33939393939383
|
| 662 |
+
],
|
| 663 |
+
"size": [
|
| 664 |
+
300,
|
| 665 |
+
130
|
| 666 |
+
],
|
| 667 |
+
"flags": {},
|
| 668 |
+
"order": 6,
|
| 669 |
+
"mode": 0,
|
| 670 |
+
"inputs": [
|
| 671 |
+
{
|
| 672 |
+
"localized_name": "images",
|
| 673 |
+
"name": "images",
|
| 674 |
+
"type": "IMAGE",
|
| 675 |
+
"link": 8
|
| 676 |
+
},
|
| 677 |
+
{
|
| 678 |
+
"localized_name": "audio",
|
| 679 |
+
"name": "audio",
|
| 680 |
+
"shape": 7,
|
| 681 |
+
"type": "AUDIO",
|
| 682 |
+
"link": 9
|
| 683 |
+
},
|
| 684 |
+
{
|
| 685 |
+
"localized_name": "fps",
|
| 686 |
+
"name": "fps",
|
| 687 |
+
"type": "FLOAT",
|
| 688 |
+
"widget": {
|
| 689 |
+
"name": "fps"
|
| 690 |
+
},
|
| 691 |
+
"link": null
|
| 692 |
+
},
|
| 693 |
+
{
|
| 694 |
+
"localized_name": "bit_depth",
|
| 695 |
+
"name": "bit_depth",
|
| 696 |
+
"shape": 7,
|
| 697 |
+
"type": "COMBO",
|
| 698 |
+
"widget": {
|
| 699 |
+
"name": "bit_depth"
|
| 700 |
+
},
|
| 701 |
+
"link": null
|
| 702 |
+
},
|
| 703 |
+
{
|
| 704 |
+
"localized_name": "color_space",
|
| 705 |
+
"name": "color_space",
|
| 706 |
+
"shape": 7,
|
| 707 |
+
"type": "COMBO",
|
| 708 |
+
"widget": {
|
| 709 |
+
"name": "color_space"
|
| 710 |
+
},
|
| 711 |
+
"link": null
|
| 712 |
+
}
|
| 713 |
+
],
|
| 714 |
+
"outputs": [
|
| 715 |
+
{
|
| 716 |
+
"localized_name": "VIDEO",
|
| 717 |
+
"name": "VIDEO",
|
| 718 |
+
"type": "VIDEO",
|
| 719 |
+
"slot_index": 0,
|
| 720 |
+
"links": [
|
| 721 |
+
10
|
| 722 |
+
]
|
| 723 |
+
}
|
| 724 |
+
],
|
| 725 |
+
"properties": {
|
| 726 |
+
"cnr_id": "comfy-core",
|
| 727 |
+
"ver": "0.34.0",
|
| 728 |
+
"Node name for S&R": "CreateVideo"
|
| 729 |
+
},
|
| 730 |
+
"widgets_values": [
|
| 731 |
+
24,
|
| 732 |
+
"auto",
|
| 733 |
+
"sRGB"
|
| 734 |
+
]
|
| 735 |
+
},
|
| 736 |
+
{
|
| 737 |
+
"id": 9,
|
| 738 |
+
"type": "SaveVideo",
|
| 739 |
+
"pos": [
|
| 740 |
+
3032.515151515151,
|
| 741 |
+
714.542424242424
|
| 742 |
+
],
|
| 743 |
+
"size": [
|
| 744 |
+
340,
|
| 745 |
+
106
|
| 746 |
+
],
|
| 747 |
+
"flags": {},
|
| 748 |
+
"order": 7,
|
| 749 |
+
"mode": 0,
|
| 750 |
+
"inputs": [
|
| 751 |
+
{
|
| 752 |
+
"localized_name": "video",
|
| 753 |
+
"name": "video",
|
| 754 |
+
"type": "VIDEO",
|
| 755 |
+
"link": 10
|
| 756 |
+
},
|
| 757 |
+
{
|
| 758 |
+
"localized_name": "filename_prefix",
|
| 759 |
+
"name": "filename_prefix",
|
| 760 |
+
"type": "STRING",
|
| 761 |
+
"widget": {
|
| 762 |
+
"name": "filename_prefix"
|
| 763 |
+
},
|
| 764 |
+
"link": null
|
| 765 |
+
},
|
| 766 |
+
{
|
| 767 |
+
"localized_name": "format",
|
| 768 |
+
"name": "format",
|
| 769 |
+
"type": "COMFY_DYNAMICCOMBO_V3",
|
| 770 |
+
"widget": {
|
| 771 |
+
"name": "format"
|
| 772 |
+
},
|
| 773 |
+
"link": null
|
| 774 |
+
},
|
| 775 |
+
{
|
| 776 |
+
"localized_name": "format.codec",
|
| 777 |
+
"name": "format.codec",
|
| 778 |
+
"type": "COMFY_DYNAMICCOMBO_V3",
|
| 779 |
+
"widget": {
|
| 780 |
+
"name": "format.codec"
|
| 781 |
+
},
|
| 782 |
+
"link": null
|
| 783 |
+
},
|
| 784 |
+
{
|
| 785 |
+
"localized_name": "codec",
|
| 786 |
+
"name": "codec",
|
| 787 |
+
"shape": 7,
|
| 788 |
+
"type": "COMFY_DYNAMICCOMBO_V3",
|
| 789 |
+
"widget": {
|
| 790 |
+
"name": "codec"
|
| 791 |
+
},
|
| 792 |
+
"link": null
|
| 793 |
+
}
|
| 794 |
+
],
|
| 795 |
+
"outputs": [
|
| 796 |
+
{
|
| 797 |
+
"localized_name": "video",
|
| 798 |
+
"name": "video",
|
| 799 |
+
"type": "VIDEO",
|
| 800 |
+
"links": []
|
| 801 |
+
}
|
| 802 |
+
],
|
| 803 |
+
"title": "SAVE",
|
| 804 |
+
"properties": {
|
| 805 |
+
"cnr_id": "comfy-core",
|
| 806 |
+
"ver": "0.34.0",
|
| 807 |
+
"Node name for S&R": "SaveVideo"
|
| 808 |
+
},
|
| 809 |
+
"widgets_values": [
|
| 810 |
+
"video/HANDTIECLIPS/chain",
|
| 811 |
+
"auto",
|
| 812 |
+
"auto",
|
| 813 |
+
"auto"
|
| 814 |
+
]
|
| 815 |
+
},
|
| 816 |
+
{
|
| 817 |
+
"id": 1,
|
| 818 |
+
"type": "Note",
|
| 819 |
+
"pos": [
|
| 820 |
+
-137.49999999999997,
|
| 821 |
+
-359.15000000000015
|
| 822 |
+
],
|
| 823 |
+
"size": [
|
| 824 |
+
440,
|
| 825 |
+
900
|
| 826 |
+
],
|
| 827 |
+
"flags": {},
|
| 828 |
+
"order": 8,
|
| 829 |
+
"mode": 0,
|
| 830 |
+
"inputs": [],
|
| 831 |
+
"outputs": [],
|
| 832 |
+
"title": "H3 Ref2VA Chain",
|
| 833 |
+
"properties": {},
|
| 834 |
+
"widgets_values": [
|
| 835 |
+
"Hand Tie Clips -- SHOWCASE (6 hops x 7 s = 39.2 s)\n\nA continuity stress test, and the honest demonstration of what the reference\nregister buys you.\n\nREQUIRES a turbo stack on the MODEL wire, which is how this node is actually\nrun here:\n\n UNETLoader -> LoRA Loader Stack (turbo LoRA) -> H3 AdaLN LoRA Fix\n -> MiniMax H3 Low VRAM Attention -> H3 SLA Attention\n -> Model Preview Override -> this node\n\n ComfyUI-PlagueKind-Nodes -> LoRA Loader Stack, AdaLN Fix, SLA Attention\n ComfyUI-KJNodes -> Low VRAM Attention (experimental),\n Model Preview Override\n\nOn disk as well: the turbo LoRA named in the loader, and taeh3.safetensors for\nthe live preview (or set tiny_vae to none).\n\nCLIP reaches this node FROM THE LORA LOADER, not from the encoder. That is what\nmakes the text half of every LoRA land. Do not rewire it back.\n\n`steps` is 7, which only works with the turbo LoRA. Missing a pack? Its nodes\nload as red boxes -- delete them, wire the loader straight into `model` and the\nencoder into `clip`, and raise `steps` to 20 or so.\n\nBEFORE YOU RUN IT, supply three pictures. Open REFERENCES on the node and drop\nyour own onto each thumbnail, or put files in ComfyUI/input/h3_refs named:\n\n ref_face.jpg head-and-shoulders, even light -> @hero_face\n ref_outfit.jpg full length, same person -> @hero_outfit\n ref_room.jpg the room, wide -> @kitchen\n\nWithout them the run stops and names the reference it could not find. Nothing\nguesses.\n\nWhat each hop is testing:\n\n 1 kitchen, dialogue all three references active\n 2 lateral move to the window kitchen only\n 3 exits through the doorway kitchen only\n 4 hallway - UNSEEN space face re-asserted; no room reference exists\n 5 dialogue, ZERO references identity, wardrobe and voice ride on the frame\n pin plus subjects.1.locked/context alone\n 6 returns to the kitchen on a MATCH CUT, not a continuous join -- one\n unbroken take across two rooms makes the model\n morph one into the other mid-movement\n\nHop 5 is the point of the whole thing. If the cook is still the same person in\nthe same apron with the same voice, with no picture in front of the encoder,\nthe register is doing its job.\n\n`shots` on each reference is what schedules this. On a continuation chain,\nomitting `shots` means HOP 1 ONLY. Right for a place plate, wrong for a face:\nput every face reference on every hop, or the identity drifts and stays\ndrifted. List every hop a still should appear on.\n\nSettings that are deliberate, not defaults:\n control_after_generate = fixed or no two runs are comparable\n tone_compensate = frame_shift counters the drift that accumulates over six\n hops. Set it to `off` and re-queue for a\n same-seed A/B: tone mode is not in the hop\n key, so every hop replays from cache in\n seconds instead of re-rendering.\n cache_hops = on edit shot 4 and only shots 4-6 re-render\n"
|
| 836 |
+
],
|
| 837 |
+
"color": "#432",
|
| 838 |
+
"bgcolor": "#653"
|
| 839 |
+
},
|
| 840 |
+
{
|
| 841 |
+
"id": 30,
|
| 842 |
+
"type": "LTX_lora_loader",
|
| 843 |
+
"pos": [
|
| 844 |
+
520,
|
| 845 |
+
-40
|
| 846 |
+
],
|
| 847 |
+
"size": [
|
| 848 |
+
420,
|
| 849 |
+
240
|
| 850 |
+
],
|
| 851 |
+
"flags": {},
|
| 852 |
+
"order": 0,
|
| 853 |
+
"mode": 0,
|
| 854 |
+
"inputs": [
|
| 855 |
+
{
|
| 856 |
+
"name": "model",
|
| 857 |
+
"type": "MODEL",
|
| 858 |
+
"link": 11
|
| 859 |
+
},
|
| 860 |
+
{
|
| 861 |
+
"name": "clip",
|
| 862 |
+
"type": "CLIP",
|
| 863 |
+
"link": 12
|
| 864 |
+
}
|
| 865 |
+
],
|
| 866 |
+
"outputs": [
|
| 867 |
+
{
|
| 868 |
+
"name": "model",
|
| 869 |
+
"type": "MODEL",
|
| 870 |
+
"links": [
|
| 871 |
+
13
|
| 872 |
+
]
|
| 873 |
+
},
|
| 874 |
+
{
|
| 875 |
+
"name": "clip",
|
| 876 |
+
"type": "CLIP",
|
| 877 |
+
"links": [
|
| 878 |
+
18
|
| 879 |
+
]
|
| 880 |
+
}
|
| 881 |
+
],
|
| 882 |
+
"title": "LoRA Loader Stack (turbo)",
|
| 883 |
+
"properties": {
|
| 884 |
+
"htc_speed": "lora",
|
| 885 |
+
"Node name for S&R": "LTX_lora_loader"
|
| 886 |
+
},
|
| 887 |
+
"widgets_values": [
|
| 888 |
+
"minimax",
|
| 889 |
+
"[{\"on\":true,\"lora\":\"minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors\",\"str\":1,\"v\":1,\"a\":1,\"t\":1}]",
|
| 890 |
+
""
|
| 891 |
+
]
|
| 892 |
+
},
|
| 893 |
+
{
|
| 894 |
+
"id": 31,
|
| 895 |
+
"type": "H3AdaLNLoRAFix",
|
| 896 |
+
"pos": [
|
| 897 |
+
520,
|
| 898 |
+
240
|
| 899 |
+
],
|
| 900 |
+
"size": [
|
| 901 |
+
340,
|
| 902 |
+
150
|
| 903 |
+
],
|
| 904 |
+
"flags": {},
|
| 905 |
+
"order": 0,
|
| 906 |
+
"mode": 0,
|
| 907 |
+
"inputs": [
|
| 908 |
+
{
|
| 909 |
+
"name": "model",
|
| 910 |
+
"type": "MODEL",
|
| 911 |
+
"link": 13
|
| 912 |
+
}
|
| 913 |
+
],
|
| 914 |
+
"outputs": [
|
| 915 |
+
{
|
| 916 |
+
"name": "MODEL",
|
| 917 |
+
"type": "MODEL",
|
| 918 |
+
"links": [
|
| 919 |
+
14
|
| 920 |
+
]
|
| 921 |
+
}
|
| 922 |
+
],
|
| 923 |
+
"title": "H3 AdaLN LoRA Fix",
|
| 924 |
+
"properties": {
|
| 925 |
+
"htc_speed": "adaln",
|
| 926 |
+
"Node name for S&R": "H3AdaLNLoRAFix"
|
| 927 |
+
},
|
| 928 |
+
"widgets_values": [
|
| 929 |
+
"port"
|
| 930 |
+
]
|
| 931 |
+
},
|
| 932 |
+
{
|
| 933 |
+
"id": 32,
|
| 934 |
+
"type": "MiniMaxLowVRAMAttention",
|
| 935 |
+
"pos": [
|
| 936 |
+
520,
|
| 937 |
+
430
|
| 938 |
+
],
|
| 939 |
+
"size": [
|
| 940 |
+
330,
|
| 941 |
+
58
|
| 942 |
+
],
|
| 943 |
+
"flags": {},
|
| 944 |
+
"order": 0,
|
| 945 |
+
"mode": 0,
|
| 946 |
+
"inputs": [
|
| 947 |
+
{
|
| 948 |
+
"name": "model",
|
| 949 |
+
"type": "MODEL",
|
| 950 |
+
"link": 14
|
| 951 |
+
}
|
| 952 |
+
],
|
| 953 |
+
"outputs": [
|
| 954 |
+
{
|
| 955 |
+
"name": "model",
|
| 956 |
+
"type": "MODEL",
|
| 957 |
+
"links": [
|
| 958 |
+
15
|
| 959 |
+
]
|
| 960 |
+
}
|
| 961 |
+
],
|
| 962 |
+
"title": "MiniMax H3 Low VRAM Attention",
|
| 963 |
+
"properties": {
|
| 964 |
+
"htc_speed": "lowvram",
|
| 965 |
+
"Node name for S&R": "MiniMaxLowVRAMAttention"
|
| 966 |
+
},
|
| 967 |
+
"widgets_values": [
|
| 968 |
+
4
|
| 969 |
+
]
|
| 970 |
+
},
|
| 971 |
+
{
|
| 972 |
+
"id": 33,
|
| 973 |
+
"type": "H3SLAAttention",
|
| 974 |
+
"pos": [
|
| 975 |
+
520,
|
| 976 |
+
530
|
| 977 |
+
],
|
| 978 |
+
"size": [
|
| 979 |
+
340,
|
| 980 |
+
322
|
| 981 |
+
],
|
| 982 |
+
"flags": {},
|
| 983 |
+
"order": 0,
|
| 984 |
+
"mode": 0,
|
| 985 |
+
"inputs": [
|
| 986 |
+
{
|
| 987 |
+
"name": "model",
|
| 988 |
+
"type": "MODEL",
|
| 989 |
+
"link": 15
|
| 990 |
+
}
|
| 991 |
+
],
|
| 992 |
+
"outputs": [
|
| 993 |
+
{
|
| 994 |
+
"name": "MODEL",
|
| 995 |
+
"type": "MODEL",
|
| 996 |
+
"links": [
|
| 997 |
+
16
|
| 998 |
+
]
|
| 999 |
+
}
|
| 1000 |
+
],
|
| 1001 |
+
"title": "H3 SLA Attention",
|
| 1002 |
+
"properties": {
|
| 1003 |
+
"htc_speed": "sla",
|
| 1004 |
+
"Node name for S&R": "H3SLAAttention"
|
| 1005 |
+
},
|
| 1006 |
+
"widgets_values": [
|
| 1007 |
+
0.9,
|
| 1008 |
+
"64",
|
| 1009 |
+
8192,
|
| 1010 |
+
0,
|
| 1011 |
+
true,
|
| 1012 |
+
true,
|
| 1013 |
+
"0",
|
| 1014 |
+
"comfy_kitchen",
|
| 1015 |
+
true,
|
| 1016 |
+
true,
|
| 1017 |
+
"Light"
|
| 1018 |
+
]
|
| 1019 |
+
},
|
| 1020 |
+
{
|
| 1021 |
+
"id": 34,
|
| 1022 |
+
"type": "ModelPreviewOverrideKJ",
|
| 1023 |
+
"pos": [
|
| 1024 |
+
520,
|
| 1025 |
+
900
|
| 1026 |
+
],
|
| 1027 |
+
"size": [
|
| 1028 |
+
360,
|
| 1029 |
+
480
|
| 1030 |
+
],
|
| 1031 |
+
"flags": {},
|
| 1032 |
+
"order": 0,
|
| 1033 |
+
"mode": 0,
|
| 1034 |
+
"inputs": [
|
| 1035 |
+
{
|
| 1036 |
+
"name": "model",
|
| 1037 |
+
"type": "MODEL",
|
| 1038 |
+
"link": 16
|
| 1039 |
+
},
|
| 1040 |
+
{
|
| 1041 |
+
"name": "vae",
|
| 1042 |
+
"type": "VAE",
|
| 1043 |
+
"link": null
|
| 1044 |
+
}
|
| 1045 |
+
],
|
| 1046 |
+
"outputs": [
|
| 1047 |
+
{
|
| 1048 |
+
"name": "MODEL",
|
| 1049 |
+
"type": "MODEL",
|
| 1050 |
+
"links": [
|
| 1051 |
+
17
|
| 1052 |
+
]
|
| 1053 |
+
}
|
| 1054 |
+
],
|
| 1055 |
+
"title": "Model Preview Override",
|
| 1056 |
+
"properties": {
|
| 1057 |
+
"htc_speed": "preview",
|
| 1058 |
+
"Node name for S&R": "ModelPreviewOverrideKJ"
|
| 1059 |
+
},
|
| 1060 |
+
"widgets_values": [
|
| 1061 |
+
512,
|
| 1062 |
+
80,
|
| 1063 |
+
true,
|
| 1064 |
+
100,
|
| 1065 |
+
8,
|
| 1066 |
+
"taeh3.safetensors",
|
| 1067 |
+
""
|
| 1068 |
+
]
|
| 1069 |
+
}
|
| 1070 |
+
],
|
| 1071 |
+
"links": [
|
| 1072 |
+
[
|
| 1073 |
+
3,
|
| 1074 |
+
4,
|
| 1075 |
+
0,
|
| 1076 |
+
7,
|
| 1077 |
+
2,
|
| 1078 |
+
"VAE"
|
| 1079 |
+
],
|
| 1080 |
+
[
|
| 1081 |
+
4,
|
| 1082 |
+
5,
|
| 1083 |
+
0,
|
| 1084 |
+
7,
|
| 1085 |
+
3,
|
| 1086 |
+
"VAE"
|
| 1087 |
+
],
|
| 1088 |
+
[
|
| 1089 |
+
5,
|
| 1090 |
+
7,
|
| 1091 |
+
0,
|
| 1092 |
+
16,
|
| 1093 |
+
0,
|
| 1094 |
+
"IMAGE"
|
| 1095 |
+
],
|
| 1096 |
+
[
|
| 1097 |
+
6,
|
| 1098 |
+
7,
|
| 1099 |
+
1,
|
| 1100 |
+
16,
|
| 1101 |
+
1,
|
| 1102 |
+
"AUDIO"
|
| 1103 |
+
],
|
| 1104 |
+
[
|
| 1105 |
+
7,
|
| 1106 |
+
7,
|
| 1107 |
+
2,
|
| 1108 |
+
16,
|
| 1109 |
+
2,
|
| 1110 |
+
"STRING"
|
| 1111 |
+
],
|
| 1112 |
+
[
|
| 1113 |
+
8,
|
| 1114 |
+
16,
|
| 1115 |
+
0,
|
| 1116 |
+
8,
|
| 1117 |
+
0,
|
| 1118 |
+
"IMAGE"
|
| 1119 |
+
],
|
| 1120 |
+
[
|
| 1121 |
+
9,
|
| 1122 |
+
16,
|
| 1123 |
+
1,
|
| 1124 |
+
8,
|
| 1125 |
+
1,
|
| 1126 |
+
"AUDIO"
|
| 1127 |
+
],
|
| 1128 |
+
[
|
| 1129 |
+
10,
|
| 1130 |
+
8,
|
| 1131 |
+
0,
|
| 1132 |
+
9,
|
| 1133 |
+
0,
|
| 1134 |
+
"VIDEO"
|
| 1135 |
+
],
|
| 1136 |
+
[
|
| 1137 |
+
11,
|
| 1138 |
+
2,
|
| 1139 |
+
0,
|
| 1140 |
+
30,
|
| 1141 |
+
0,
|
| 1142 |
+
"MODEL"
|
| 1143 |
+
],
|
| 1144 |
+
[
|
| 1145 |
+
12,
|
| 1146 |
+
3,
|
| 1147 |
+
0,
|
| 1148 |
+
30,
|
| 1149 |
+
1,
|
| 1150 |
+
"CLIP"
|
| 1151 |
+
],
|
| 1152 |
+
[
|
| 1153 |
+
13,
|
| 1154 |
+
30,
|
| 1155 |
+
0,
|
| 1156 |
+
31,
|
| 1157 |
+
0,
|
| 1158 |
+
"MODEL"
|
| 1159 |
+
],
|
| 1160 |
+
[
|
| 1161 |
+
14,
|
| 1162 |
+
31,
|
| 1163 |
+
0,
|
| 1164 |
+
32,
|
| 1165 |
+
0,
|
| 1166 |
+
"MODEL"
|
| 1167 |
+
],
|
| 1168 |
+
[
|
| 1169 |
+
15,
|
| 1170 |
+
32,
|
| 1171 |
+
0,
|
| 1172 |
+
33,
|
| 1173 |
+
0,
|
| 1174 |
+
"MODEL"
|
| 1175 |
+
],
|
| 1176 |
+
[
|
| 1177 |
+
16,
|
| 1178 |
+
33,
|
| 1179 |
+
0,
|
| 1180 |
+
34,
|
| 1181 |
+
0,
|
| 1182 |
+
"MODEL"
|
| 1183 |
+
],
|
| 1184 |
+
[
|
| 1185 |
+
17,
|
| 1186 |
+
34,
|
| 1187 |
+
0,
|
| 1188 |
+
7,
|
| 1189 |
+
0,
|
| 1190 |
+
"MODEL"
|
| 1191 |
+
],
|
| 1192 |
+
[
|
| 1193 |
+
18,
|
| 1194 |
+
30,
|
| 1195 |
+
1,
|
| 1196 |
+
7,
|
| 1197 |
+
1,
|
| 1198 |
+
"CLIP"
|
| 1199 |
+
]
|
| 1200 |
+
],
|
| 1201 |
+
"groups": [],
|
| 1202 |
+
"config": {},
|
| 1203 |
+
"extra": {
|
| 1204 |
+
"ds": {
|
| 1205 |
+
"scale": 0.8264462809917354,
|
| 1206 |
+
"offset": [
|
| 1207 |
+
76.11091610712583,
|
| 1208 |
+
-281.1386834420914
|
| 1209 |
+
]
|
| 1210 |
+
}
|
| 1211 |
+
},
|
| 1212 |
+
"version": 0.4
|
| 1213 |
+
}
|
|
@@ -0,0 +1,1358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "h3-ref2va-chain",
|
| 3 |
+
"revision": 1,
|
| 4 |
+
"last_node_id": 34,
|
| 5 |
+
"last_link_id": 18,
|
| 6 |
+
"nodes": [
|
| 7 |
+
{
|
| 8 |
+
"id": 2,
|
| 9 |
+
"type": "UNETLoader",
|
| 10 |
+
"pos": [
|
| 11 |
+
0,
|
| 12 |
+
340
|
| 13 |
+
],
|
| 14 |
+
"size": [
|
| 15 |
+
420,
|
| 16 |
+
82
|
| 17 |
+
],
|
| 18 |
+
"flags": {},
|
| 19 |
+
"order": 0,
|
| 20 |
+
"mode": 0,
|
| 21 |
+
"inputs": [
|
| 22 |
+
{
|
| 23 |
+
"localized_name": "unet_name",
|
| 24 |
+
"name": "unet_name",
|
| 25 |
+
"type": "COMBO",
|
| 26 |
+
"widget": {
|
| 27 |
+
"name": "unet_name"
|
| 28 |
+
},
|
| 29 |
+
"link": null
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"localized_name": "weight_dtype",
|
| 33 |
+
"name": "weight_dtype",
|
| 34 |
+
"type": "COMBO",
|
| 35 |
+
"widget": {
|
| 36 |
+
"name": "weight_dtype"
|
| 37 |
+
},
|
| 38 |
+
"link": null
|
| 39 |
+
}
|
| 40 |
+
],
|
| 41 |
+
"outputs": [
|
| 42 |
+
{
|
| 43 |
+
"localized_name": "MODEL",
|
| 44 |
+
"name": "MODEL",
|
| 45 |
+
"type": "MODEL",
|
| 46 |
+
"slot_index": 0,
|
| 47 |
+
"links": [
|
| 48 |
+
11
|
| 49 |
+
]
|
| 50 |
+
}
|
| 51 |
+
],
|
| 52 |
+
"title": "DiT (ref2va)",
|
| 53 |
+
"properties": {
|
| 54 |
+
"cnr_id": "comfy-core",
|
| 55 |
+
"ver": "0.34.0",
|
| 56 |
+
"Node name for S&R": "UNETLoader"
|
| 57 |
+
},
|
| 58 |
+
"widgets_values": [
|
| 59 |
+
"minimax_h3_hybrid_fl2va_ref2va_b30-49-int8.safetensors",
|
| 60 |
+
"default"
|
| 61 |
+
]
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": 3,
|
| 65 |
+
"type": "CLIPLoader",
|
| 66 |
+
"pos": [
|
| 67 |
+
0,
|
| 68 |
+
460
|
| 69 |
+
],
|
| 70 |
+
"size": [
|
| 71 |
+
420,
|
| 72 |
+
106
|
| 73 |
+
],
|
| 74 |
+
"flags": {},
|
| 75 |
+
"order": 1,
|
| 76 |
+
"mode": 0,
|
| 77 |
+
"inputs": [
|
| 78 |
+
{
|
| 79 |
+
"localized_name": "clip_name",
|
| 80 |
+
"name": "clip_name",
|
| 81 |
+
"type": "COMBO",
|
| 82 |
+
"widget": {
|
| 83 |
+
"name": "clip_name"
|
| 84 |
+
},
|
| 85 |
+
"link": null
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"localized_name": "type",
|
| 89 |
+
"name": "type",
|
| 90 |
+
"type": "COMBO",
|
| 91 |
+
"widget": {
|
| 92 |
+
"name": "type"
|
| 93 |
+
},
|
| 94 |
+
"link": null
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"localized_name": "device",
|
| 98 |
+
"name": "device",
|
| 99 |
+
"shape": 7,
|
| 100 |
+
"type": "COMBO",
|
| 101 |
+
"widget": {
|
| 102 |
+
"name": "device"
|
| 103 |
+
},
|
| 104 |
+
"link": null
|
| 105 |
+
}
|
| 106 |
+
],
|
| 107 |
+
"outputs": [
|
| 108 |
+
{
|
| 109 |
+
"localized_name": "CLIP",
|
| 110 |
+
"name": "CLIP",
|
| 111 |
+
"type": "CLIP",
|
| 112 |
+
"slot_index": 0,
|
| 113 |
+
"links": [
|
| 114 |
+
12
|
| 115 |
+
]
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"title": "Text encoder",
|
| 119 |
+
"properties": {
|
| 120 |
+
"cnr_id": "comfy-core",
|
| 121 |
+
"ver": "0.34.0",
|
| 122 |
+
"Node name for S&R": "CLIPLoader"
|
| 123 |
+
},
|
| 124 |
+
"widgets_values": [
|
| 125 |
+
"qwen3vl_32b_minimax_h3_int8_convrot.safetensors",
|
| 126 |
+
"minimax",
|
| 127 |
+
"default"
|
| 128 |
+
]
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"id": 4,
|
| 132 |
+
"type": "VAELoader",
|
| 133 |
+
"pos": [
|
| 134 |
+
0,
|
| 135 |
+
600
|
| 136 |
+
],
|
| 137 |
+
"size": [
|
| 138 |
+
420,
|
| 139 |
+
82
|
| 140 |
+
],
|
| 141 |
+
"flags": {},
|
| 142 |
+
"order": 2,
|
| 143 |
+
"mode": 0,
|
| 144 |
+
"inputs": [
|
| 145 |
+
{
|
| 146 |
+
"localized_name": "vae_name",
|
| 147 |
+
"name": "vae_name",
|
| 148 |
+
"type": "COMBO",
|
| 149 |
+
"widget": {
|
| 150 |
+
"name": "vae_name"
|
| 151 |
+
},
|
| 152 |
+
"link": null
|
| 153 |
+
}
|
| 154 |
+
],
|
| 155 |
+
"outputs": [
|
| 156 |
+
{
|
| 157 |
+
"localized_name": "VAE",
|
| 158 |
+
"name": "VAE",
|
| 159 |
+
"type": "VAE",
|
| 160 |
+
"slot_index": 0,
|
| 161 |
+
"links": [
|
| 162 |
+
3
|
| 163 |
+
]
|
| 164 |
+
}
|
| 165 |
+
],
|
| 166 |
+
"title": "Video VAE",
|
| 167 |
+
"properties": {
|
| 168 |
+
"cnr_id": "comfy-core",
|
| 169 |
+
"ver": "0.34.0",
|
| 170 |
+
"Node name for S&R": "VAELoader"
|
| 171 |
+
},
|
| 172 |
+
"widgets_values": [
|
| 173 |
+
"minimax_h3_video_vae_int8_convrot.safetensors"
|
| 174 |
+
]
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"id": 5,
|
| 178 |
+
"type": "VAELoader",
|
| 179 |
+
"pos": [
|
| 180 |
+
0,
|
| 181 |
+
720
|
| 182 |
+
],
|
| 183 |
+
"size": [
|
| 184 |
+
420,
|
| 185 |
+
82
|
| 186 |
+
],
|
| 187 |
+
"flags": {},
|
| 188 |
+
"order": 3,
|
| 189 |
+
"mode": 0,
|
| 190 |
+
"inputs": [
|
| 191 |
+
{
|
| 192 |
+
"localized_name": "vae_name",
|
| 193 |
+
"name": "vae_name",
|
| 194 |
+
"type": "COMBO",
|
| 195 |
+
"widget": {
|
| 196 |
+
"name": "vae_name"
|
| 197 |
+
},
|
| 198 |
+
"link": null
|
| 199 |
+
}
|
| 200 |
+
],
|
| 201 |
+
"outputs": [
|
| 202 |
+
{
|
| 203 |
+
"localized_name": "VAE",
|
| 204 |
+
"name": "VAE",
|
| 205 |
+
"type": "VAE",
|
| 206 |
+
"slot_index": 0,
|
| 207 |
+
"links": [
|
| 208 |
+
4
|
| 209 |
+
]
|
| 210 |
+
}
|
| 211 |
+
],
|
| 212 |
+
"title": "Audio VAE",
|
| 213 |
+
"properties": {
|
| 214 |
+
"cnr_id": "comfy-core",
|
| 215 |
+
"ver": "0.34.0",
|
| 216 |
+
"Node name for S&R": "VAELoader"
|
| 217 |
+
},
|
| 218 |
+
"widgets_values": [
|
| 219 |
+
"minimax_h3_audio_vae_fp32.safetensors"
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"id": 7,
|
| 224 |
+
"type": "HandTieClips",
|
| 225 |
+
"pos": [
|
| 226 |
+
994,
|
| 227 |
+
427
|
| 228 |
+
],
|
| 229 |
+
"size": [
|
| 230 |
+
968.3162169696975,
|
| 231 |
+
1426.2006199999998
|
| 232 |
+
],
|
| 233 |
+
"flags": {},
|
| 234 |
+
"order": 4,
|
| 235 |
+
"mode": 0,
|
| 236 |
+
"inputs": [
|
| 237 |
+
{
|
| 238 |
+
"localized_name": "model",
|
| 239 |
+
"name": "model",
|
| 240 |
+
"type": "MODEL",
|
| 241 |
+
"link": 17
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"localized_name": "clip",
|
| 245 |
+
"name": "clip",
|
| 246 |
+
"type": "CLIP",
|
| 247 |
+
"link": 18
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"localized_name": "vae",
|
| 251 |
+
"name": "vae",
|
| 252 |
+
"type": "VAE",
|
| 253 |
+
"link": 3
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"localized_name": "audio_vae",
|
| 257 |
+
"name": "audio_vae",
|
| 258 |
+
"type": "VAE",
|
| 259 |
+
"link": 4
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"localized_name": "continuity_state",
|
| 263 |
+
"name": "continuity_state",
|
| 264 |
+
"shape": 7,
|
| 265 |
+
"type": "STRING",
|
| 266 |
+
"link": null
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"localized_name": "prompt",
|
| 270 |
+
"name": "prompt",
|
| 271 |
+
"type": "STRING",
|
| 272 |
+
"widget": {
|
| 273 |
+
"name": "prompt"
|
| 274 |
+
},
|
| 275 |
+
"link": null
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"localized_name": "chains",
|
| 279 |
+
"name": "chains",
|
| 280 |
+
"type": "COMBO",
|
| 281 |
+
"widget": {
|
| 282 |
+
"name": "chains"
|
| 283 |
+
},
|
| 284 |
+
"link": null
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
"localized_name": "resolution",
|
| 288 |
+
"name": "resolution",
|
| 289 |
+
"type": "COMBO",
|
| 290 |
+
"widget": {
|
| 291 |
+
"name": "resolution"
|
| 292 |
+
},
|
| 293 |
+
"link": null
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"localized_name": "aspect",
|
| 297 |
+
"name": "aspect",
|
| 298 |
+
"type": "COMBO",
|
| 299 |
+
"widget": {
|
| 300 |
+
"name": "aspect"
|
| 301 |
+
},
|
| 302 |
+
"link": null
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"localized_name": "duration",
|
| 306 |
+
"name": "duration",
|
| 307 |
+
"type": "COMBO",
|
| 308 |
+
"widget": {
|
| 309 |
+
"name": "duration"
|
| 310 |
+
},
|
| 311 |
+
"link": null
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"localized_name": "overlap",
|
| 315 |
+
"name": "overlap",
|
| 316 |
+
"type": "COMBO",
|
| 317 |
+
"widget": {
|
| 318 |
+
"name": "overlap"
|
| 319 |
+
},
|
| 320 |
+
"link": null
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"localized_name": "seed",
|
| 324 |
+
"name": "seed",
|
| 325 |
+
"type": "INT",
|
| 326 |
+
"widget": {
|
| 327 |
+
"name": "seed"
|
| 328 |
+
},
|
| 329 |
+
"link": null
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"localized_name": "seed_per_shot",
|
| 333 |
+
"name": "seed_per_shot",
|
| 334 |
+
"type": "BOOLEAN",
|
| 335 |
+
"widget": {
|
| 336 |
+
"name": "seed_per_shot"
|
| 337 |
+
},
|
| 338 |
+
"link": null
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"localized_name": "steps",
|
| 342 |
+
"name": "steps",
|
| 343 |
+
"type": "INT",
|
| 344 |
+
"widget": {
|
| 345 |
+
"name": "steps"
|
| 346 |
+
},
|
| 347 |
+
"link": null
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"localized_name": "sampler_name",
|
| 351 |
+
"name": "sampler_name",
|
| 352 |
+
"type": "COMBO",
|
| 353 |
+
"widget": {
|
| 354 |
+
"name": "sampler_name"
|
| 355 |
+
},
|
| 356 |
+
"link": null
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"localized_name": "scheduler",
|
| 360 |
+
"name": "scheduler",
|
| 361 |
+
"type": "COMBO",
|
| 362 |
+
"widget": {
|
| 363 |
+
"name": "scheduler"
|
| 364 |
+
},
|
| 365 |
+
"link": null
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"localized_name": "shift_video",
|
| 369 |
+
"name": "shift_video",
|
| 370 |
+
"type": "FLOAT",
|
| 371 |
+
"widget": {
|
| 372 |
+
"name": "shift_video"
|
| 373 |
+
},
|
| 374 |
+
"link": null
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"localized_name": "shift_audio",
|
| 378 |
+
"name": "shift_audio",
|
| 379 |
+
"type": "FLOAT",
|
| 380 |
+
"widget": {
|
| 381 |
+
"name": "shift_audio"
|
| 382 |
+
},
|
| 383 |
+
"link": null
|
| 384 |
+
},
|
| 385 |
+
{
|
| 386 |
+
"localized_name": "ref_image_size",
|
| 387 |
+
"name": "ref_image_size",
|
| 388 |
+
"type": "COMBO",
|
| 389 |
+
"widget": {
|
| 390 |
+
"name": "ref_image_size"
|
| 391 |
+
},
|
| 392 |
+
"link": null
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"localized_name": "hop_script",
|
| 396 |
+
"name": "hop_script",
|
| 397 |
+
"shape": 7,
|
| 398 |
+
"type": "COMBO",
|
| 399 |
+
"widget": {
|
| 400 |
+
"name": "hop_script"
|
| 401 |
+
},
|
| 402 |
+
"link": null
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"localized_name": "pin_to_qwen",
|
| 406 |
+
"name": "pin_to_qwen",
|
| 407 |
+
"shape": 7,
|
| 408 |
+
"type": "COMBO",
|
| 409 |
+
"widget": {
|
| 410 |
+
"name": "pin_to_qwen"
|
| 411 |
+
},
|
| 412 |
+
"link": null
|
| 413 |
+
},
|
| 414 |
+
{
|
| 415 |
+
"localized_name": "shot_plan",
|
| 416 |
+
"name": "shot_plan",
|
| 417 |
+
"shape": 7,
|
| 418 |
+
"type": "STRING",
|
| 419 |
+
"widget": {
|
| 420 |
+
"name": "shot_plan"
|
| 421 |
+
},
|
| 422 |
+
"link": null
|
| 423 |
+
},
|
| 424 |
+
{
|
| 425 |
+
"localized_name": "ref_plan",
|
| 426 |
+
"name": "ref_plan",
|
| 427 |
+
"shape": 7,
|
| 428 |
+
"type": "STRING",
|
| 429 |
+
"widget": {
|
| 430 |
+
"name": "ref_plan"
|
| 431 |
+
},
|
| 432 |
+
"link": null
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"localized_name": "cache_hops",
|
| 436 |
+
"name": "cache_hops",
|
| 437 |
+
"shape": 7,
|
| 438 |
+
"type": "COMBO",
|
| 439 |
+
"widget": {
|
| 440 |
+
"name": "cache_hops"
|
| 441 |
+
},
|
| 442 |
+
"link": null
|
| 443 |
+
},
|
| 444 |
+
{
|
| 445 |
+
"localized_name": "cache_budget_gb",
|
| 446 |
+
"name": "cache_budget_gb",
|
| 447 |
+
"shape": 7,
|
| 448 |
+
"type": "FLOAT",
|
| 449 |
+
"widget": {
|
| 450 |
+
"name": "cache_budget_gb"
|
| 451 |
+
},
|
| 452 |
+
"link": null
|
| 453 |
+
},
|
| 454 |
+
{
|
| 455 |
+
"localized_name": "audio_pin_frames",
|
| 456 |
+
"name": "audio_pin_frames",
|
| 457 |
+
"shape": 7,
|
| 458 |
+
"type": "INT",
|
| 459 |
+
"widget": {
|
| 460 |
+
"name": "audio_pin_frames"
|
| 461 |
+
},
|
| 462 |
+
"link": null
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"localized_name": "pin_renorm",
|
| 466 |
+
"name": "pin_renorm",
|
| 467 |
+
"shape": 7,
|
| 468 |
+
"type": "COMBO",
|
| 469 |
+
"widget": {
|
| 470 |
+
"name": "pin_renorm"
|
| 471 |
+
},
|
| 472 |
+
"link": null
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"localized_name": "pin_noise",
|
| 476 |
+
"name": "pin_noise",
|
| 477 |
+
"shape": 7,
|
| 478 |
+
"type": "FLOAT",
|
| 479 |
+
"widget": {
|
| 480 |
+
"name": "pin_noise"
|
| 481 |
+
},
|
| 482 |
+
"link": null
|
| 483 |
+
},
|
| 484 |
+
{
|
| 485 |
+
"localized_name": "tone_compensate",
|
| 486 |
+
"name": "tone_compensate",
|
| 487 |
+
"shape": 7,
|
| 488 |
+
"type": "COMBO",
|
| 489 |
+
"widget": {
|
| 490 |
+
"name": "tone_compensate"
|
| 491 |
+
},
|
| 492 |
+
"link": null
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"localized_name": "start_image_file",
|
| 496 |
+
"name": "start_image_file",
|
| 497 |
+
"shape": 7,
|
| 498 |
+
"type": "STRING",
|
| 499 |
+
"widget": {
|
| 500 |
+
"name": "start_image_file"
|
| 501 |
+
},
|
| 502 |
+
"link": null
|
| 503 |
+
},
|
| 504 |
+
{
|
| 505 |
+
"localized_name": "reference_video_file",
|
| 506 |
+
"name": "reference_video_file",
|
| 507 |
+
"shape": 7,
|
| 508 |
+
"type": "STRING",
|
| 509 |
+
"widget": {
|
| 510 |
+
"name": "reference_video_file"
|
| 511 |
+
},
|
| 512 |
+
"link": null
|
| 513 |
+
},
|
| 514 |
+
{
|
| 515 |
+
"localized_name": "voice_file",
|
| 516 |
+
"name": "voice_file",
|
| 517 |
+
"shape": 7,
|
| 518 |
+
"type": "STRING",
|
| 519 |
+
"widget": {
|
| 520 |
+
"name": "voice_file"
|
| 521 |
+
},
|
| 522 |
+
"link": null
|
| 523 |
+
}
|
| 524 |
+
],
|
| 525 |
+
"outputs": [
|
| 526 |
+
{
|
| 527 |
+
"localized_name": "images",
|
| 528 |
+
"name": "images",
|
| 529 |
+
"type": "IMAGE",
|
| 530 |
+
"slot_index": 0,
|
| 531 |
+
"links": [
|
| 532 |
+
5
|
| 533 |
+
]
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
"localized_name": "audio",
|
| 537 |
+
"name": "audio",
|
| 538 |
+
"type": "AUDIO",
|
| 539 |
+
"slot_index": 1,
|
| 540 |
+
"links": [
|
| 541 |
+
6
|
| 542 |
+
]
|
| 543 |
+
},
|
| 544 |
+
{
|
| 545 |
+
"localized_name": "info",
|
| 546 |
+
"name": "info",
|
| 547 |
+
"type": "STRING",
|
| 548 |
+
"slot_index": 2,
|
| 549 |
+
"links": [
|
| 550 |
+
7
|
| 551 |
+
]
|
| 552 |
+
}
|
| 553 |
+
],
|
| 554 |
+
"properties": {
|
| 555 |
+
"Node name for S&R": "H3RefChain",
|
| 556 |
+
"h3_plan_backup": "{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"Live-action, natural indoor light. The cook stands at the counter in @kitchen, looks up from the chopping board, and speaks one short line to someone off-frame.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"The cook sets the knife down, turns toward the window, and finishes her last word as she looks out.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"push_in\",\n \"framing\": \"close\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s3\",\n \"beat\": \"The cook leans back against the counter with her lips closed, and lets her eyes move slowly across the room. The room is still, with only the low hum of the refrigerator.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pull_back\",\n \"framing\": \"medium\",\n \"pace\": \"slow\",\n \"tail\": \"settle\"\n }\n }\n ]\n}",
|
| 557 |
+
"h3_editor_mode": "shots"
|
| 558 |
+
},
|
| 559 |
+
"widgets_values": [
|
| 560 |
+
"Live-action, natural indoor light. The person looks exactly as in the reference photographs.\n\nA cook in an apron stands at a kitchen counter, slicing. They look up, speak one short line, then turn back to the board.",
|
| 561 |
+
"2",
|
| 562 |
+
"0.3 MP",
|
| 563 |
+
"16:9 landscape",
|
| 564 |
+
"5 s",
|
| 565 |
+
"0.9 s",
|
| 566 |
+
427058607873049,
|
| 567 |
+
"fixed",
|
| 568 |
+
true,
|
| 569 |
+
7,
|
| 570 |
+
"res_multistep",
|
| 571 |
+
"simple",
|
| 572 |
+
12,
|
| 573 |
+
3,
|
| 574 |
+
"match",
|
| 575 |
+
"next",
|
| 576 |
+
"last frame",
|
| 577 |
+
"{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"A cook in an apron stands at a kitchen counter, looks up from the chopping board and says, 'You are earlier than I expected.' She turns back to the board and goes on slicing, the knife tapping steadily against the wood.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"She sets the knife down, turns toward the window and looks out at the street, her weight settling onto one hip. The kitchen is quiet apart from the low hum of the refrigerator.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pan_follow\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"settle\"\n }\n }\n ]\n}",
|
| 578 |
+
"{\n \"refs\": [],\n \"subjects\": {}\n}",
|
| 579 |
+
"on",
|
| 580 |
+
19,
|
| 581 |
+
24,
|
| 582 |
+
"off",
|
| 583 |
+
0,
|
| 584 |
+
"frame_shift",
|
| 585 |
+
"",
|
| 586 |
+
"",
|
| 587 |
+
"",
|
| 588 |
+
"Live-action, natural light, one continuous take."
|
| 589 |
+
],
|
| 590 |
+
"title": "H3 Ref2VA Chain - starter"
|
| 591 |
+
},
|
| 592 |
+
{
|
| 593 |
+
"id": 16,
|
| 594 |
+
"type": "HTCChainPreview",
|
| 595 |
+
"pos": [
|
| 596 |
+
2258.827272727274,
|
| 597 |
+
-90.16060606060621
|
| 598 |
+
],
|
| 599 |
+
"size": [
|
| 600 |
+
400,
|
| 601 |
+
460
|
| 602 |
+
],
|
| 603 |
+
"flags": {},
|
| 604 |
+
"order": 5,
|
| 605 |
+
"mode": 0,
|
| 606 |
+
"inputs": [
|
| 607 |
+
{
|
| 608 |
+
"localized_name": "images",
|
| 609 |
+
"name": "images",
|
| 610 |
+
"type": "IMAGE",
|
| 611 |
+
"link": 5
|
| 612 |
+
},
|
| 613 |
+
{
|
| 614 |
+
"localized_name": "audio",
|
| 615 |
+
"name": "audio",
|
| 616 |
+
"shape": 7,
|
| 617 |
+
"type": "AUDIO",
|
| 618 |
+
"link": 6
|
| 619 |
+
},
|
| 620 |
+
{
|
| 621 |
+
"localized_name": "info",
|
| 622 |
+
"name": "info",
|
| 623 |
+
"shape": 7,
|
| 624 |
+
"type": "STRING",
|
| 625 |
+
"link": 7
|
| 626 |
+
}
|
| 627 |
+
],
|
| 628 |
+
"outputs": [
|
| 629 |
+
{
|
| 630 |
+
"localized_name": "images",
|
| 631 |
+
"name": "images",
|
| 632 |
+
"type": "IMAGE",
|
| 633 |
+
"slot_index": 0,
|
| 634 |
+
"links": [
|
| 635 |
+
8
|
| 636 |
+
]
|
| 637 |
+
},
|
| 638 |
+
{
|
| 639 |
+
"localized_name": "audio",
|
| 640 |
+
"name": "audio",
|
| 641 |
+
"type": "AUDIO",
|
| 642 |
+
"slot_index": 1,
|
| 643 |
+
"links": [
|
| 644 |
+
9
|
| 645 |
+
]
|
| 646 |
+
}
|
| 647 |
+
],
|
| 648 |
+
"properties": {
|
| 649 |
+
"Node name for S&R": "H3ChainPreview",
|
| 650 |
+
"h3rcPanelH": 40
|
| 651 |
+
},
|
| 652 |
+
"widgets_values": [
|
| 653 |
+
""
|
| 654 |
+
]
|
| 655 |
+
},
|
| 656 |
+
{
|
| 657 |
+
"id": 8,
|
| 658 |
+
"type": "CreateVideo",
|
| 659 |
+
"pos": [
|
| 660 |
+
2991.7272727272743,
|
| 661 |
+
37.33939393939383
|
| 662 |
+
],
|
| 663 |
+
"size": [
|
| 664 |
+
300,
|
| 665 |
+
130
|
| 666 |
+
],
|
| 667 |
+
"flags": {},
|
| 668 |
+
"order": 6,
|
| 669 |
+
"mode": 0,
|
| 670 |
+
"inputs": [
|
| 671 |
+
{
|
| 672 |
+
"localized_name": "images",
|
| 673 |
+
"name": "images",
|
| 674 |
+
"type": "IMAGE",
|
| 675 |
+
"link": 8
|
| 676 |
+
},
|
| 677 |
+
{
|
| 678 |
+
"localized_name": "audio",
|
| 679 |
+
"name": "audio",
|
| 680 |
+
"shape": 7,
|
| 681 |
+
"type": "AUDIO",
|
| 682 |
+
"link": 9
|
| 683 |
+
},
|
| 684 |
+
{
|
| 685 |
+
"localized_name": "fps",
|
| 686 |
+
"name": "fps",
|
| 687 |
+
"type": "FLOAT",
|
| 688 |
+
"widget": {
|
| 689 |
+
"name": "fps"
|
| 690 |
+
},
|
| 691 |
+
"link": null
|
| 692 |
+
},
|
| 693 |
+
{
|
| 694 |
+
"localized_name": "bit_depth",
|
| 695 |
+
"name": "bit_depth",
|
| 696 |
+
"shape": 7,
|
| 697 |
+
"type": "COMBO",
|
| 698 |
+
"widget": {
|
| 699 |
+
"name": "bit_depth"
|
| 700 |
+
},
|
| 701 |
+
"link": null
|
| 702 |
+
},
|
| 703 |
+
{
|
| 704 |
+
"localized_name": "color_space",
|
| 705 |
+
"name": "color_space",
|
| 706 |
+
"shape": 7,
|
| 707 |
+
"type": "COMBO",
|
| 708 |
+
"widget": {
|
| 709 |
+
"name": "color_space"
|
| 710 |
+
},
|
| 711 |
+
"link": null
|
| 712 |
+
}
|
| 713 |
+
],
|
| 714 |
+
"outputs": [
|
| 715 |
+
{
|
| 716 |
+
"localized_name": "VIDEO",
|
| 717 |
+
"name": "VIDEO",
|
| 718 |
+
"type": "VIDEO",
|
| 719 |
+
"slot_index": 0,
|
| 720 |
+
"links": [
|
| 721 |
+
10
|
| 722 |
+
]
|
| 723 |
+
}
|
| 724 |
+
],
|
| 725 |
+
"properties": {
|
| 726 |
+
"cnr_id": "comfy-core",
|
| 727 |
+
"ver": "0.34.0",
|
| 728 |
+
"Node name for S&R": "CreateVideo"
|
| 729 |
+
},
|
| 730 |
+
"widgets_values": [
|
| 731 |
+
24,
|
| 732 |
+
"auto",
|
| 733 |
+
"sRGB"
|
| 734 |
+
]
|
| 735 |
+
},
|
| 736 |
+
{
|
| 737 |
+
"id": 9,
|
| 738 |
+
"type": "SaveVideo",
|
| 739 |
+
"pos": [
|
| 740 |
+
3032.515151515151,
|
| 741 |
+
714.542424242424
|
| 742 |
+
],
|
| 743 |
+
"size": [
|
| 744 |
+
340,
|
| 745 |
+
106
|
| 746 |
+
],
|
| 747 |
+
"flags": {},
|
| 748 |
+
"order": 7,
|
| 749 |
+
"mode": 0,
|
| 750 |
+
"inputs": [
|
| 751 |
+
{
|
| 752 |
+
"localized_name": "video",
|
| 753 |
+
"name": "video",
|
| 754 |
+
"type": "VIDEO",
|
| 755 |
+
"link": 10
|
| 756 |
+
},
|
| 757 |
+
{
|
| 758 |
+
"localized_name": "filename_prefix",
|
| 759 |
+
"name": "filename_prefix",
|
| 760 |
+
"type": "STRING",
|
| 761 |
+
"widget": {
|
| 762 |
+
"name": "filename_prefix"
|
| 763 |
+
},
|
| 764 |
+
"link": null
|
| 765 |
+
},
|
| 766 |
+
{
|
| 767 |
+
"localized_name": "format",
|
| 768 |
+
"name": "format",
|
| 769 |
+
"type": "COMFY_DYNAMICCOMBO_V3",
|
| 770 |
+
"widget": {
|
| 771 |
+
"name": "format"
|
| 772 |
+
},
|
| 773 |
+
"link": null
|
| 774 |
+
},
|
| 775 |
+
{
|
| 776 |
+
"localized_name": "format.codec",
|
| 777 |
+
"name": "format.codec",
|
| 778 |
+
"type": "COMFY_DYNAMICCOMBO_V3",
|
| 779 |
+
"widget": {
|
| 780 |
+
"name": "format.codec"
|
| 781 |
+
},
|
| 782 |
+
"link": null
|
| 783 |
+
},
|
| 784 |
+
{
|
| 785 |
+
"localized_name": "codec",
|
| 786 |
+
"name": "codec",
|
| 787 |
+
"shape": 7,
|
| 788 |
+
"type": "COMFY_DYNAMICCOMBO_V3",
|
| 789 |
+
"widget": {
|
| 790 |
+
"name": "codec"
|
| 791 |
+
},
|
| 792 |
+
"link": null
|
| 793 |
+
}
|
| 794 |
+
],
|
| 795 |
+
"outputs": [
|
| 796 |
+
{
|
| 797 |
+
"localized_name": "video",
|
| 798 |
+
"name": "video",
|
| 799 |
+
"type": "VIDEO",
|
| 800 |
+
"links": []
|
| 801 |
+
}
|
| 802 |
+
],
|
| 803 |
+
"title": "SAVE",
|
| 804 |
+
"properties": {
|
| 805 |
+
"cnr_id": "comfy-core",
|
| 806 |
+
"ver": "0.34.0",
|
| 807 |
+
"Node name for S&R": "SaveVideo"
|
| 808 |
+
},
|
| 809 |
+
"widgets_values": [
|
| 810 |
+
"video/HANDTIECLIPS/chain",
|
| 811 |
+
"auto",
|
| 812 |
+
"auto",
|
| 813 |
+
"auto"
|
| 814 |
+
]
|
| 815 |
+
},
|
| 816 |
+
{
|
| 817 |
+
"id": 30,
|
| 818 |
+
"type": "LTX_lora_loader",
|
| 819 |
+
"pos": [
|
| 820 |
+
520,
|
| 821 |
+
-40
|
| 822 |
+
],
|
| 823 |
+
"size": [
|
| 824 |
+
420,
|
| 825 |
+
240
|
| 826 |
+
],
|
| 827 |
+
"flags": {},
|
| 828 |
+
"order": 0,
|
| 829 |
+
"mode": 0,
|
| 830 |
+
"inputs": [
|
| 831 |
+
{
|
| 832 |
+
"name": "model",
|
| 833 |
+
"type": "MODEL",
|
| 834 |
+
"link": 11
|
| 835 |
+
},
|
| 836 |
+
{
|
| 837 |
+
"name": "clip",
|
| 838 |
+
"type": "CLIP",
|
| 839 |
+
"link": 12
|
| 840 |
+
}
|
| 841 |
+
],
|
| 842 |
+
"outputs": [
|
| 843 |
+
{
|
| 844 |
+
"name": "model",
|
| 845 |
+
"type": "MODEL",
|
| 846 |
+
"links": [
|
| 847 |
+
13
|
| 848 |
+
]
|
| 849 |
+
},
|
| 850 |
+
{
|
| 851 |
+
"name": "clip",
|
| 852 |
+
"type": "CLIP",
|
| 853 |
+
"links": [
|
| 854 |
+
18
|
| 855 |
+
]
|
| 856 |
+
}
|
| 857 |
+
],
|
| 858 |
+
"title": "LoRA Loader Stack (turbo)",
|
| 859 |
+
"properties": {
|
| 860 |
+
"htc_speed": "lora",
|
| 861 |
+
"Node name for S&R": "LTX_lora_loader"
|
| 862 |
+
},
|
| 863 |
+
"widgets_values": [
|
| 864 |
+
"minimax",
|
| 865 |
+
"[{\"on\":true,\"lora\":\"minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors\",\"str\":1,\"v\":1,\"a\":1,\"t\":1}]",
|
| 866 |
+
""
|
| 867 |
+
]
|
| 868 |
+
},
|
| 869 |
+
{
|
| 870 |
+
"id": 31,
|
| 871 |
+
"type": "H3AdaLNLoRAFix",
|
| 872 |
+
"pos": [
|
| 873 |
+
520,
|
| 874 |
+
240
|
| 875 |
+
],
|
| 876 |
+
"size": [
|
| 877 |
+
340,
|
| 878 |
+
150
|
| 879 |
+
],
|
| 880 |
+
"flags": {},
|
| 881 |
+
"order": 0,
|
| 882 |
+
"mode": 0,
|
| 883 |
+
"inputs": [
|
| 884 |
+
{
|
| 885 |
+
"name": "model",
|
| 886 |
+
"type": "MODEL",
|
| 887 |
+
"link": 13
|
| 888 |
+
}
|
| 889 |
+
],
|
| 890 |
+
"outputs": [
|
| 891 |
+
{
|
| 892 |
+
"name": "MODEL",
|
| 893 |
+
"type": "MODEL",
|
| 894 |
+
"links": [
|
| 895 |
+
14
|
| 896 |
+
]
|
| 897 |
+
}
|
| 898 |
+
],
|
| 899 |
+
"title": "H3 AdaLN LoRA Fix",
|
| 900 |
+
"properties": {
|
| 901 |
+
"htc_speed": "adaln",
|
| 902 |
+
"Node name for S&R": "H3AdaLNLoRAFix"
|
| 903 |
+
},
|
| 904 |
+
"widgets_values": [
|
| 905 |
+
"port"
|
| 906 |
+
]
|
| 907 |
+
},
|
| 908 |
+
{
|
| 909 |
+
"id": 32,
|
| 910 |
+
"type": "MiniMaxLowVRAMAttention",
|
| 911 |
+
"pos": [
|
| 912 |
+
520,
|
| 913 |
+
430
|
| 914 |
+
],
|
| 915 |
+
"size": [
|
| 916 |
+
330,
|
| 917 |
+
58
|
| 918 |
+
],
|
| 919 |
+
"flags": {},
|
| 920 |
+
"order": 0,
|
| 921 |
+
"mode": 0,
|
| 922 |
+
"inputs": [
|
| 923 |
+
{
|
| 924 |
+
"name": "model",
|
| 925 |
+
"type": "MODEL",
|
| 926 |
+
"link": 14
|
| 927 |
+
}
|
| 928 |
+
],
|
| 929 |
+
"outputs": [
|
| 930 |
+
{
|
| 931 |
+
"name": "model",
|
| 932 |
+
"type": "MODEL",
|
| 933 |
+
"links": [
|
| 934 |
+
15
|
| 935 |
+
]
|
| 936 |
+
}
|
| 937 |
+
],
|
| 938 |
+
"title": "MiniMax H3 Low VRAM Attention",
|
| 939 |
+
"properties": {
|
| 940 |
+
"htc_speed": "lowvram",
|
| 941 |
+
"Node name for S&R": "MiniMaxLowVRAMAttention"
|
| 942 |
+
},
|
| 943 |
+
"widgets_values": [
|
| 944 |
+
4
|
| 945 |
+
]
|
| 946 |
+
},
|
| 947 |
+
{
|
| 948 |
+
"id": 33,
|
| 949 |
+
"type": "H3SLAAttention",
|
| 950 |
+
"pos": [
|
| 951 |
+
520,
|
| 952 |
+
530
|
| 953 |
+
],
|
| 954 |
+
"size": [
|
| 955 |
+
340,
|
| 956 |
+
322
|
| 957 |
+
],
|
| 958 |
+
"flags": {},
|
| 959 |
+
"order": 0,
|
| 960 |
+
"mode": 0,
|
| 961 |
+
"inputs": [
|
| 962 |
+
{
|
| 963 |
+
"name": "model",
|
| 964 |
+
"type": "MODEL",
|
| 965 |
+
"link": 15
|
| 966 |
+
}
|
| 967 |
+
],
|
| 968 |
+
"outputs": [
|
| 969 |
+
{
|
| 970 |
+
"name": "MODEL",
|
| 971 |
+
"type": "MODEL",
|
| 972 |
+
"links": [
|
| 973 |
+
16
|
| 974 |
+
]
|
| 975 |
+
}
|
| 976 |
+
],
|
| 977 |
+
"title": "H3 SLA Attention",
|
| 978 |
+
"properties": {
|
| 979 |
+
"htc_speed": "sla",
|
| 980 |
+
"Node name for S&R": "H3SLAAttention"
|
| 981 |
+
},
|
| 982 |
+
"widgets_values": [
|
| 983 |
+
0.9,
|
| 984 |
+
"64",
|
| 985 |
+
8192,
|
| 986 |
+
0,
|
| 987 |
+
true,
|
| 988 |
+
true,
|
| 989 |
+
"0",
|
| 990 |
+
"comfy_kitchen",
|
| 991 |
+
true,
|
| 992 |
+
true,
|
| 993 |
+
"Light"
|
| 994 |
+
]
|
| 995 |
+
},
|
| 996 |
+
{
|
| 997 |
+
"id": 34,
|
| 998 |
+
"type": "ModelPreviewOverrideKJ",
|
| 999 |
+
"pos": [
|
| 1000 |
+
520,
|
| 1001 |
+
900
|
| 1002 |
+
],
|
| 1003 |
+
"size": [
|
| 1004 |
+
360,
|
| 1005 |
+
480
|
| 1006 |
+
],
|
| 1007 |
+
"flags": {},
|
| 1008 |
+
"order": 0,
|
| 1009 |
+
"mode": 0,
|
| 1010 |
+
"inputs": [
|
| 1011 |
+
{
|
| 1012 |
+
"name": "model",
|
| 1013 |
+
"type": "MODEL",
|
| 1014 |
+
"link": 16
|
| 1015 |
+
},
|
| 1016 |
+
{
|
| 1017 |
+
"name": "vae",
|
| 1018 |
+
"type": "VAE",
|
| 1019 |
+
"link": null
|
| 1020 |
+
}
|
| 1021 |
+
],
|
| 1022 |
+
"outputs": [
|
| 1023 |
+
{
|
| 1024 |
+
"name": "MODEL",
|
| 1025 |
+
"type": "MODEL",
|
| 1026 |
+
"links": [
|
| 1027 |
+
17
|
| 1028 |
+
]
|
| 1029 |
+
}
|
| 1030 |
+
],
|
| 1031 |
+
"title": "Model Preview Override",
|
| 1032 |
+
"properties": {
|
| 1033 |
+
"htc_speed": "preview",
|
| 1034 |
+
"Node name for S&R": "ModelPreviewOverrideKJ"
|
| 1035 |
+
},
|
| 1036 |
+
"widgets_values": [
|
| 1037 |
+
512,
|
| 1038 |
+
80,
|
| 1039 |
+
true,
|
| 1040 |
+
100,
|
| 1041 |
+
8,
|
| 1042 |
+
"taeh3.safetensors",
|
| 1043 |
+
""
|
| 1044 |
+
]
|
| 1045 |
+
},
|
| 1046 |
+
{
|
| 1047 |
+
"id": 20,
|
| 1048 |
+
"type": "MarkdownNote",
|
| 1049 |
+
"pos": [
|
| 1050 |
+
-960,
|
| 1051 |
+
-400
|
| 1052 |
+
],
|
| 1053 |
+
"size": [
|
| 1054 |
+
440,
|
| 1055 |
+
940
|
| 1056 |
+
],
|
| 1057 |
+
"flags": {},
|
| 1058 |
+
"order": 8,
|
| 1059 |
+
"mode": 0,
|
| 1060 |
+
"inputs": [],
|
| 1061 |
+
"outputs": [],
|
| 1062 |
+
"title": "START HERE",
|
| 1063 |
+
"properties": {
|
| 1064 |
+
"htc_card": "start"
|
| 1065 |
+
},
|
| 1066 |
+
"widgets_values": [
|
| 1067 |
+
"## Start here\n\nTwo hops of 5 s, joined into one 10 s clip.\n\n### Requires\n\nThe MODEL wire is a turbo stack, not a bare loader:\n\n```\nUNETLoader\n -> LoRA Loader Stack (turbo LoRA)\n -> H3 AdaLN LoRA Fix\n -> MiniMax H3 Low VRAM Attention\n -> H3 SLA Attention\n -> Model Preview Override\n -> this node\n```\n\n| pack | nodes |\n|---|---|\n| **ComfyUI-PlagueKind-Nodes** | LoRA Loader Stack, AdaLN Fix, SLA Attention |\n| **ComfyUI-KJNodes** | Low VRAM Attention (experimental), Model Preview Override |\n\nAlso on disk: the **turbo LoRA** named in the loader, and `taeh3.safetensors`\nfor the live preview (or set `tiny_vae` to `none`).\n\n**CLIP goes to this node from the LoRA loader, not from the encoder.** That is\nwhat makes the text half of every LoRA land. Do not rewire it back.\n\n`steps` is **7**, which only works with the turbo LoRA. Missing a pack? Its\nnodes load as red boxes -- delete them, wire the loader straight into `model`\nand the encoder into `clip`, and raise `steps` to 20 or so.\n\n1. Point the four **loaders** at your H3 files.\n2. Queue.\n3. Read the **SHOTS** cards on the node.\n\n**Shot 1 is the whole opening. Every later shot is only the new beat.** The node\nwrites the identity lock, the live-frame citation and the join itself -- so\nre-describing the face, the clothes or the room after shot 1 competes with the\nframe pin instead of reinforcing it.\n\nThis plan ships with **no references on purpose**, so it runs before you have\nsupplied any pictures.\n\n### Adding pictures\n\n- Open **REFERENCES**, add a row, drop a photo on its thumbnail. Files land in\n `ComfyUI/input/h3_refs`. There is no Load Image node to wire.\n- Give the row a `@tag`, and group photos of the same person under one subject.\n- Write the tag into the beat: `@hero_face stands at the counter in @kitchen`.\n\nThe long-form guide is `PROMPTING.md` in the pack folder."
|
| 1068 |
+
],
|
| 1069 |
+
"color": "#232",
|
| 1070 |
+
"bgcolor": "#353"
|
| 1071 |
+
},
|
| 1072 |
+
{
|
| 1073 |
+
"id": 21,
|
| 1074 |
+
"type": "MarkdownNote",
|
| 1075 |
+
"pos": [
|
| 1076 |
+
-960,
|
| 1077 |
+
600
|
| 1078 |
+
],
|
| 1079 |
+
"size": [
|
| 1080 |
+
440,
|
| 1081 |
+
760
|
| 1082 |
+
],
|
| 1083 |
+
"flags": {},
|
| 1084 |
+
"order": 9,
|
| 1085 |
+
"mode": 0,
|
| 1086 |
+
"inputs": [],
|
| 1087 |
+
"outputs": [],
|
| 1088 |
+
"title": "The rules",
|
| 1089 |
+
"properties": {
|
| 1090 |
+
"htc_card": "rules"
|
| 1091 |
+
},
|
| 1092 |
+
"widgets_values": [
|
| 1093 |
+
"## 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`."
|
| 1094 |
+
],
|
| 1095 |
+
"color": "#432",
|
| 1096 |
+
"bgcolor": "#653"
|
| 1097 |
+
},
|
| 1098 |
+
{
|
| 1099 |
+
"id": 22,
|
| 1100 |
+
"type": "MarkdownNote",
|
| 1101 |
+
"pos": [
|
| 1102 |
+
-960,
|
| 1103 |
+
1420
|
| 1104 |
+
],
|
| 1105 |
+
"size": [
|
| 1106 |
+
440,
|
| 1107 |
+
640
|
| 1108 |
+
],
|
| 1109 |
+
"flags": {},
|
| 1110 |
+
"order": 10,
|
| 1111 |
+
"mode": 0,
|
| 1112 |
+
"inputs": [],
|
| 1113 |
+
"outputs": [],
|
| 1114 |
+
"title": "References and @tags",
|
| 1115 |
+
"properties": {
|
| 1116 |
+
"htc_card": "refs"
|
| 1117 |
+
},
|
| 1118 |
+
"widgets_values": [
|
| 1119 |
+
"## References and @tags\n\nA picture in the register does not make the model use it. **The beat is what\ndrives the frame** -- write the tag into the action line.\n\nPhrase a place as a place that is *depicted*, not as a container to be placed\ninside: \"at the counter in `@kitchen`\", not \"steps into `@kitchen`\".\n\n### Fields\n\n| field | what it does |\n|---|---|\n| `tag` | what you write in beats as `@tag` |\n| `file` | a bare filename in `input/h3_refs` |\n| `subject` | groups pictures **of the same person** |\n| `retention` | `fully_preserved` / `partially_copy` / `reference` |\n| `shots` | the 1-based hops this picture rides |\n\n**One subject number per person.** Two different people under one number makes\nthe model render the average of their faces.\n\n### `shots` is the part that decides continuity\n\n**A reference with no `shots` list rides hop 1 only.** Right for a **place**\nplate: a room still riding a hop set somewhere else beats the frame pin, and the\nmodel follows the still. **List every hop a picture belongs on.**\n\n**A face plate is the opposite \u2014 put it on every hop.** A hop scheduled with no\nface reference came back a different person, and no later hop recovered.\nIdentity drift does not self-correct.\n\n- Every **face** ref gets every hop: `\"shots\": [1, 2, 3, 4, 5, 6]`.\n- Keep a **place** ref on the hops set in that place, and off the rest.\n\n### `locked` and `context`\n\nPer-subject prose that rides **every hop**, with no picture citation, so it\ncarries identity across a hop where the photograph is absent. Give every subject\na `locked`, and a `name` -- from hop 2 on, `@tag` for a person resolves to that\nname.\n\n**Name a colour or it drifts.** A noun with no adjective is unanchored: each hop\nis an independent encode, so \"the bowl\" on hop 4 came back stainless steel. Put\nproperties in `context` -- \"the bowl is white porcelain\" -- never locations, and\nrepeat the adjective in every beat."
|
| 1120 |
+
],
|
| 1121 |
+
"color": "#432",
|
| 1122 |
+
"bgcolor": "#653"
|
| 1123 |
+
},
|
| 1124 |
+
{
|
| 1125 |
+
"id": 23,
|
| 1126 |
+
"type": "MarkdownNote",
|
| 1127 |
+
"pos": [
|
| 1128 |
+
-480,
|
| 1129 |
+
-400
|
| 1130 |
+
],
|
| 1131 |
+
"size": [
|
| 1132 |
+
440,
|
| 1133 |
+
640
|
| 1134 |
+
],
|
| 1135 |
+
"flags": {},
|
| 1136 |
+
"order": 11,
|
| 1137 |
+
"mode": 0,
|
| 1138 |
+
"inputs": [],
|
| 1139 |
+
"outputs": [],
|
| 1140 |
+
"title": "Directives",
|
| 1141 |
+
"properties": {
|
| 1142 |
+
"htc_card": "directives"
|
| 1143 |
+
},
|
| 1144 |
+
"widgets_values": [
|
| 1145 |
+
"## Directives\n\nFive axes, all optional, set per shot. An unset axis emits nothing at all and\ncosts no tokens.\n\n| axis | values |\n|---|---|\n| `join` | `continuous`, `match_cut`, `hard_cut` -- **omit on shot 1** |\n| `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |\n| `framing` | `keep`, `wide`, `medium`, `close` |\n| `pace` | `slow`, `steady`, `brisk` |\n| `tail` | `ongoing` (default), `settle`, `hold` |\n\nThey compile in that order -- `join` first, because it describes how this hop\nmeets the previous one.\n\n### Two combinations the node warns about\n\n**`join: continuous` + a framing change + `camera: hold`.** A framing change asks\nthe audience to be somewhere new; with the camera still, the only way there is a\ncut. Earn it on the move (`push_in`, `pull_back`, `pan_follow`) or use\n`framing: keep`.\n\n**`push_in` + `wide`, or `pull_back` + `close`.** The move points the opposite way\nfrom the destination.\n\nBoth are warnings, not errors. They are legitimate things to want -- they just\nrarely read the way you meant.\n\n### Duration\n\nPer-shot `duration` overrides the chain, using exactly these labels:\n`\"5 s\"`, `\"7 s\"`, `\"8 s\"`, `\"10 s\"`, `\"15 s\"`."
|
| 1146 |
+
],
|
| 1147 |
+
"color": "#432",
|
| 1148 |
+
"bgcolor": "#653"
|
| 1149 |
+
},
|
| 1150 |
+
{
|
| 1151 |
+
"id": 24,
|
| 1152 |
+
"type": "MarkdownNote",
|
| 1153 |
+
"pos": [
|
| 1154 |
+
-480,
|
| 1155 |
+
280
|
| 1156 |
+
],
|
| 1157 |
+
"size": [
|
| 1158 |
+
440,
|
| 1159 |
+
760
|
| 1160 |
+
],
|
| 1161 |
+
"flags": {},
|
| 1162 |
+
"order": 12,
|
| 1163 |
+
"mode": 0,
|
| 1164 |
+
"inputs": [],
|
| 1165 |
+
"outputs": [],
|
| 1166 |
+
"title": "When it goes wrong",
|
| 1167 |
+
"properties": {
|
| 1168 |
+
"htc_card": "trouble"
|
| 1169 |
+
},
|
| 1170 |
+
"widgets_values": [
|
| 1171 |
+
"## 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 | Luminance drifts one way and nothing pushes back | 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 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."
|
| 1172 |
+
],
|
| 1173 |
+
"color": "#432",
|
| 1174 |
+
"bgcolor": "#653"
|
| 1175 |
+
},
|
| 1176 |
+
{
|
| 1177 |
+
"id": 25,
|
| 1178 |
+
"type": "MarkdownNote",
|
| 1179 |
+
"pos": [
|
| 1180 |
+
-480,
|
| 1181 |
+
1080
|
| 1182 |
+
],
|
| 1183 |
+
"size": [
|
| 1184 |
+
440,
|
| 1185 |
+
620
|
| 1186 |
+
],
|
| 1187 |
+
"flags": {},
|
| 1188 |
+
"order": 13,
|
| 1189 |
+
"mode": 0,
|
| 1190 |
+
"inputs": [],
|
| 1191 |
+
"outputs": [],
|
| 1192 |
+
"title": "Let a model write it",
|
| 1193 |
+
"properties": {
|
| 1194 |
+
"htc_card": "author"
|
| 1195 |
+
},
|
| 1196 |
+
"widgets_values": [
|
| 1197 |
+
"## 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 8192 or more**. The prompt is ~2,600 tokens and\n the reply another 1,000-2,000; a 4k 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."
|
| 1198 |
+
],
|
| 1199 |
+
"color": "#432",
|
| 1200 |
+
"bgcolor": "#653"
|
| 1201 |
+
}
|
| 1202 |
+
],
|
| 1203 |
+
"links": [
|
| 1204 |
+
[
|
| 1205 |
+
3,
|
| 1206 |
+
4,
|
| 1207 |
+
0,
|
| 1208 |
+
7,
|
| 1209 |
+
2,
|
| 1210 |
+
"VAE"
|
| 1211 |
+
],
|
| 1212 |
+
[
|
| 1213 |
+
4,
|
| 1214 |
+
5,
|
| 1215 |
+
0,
|
| 1216 |
+
7,
|
| 1217 |
+
3,
|
| 1218 |
+
"VAE"
|
| 1219 |
+
],
|
| 1220 |
+
[
|
| 1221 |
+
5,
|
| 1222 |
+
7,
|
| 1223 |
+
0,
|
| 1224 |
+
16,
|
| 1225 |
+
0,
|
| 1226 |
+
"IMAGE"
|
| 1227 |
+
],
|
| 1228 |
+
[
|
| 1229 |
+
6,
|
| 1230 |
+
7,
|
| 1231 |
+
1,
|
| 1232 |
+
16,
|
| 1233 |
+
1,
|
| 1234 |
+
"AUDIO"
|
| 1235 |
+
],
|
| 1236 |
+
[
|
| 1237 |
+
7,
|
| 1238 |
+
7,
|
| 1239 |
+
2,
|
| 1240 |
+
16,
|
| 1241 |
+
2,
|
| 1242 |
+
"STRING"
|
| 1243 |
+
],
|
| 1244 |
+
[
|
| 1245 |
+
8,
|
| 1246 |
+
16,
|
| 1247 |
+
0,
|
| 1248 |
+
8,
|
| 1249 |
+
0,
|
| 1250 |
+
"IMAGE"
|
| 1251 |
+
],
|
| 1252 |
+
[
|
| 1253 |
+
9,
|
| 1254 |
+
16,
|
| 1255 |
+
1,
|
| 1256 |
+
8,
|
| 1257 |
+
1,
|
| 1258 |
+
"AUDIO"
|
| 1259 |
+
],
|
| 1260 |
+
[
|
| 1261 |
+
10,
|
| 1262 |
+
8,
|
| 1263 |
+
0,
|
| 1264 |
+
9,
|
| 1265 |
+
0,
|
| 1266 |
+
"VIDEO"
|
| 1267 |
+
],
|
| 1268 |
+
[
|
| 1269 |
+
11,
|
| 1270 |
+
2,
|
| 1271 |
+
0,
|
| 1272 |
+
30,
|
| 1273 |
+
0,
|
| 1274 |
+
"MODEL"
|
| 1275 |
+
],
|
| 1276 |
+
[
|
| 1277 |
+
12,
|
| 1278 |
+
3,
|
| 1279 |
+
0,
|
| 1280 |
+
30,
|
| 1281 |
+
1,
|
| 1282 |
+
"CLIP"
|
| 1283 |
+
],
|
| 1284 |
+
[
|
| 1285 |
+
13,
|
| 1286 |
+
30,
|
| 1287 |
+
0,
|
| 1288 |
+
31,
|
| 1289 |
+
0,
|
| 1290 |
+
"MODEL"
|
| 1291 |
+
],
|
| 1292 |
+
[
|
| 1293 |
+
14,
|
| 1294 |
+
31,
|
| 1295 |
+
0,
|
| 1296 |
+
32,
|
| 1297 |
+
0,
|
| 1298 |
+
"MODEL"
|
| 1299 |
+
],
|
| 1300 |
+
[
|
| 1301 |
+
15,
|
| 1302 |
+
32,
|
| 1303 |
+
0,
|
| 1304 |
+
33,
|
| 1305 |
+
0,
|
| 1306 |
+
"MODEL"
|
| 1307 |
+
],
|
| 1308 |
+
[
|
| 1309 |
+
16,
|
| 1310 |
+
33,
|
| 1311 |
+
0,
|
| 1312 |
+
34,
|
| 1313 |
+
0,
|
| 1314 |
+
"MODEL"
|
| 1315 |
+
],
|
| 1316 |
+
[
|
| 1317 |
+
17,
|
| 1318 |
+
34,
|
| 1319 |
+
0,
|
| 1320 |
+
7,
|
| 1321 |
+
0,
|
| 1322 |
+
"MODEL"
|
| 1323 |
+
],
|
| 1324 |
+
[
|
| 1325 |
+
18,
|
| 1326 |
+
30,
|
| 1327 |
+
1,
|
| 1328 |
+
7,
|
| 1329 |
+
1,
|
| 1330 |
+
"CLIP"
|
| 1331 |
+
]
|
| 1332 |
+
],
|
| 1333 |
+
"groups": [
|
| 1334 |
+
{
|
| 1335 |
+
"id": 1,
|
| 1336 |
+
"title": "READ ME -- writing for this node",
|
| 1337 |
+
"bounding": [
|
| 1338 |
+
-990,
|
| 1339 |
+
-465,
|
| 1340 |
+
980,
|
| 1341 |
+
2560
|
| 1342 |
+
],
|
| 1343 |
+
"color": "#3f789e",
|
| 1344 |
+
"font_size": 24
|
| 1345 |
+
}
|
| 1346 |
+
],
|
| 1347 |
+
"config": {},
|
| 1348 |
+
"extra": {
|
| 1349 |
+
"ds": {
|
| 1350 |
+
"scale": 0.8264462809917354,
|
| 1351 |
+
"offset": [
|
| 1352 |
+
500,
|
| 1353 |
+
-281.1386834420914
|
| 1354 |
+
]
|
| 1355 |
+
}
|
| 1356 |
+
},
|
| 1357 |
+
"version": 0.4
|
| 1358 |
+
}
|