diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ab0ca5..46599680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index 4cb6acc1..02a554d0 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -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 diff --git a/movie_editor/backend/settings_card.py b/movie_editor/backend/settings_card.py index be86adc7..34392b9d 100644 --- a/movie_editor/backend/settings_card.py +++ b/movie_editor/backend/settings_card.py @@ -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), diff --git a/movie_editor/tests/test_builder.py b/movie_editor/tests/test_builder.py index 85f5cf00..71c6e9c5 100644 --- a/movie_editor/tests/test_builder.py +++ b/movie_editor/tests/test_builder.py @@ -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"]) diff --git a/tests/test_settings_card_noise.py b/tests/test_settings_card_noise.py index 66edfe70..764280b5 100644 --- a/tests/test_settings_card_noise.py +++ b/tests/test_settings_card_noise.py @@ -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)"