From 5d540d43bcc31f9f692748b2120df97dd06902f5 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:12:37 +0300 Subject: [PATCH] Fix identity pin breaking generation on carry-only runs. Wire funpack_scene_media_refs even without mixed anchors, keep guide-only pin images off the run anchor LoadImage, and return 400 when pin media is missing. Co-authored-by: Cursor --- movie_editor/server.py | 49 ++++++++++++++++++++++++------- movie_editor/tests/test_server.py | 47 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/movie_editor/server.py b/movie_editor/server.py index 7911848a..018e27ad 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -200,7 +200,7 @@ def _prepare_media(proj: Project, extra_refs: Optional[list] = None) -> Optional by_scene: dict = {} seen_refs: set = set() - def _add_ref(ref: str, tgt=None, sid: Optional[str] = None) -> None: + def _add_ref(ref: str, tgt=None, sid: Optional[str] = None, *, set_primary: bool = True) -> None: nonlocal primary if not ref or ref in seen_refs: return @@ -213,7 +213,7 @@ def _prepare_media(proj: Project, extra_refs: Optional[list] = None) -> Optional by_scene[sid] = entry else: by_scene[f"_ref_{ref}"] = entry - if primary is None: + if set_primary and primary is None: primary = {"filename": fn, "target": tgt} for sc in proj.scenes: @@ -225,9 +225,10 @@ def _prepare_media(proj: Project, extra_refs: Optional[list] = None) -> Optional stype = getattr(src, "type", "") if src else "" if stype not in ("image", "generated_frame", "mixed") or not ref: continue - _add_ref(ref, tgt, sc.id) + _add_ref(ref, tgt, sc.id, set_primary=True) for ref in (extra_refs or []): - _add_ref(str(ref)) + # Guide-only assets (identity pin, prior-scene borrow) must not become the run anchor. + _add_ref(str(ref), set_primary=False) if not by_scene: return None return {"primary": primary, "by_scene": by_scene} @@ -410,11 +411,37 @@ def _run_sampler_inputs(target: Project, scene_count: int, full: Optional[Projec return samp +def _scene_media_ref_map(media_pack: Optional[dict]) -> dict[str, str]: + if not media_pack: + return {} + by_scene = media_pack.get("by_scene") or {} + return { + entry["media_ref"]: entry["filename"] + for entry in by_scene.values() + if isinstance(entry, dict) and entry.get("media_ref") and entry.get("filename") + } + + +def _missing_continuity_media(full: Project, target: Project) -> list[str]: + """Media-bin ids referenced by continuity guides/pins that are missing on disk.""" + from .backend.timeline import continuity_media_refs + + missing: list[str] = [] + for ref in continuity_media_refs(full, target): + if not media.path_for(ref): + missing.append(ref) + return missing + + def _attach_scene_anchors(samp: dict, media_pack: Optional[dict], target: Project) -> dict: """Mixed-source i2v anchors (Img2Video starting latents), separate from guides.""" import json from .backend.timeline import build_scene_anchors_payload + by_ref = _scene_media_ref_map(media_pack) + if by_ref: + samp["funpack_scene_media_refs"] = json.dumps(by_ref) + anchors = build_scene_anchors_payload(target) if not anchors or not media_pack: return samp @@ -428,13 +455,6 @@ def _attach_scene_anchors(samp: dict, media_pack: Optional[dict], target: Projec enriched[idx] = {**meta, "filename": fn} if enriched: samp["funpack_scene_anchors"] = json.dumps(enriched) - by_ref = { - entry["media_ref"]: entry["filename"] - for entry in by_scene.values() - if isinstance(entry, dict) and entry.get("media_ref") and entry.get("filename") - } - if by_ref: - samp["funpack_scene_media_refs"] = json.dumps(by_ref) return samp @@ -950,6 +970,13 @@ if web is not None and PromptServer is not None: {"detail": "Nothing to generate — all clips are video-only or excluded."}, status=400, ) + missing_media = _missing_continuity_media(p, target) + if missing_media: + ids = ", ".join(missing_media) + return web.json_response( + {"detail": f"Missing media for identity pin or continuity guides ({ids}). Re-upload or pick another image."}, + status=400, + ) # V1 uniform chain: if trimmed scenes all agree on a frame count, use it. # This makes the timeline trim handle actually affect generation length, # provided the user has linked EmptyLTXVLatent.num_frames → Project Frames diff --git a/movie_editor/tests/test_server.py b/movie_editor/tests/test_server.py index 18fe493e..3cc422cc 100644 --- a/movie_editor/tests/test_server.py +++ b/movie_editor/tests/test_server.py @@ -126,6 +126,53 @@ def test_carry_chain_auto_mid_scene_guide(): assert guides["scenes"][1][0]["source"] == "template" +def test_identity_pin_media_refs_without_mixed_anchors(): + import json + from movie_editor.server import _attach_scene_anchors + + full = _project( + continuity_settings={"identity_pin_ref": "pin1"}, + scenes=[{"id": "s1", "text": "a"}, {"id": "s2", "text": "b", "source": {"type": "carry"}}], + ) + samp = _run_sampler_inputs(full, 2, full=full) + assert "funpack_scene_guides" in samp + media_pack = { + "primary": None, + "by_scene": {"_ref_pin1": {"filename": "funpack_movie_pin1.png", "media_ref": "pin1"}}, + } + _attach_scene_anchors(samp, media_pack, full) + refs = json.loads(samp["funpack_scene_media_refs"]) + assert refs["pin1"] == "funpack_movie_pin1.png" + assert "funpack_scene_anchors" not in samp + + +def test_prepare_media_extra_refs_do_not_become_primary(monkeypatch, tmp_path): + from movie_editor import server + from movie_editor.backend import config, media + + indir = tmp_path / "input" + indir.mkdir() + mediadir = tmp_path / "media" + mediadir.mkdir() + (mediadir / "pin1.png").write_bytes(b"fake") + + monkeypatch.setattr(config, "MEDIA_DIR", mediadir) + monkeypatch.setattr(media, "path_for", lambda mid: mediadir / "pin1.png" if mid == "pin1" else None) + + class FP: + @staticmethod + def get_input_directory(): + return str(indir) + + monkeypatch.setitem(__import__("sys").modules, "folder_paths", FP) + + p = _project(scenes=[{"id": "s1", "text": "a", "source": {"type": "carry"}}]) + pack = server._prepare_media(p, ["pin1"]) + assert pack is not None + assert pack["primary"] is None + assert pack["by_scene"]["_ref_pin1"]["filename"].startswith("funpack_movie_pin1") + + def test_studio_inputs_skips_custom_conditioning(): p = _project([]) p.conditioning_slot = "custom"