fix: an unlinked H3 latent length follows the project's frames; export shows it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 18:05:29 +03:00
co-authored by Claude Sonnet 5.5
parent b036a533aa
commit 389fbba275
5 changed files with 67 additions and 1 deletions
+2
View File
@@ -3,6 +3,8 @@
## [Unreleased]
### Fixed
- An H3 latent node (Reference-to-Video / Empty AV Latent) whose `length` is not linked now follows the project's frames, instead of keeping an old typed number that made the sampler refuse the run; the settings export shows it as "follows project".
- A view added by Shot views (POV...) no longer makes the shot look like it already has a camera move.
- Export settings no longer prints a loader input the node no longer has (a stale `sla_enabled: on`) or SLA's settings while `sla` is off.
### Added
+17 -1
View File
@@ -185,6 +185,10 @@ FAMILY_FRAME_GRID: dict[str, dict] = {
}
#: nodes that make the H3 latent template; their `length` must equal the sampler's frames.
H3_LENGTH_NODES = frozenset({"MiniMaxH3ReferenceToVideo", "EmptyMiniMaxH3LatentAV"})
def family_frames(family: str, frames) -> int:
"""`frames` snapped UP to `family`'s pixel-frame grid (never below one whole step)."""
grid = FAMILY_FRAME_GRID.get(family) or FAMILY_FRAME_GRID[DEFAULT_FAMILY]
@@ -683,7 +687,19 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
for s in slots:
sid = slot_node_id[s["id"]]
nd_s = slot_def[s["id"]]
for ci_name, source in (s.get("input_sources") or {}).items():
sources = dict(s.get("input_sources") or {})
# An H3 latent node has to be built for the SAME length the sampler is given or the run
# dies at sampling ("latent node's `length` must be the SAME ... frame count"). Left
# unlinked it keeps whatever number it was last typed with, so changing the project's
# length silently stranded it. Unlinked = follows the project; a choice stays a choice.
if (s.get("node_class") in H3_LENGTH_NODES and not sources.get("length")
and "frames" in graph and "length" in (graph[sid].get("inputs") or {})
and not isinstance(graph[sid]["inputs"]["length"], list)):
was = graph[sid]["inputs"]["length"]
sources["length"] = "core:frames:0"
report["wired"].append(f"{s.get('node_class')} length ({was}) -> Project frames "
f"({graph['frames']['inputs'].get('value')})")
for ci_name, source in sources.items():
ci_name = _canonical_input(nd_s, ci_name)
if not source or source == "auto":
continue
+11
View File
@@ -550,6 +550,11 @@ def _sampling_sections(studio_inputs: dict, sampler_inputs: dict) -> list:
return out
def _h3_length_nodes():
from .builder import H3_LENGTH_NODES
return H3_LENGTH_NODES
def collect(models: dict, host: dict, *, project_name=None, version=None,
codename=None, render=None, studio_inputs=None, sampler_inputs=None) -> dict:
"""The card's content, as data. Rendering is a separate step so this is testable."""
@@ -580,6 +585,12 @@ def collect(models: dict, host: dict, *, project_name=None, version=None,
rows.append((name, ""))
rows.extend((f" {k}", v) for k, v in listed)
continue
if (name == "length" and slot.get("node_class") in _h3_length_nodes()
and not (slot.get("input_sources") or {}).get("length")
and (render or {}).get("frames per scene")):
# The builder links an unlinked H3 latent length to the project's frames, so
# the typed number is not what runs.
value = f"follows project ({render['frames per scene']})"
rows.append((name, _short(value)))
sections.append({
"title": str(title),
+25
View File
@@ -1613,3 +1613,28 @@ def test_studio_skips_its_own_enhancement_when_nothing_reads_its_conditioning():
"slots": [{"id": "r2v", "node_class": "R2V", "inputs": {}, "wires": {}}],
"core_overrides": {"sampler": {"positive": "out:r2v:CONDITIONING"}}}
assert rf_of(bypassed)["prompt_enhance_scenes"] is False
def _lat(graph):
return next(n for n in graph.values() if n["class_type"] == "EmptyMiniMaxH3LatentAV")
def test_an_unlinked_h3_latent_length_follows_the_projects_frames():
"""The project length was changed to 362 and the latent node kept its typed 260: the
sampler then refuses the run. Unlinked = follows the project."""
models = {"model_family": "minimax_h3", "slots": [
dict(s, inputs={"length": 260}) if s["id"] == "lat" else s for s in H3_MODELS["slots"]]}
graph, report = builder.build(H3_OI, models, {"prompt": "a shot", "num_frames_per_scene": 362})
length = _lat(graph)["inputs"]["length"]
assert isinstance(length, list) and length[0] == "frames"
assert graph["frames"]["inputs"]["value"] == 362
assert any("length (260)" in w and "362" in w for w in report["wired"]), report["wired"]
def test_a_linked_h3_latent_length_is_left_as_the_user_linked_it():
models = {"model_family": "minimax_h3", "slots": [
dict(s, inputs={"length": 260}, input_sources={"length": "core:frames:0"})
if s["id"] == "lat" else s for s in H3_MODELS["slots"]]}
graph, report = builder.build(H3_OI, models, {"prompt": "a shot", "num_frames_per_scene": 362})
assert _lat(graph)["inputs"]["length"][0] == "frames"
assert not any("length (260)" in w for w in report["wired"])
+12
View File
@@ -254,3 +254,15 @@ def test_a_model_slot_does_not_print_a_stale_input_or_sla_settings_with_sla_off(
rows = dict(next(s for s in sc.collect({"slots": [slot]}, {})["sections"]
if s["title"] == "Diffusion model")["rows"])
assert "sla_sparsity" in rows and "sla_enabled" not in rows
def test_an_unlinked_h3_latent_length_is_shown_as_following_the_project():
slot = {"id": "r", "node_class": "MiniMaxH3ReferenceToVideo", "label": "R2V",
"inputs": {"width": 1344, "length": 260}, "input_sources": {"length": ""}}
card = sc.collect({"slots": [slot]}, {}, render={"frames per scene": 362})
rows = dict(next(s for s in card["sections"] if s["title"] == "R2V")["rows"])
assert rows["length"] == "follows project (362)"
slot["input_sources"]["length"] = "core:frames:0"
rows = dict(next(s for s in sc.collect({"slots": [slot]}, {}, render={"frames per scene": 362})["sections"]
if s["title"] == "R2V")["rows"])
assert rows["length"] != "follows project (362)"