From 682fe95bb3695e9dbf3c59777deaa133beab8fc8 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:30:14 +0300 Subject: [PATCH] Mixed mode: skip frame overlap, keep one chain run Mixed i2v anchors no longer blend the previous scene tail into the starting chunk or the stitched output. Dragging an image onto a continuation scene auto-sets mixed so montage stays one queue request. Preview in-points skip overlap before mixed scenes. --- movie_editor/frontend/store.js | 25 +++++++++++++++----- samplers.py | 14 ++++-------- tests/test_scene_chain_sampler.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index c0254cc7..51189f8e 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -665,9 +665,9 @@ state.sceneGhosts = (state.sceneGhosts || []).filter((g) => !ids.has(g.afterSceneId) && !ids.has(g.id)); } - // Pixel overlap between consecutive chain-sampler scenes (carry/i2v guide runs). - function _chainOverlapSec(runSceneCount) { - if (!runSceneCount || runSceneCount < 2) return 0; + // Pixel overlap between two consecutive chain scenes (0 when the next scene is mixed). + function _overlapBetweenScenes(prev, curr) { + if (!prev || !curr || (curr.source?.type) === "mixed") return 0; const frames = +(state.project?.sampler_inputs?.frame_overlap ?? 16); const fps = state.project?.frame_rate || 25; return Math.max(0, frames / fps); @@ -676,7 +676,6 @@ function _recordSegment(mediaList, targetSceneIds) { if (!mediaList || !mediaList.length || !targetSceneIds || !targetSceneIds.length) return; const primary = mediaList.find((m) => m.kind === "videos" || m.kind === "gifs") || mediaList[0]; - const overlapSec = _chainOverlapSec(targetSceneIds.length); let sourceEnd = 0; let clearedRating = false; _pruneGhostsAfterRegen(targetSceneIds); @@ -685,7 +684,7 @@ const sc = scene(id); if (!sc) continue; const prev = i > 0 ? scene(targetSceneIds[i - 1]) : null; const sameUnit = prev && genUnitId(prev) === genUnitId(sc); - const inSec = i === 0 ? 0 : (sameUnit ? sourceEnd : Math.max(0, sourceEnd - overlapSec)); + const inSec = i === 0 ? 0 : (sameUnit ? sourceEnd : Math.max(0, sourceEnd - _overlapBetweenScenes(prev, sc))); state.sceneRenders[id] = { media: primary, inSec }; // New render invalidates the prior RLHF rating — UI shows "— rate —"; server // uses continue-refinement (session memory, no new rating) on the next Studio run. @@ -1015,9 +1014,23 @@ const t = (s.text || "").trim(); patchScene(s.id, { text: t ? `${t} ${trigger}` : trigger }); return true; } + // True when this scene continues the current chain run (not a new run anchor). + function _continuesChainRun(s) { + const active = state.project.scenes.filter((sc) => !sc.excluded); + const idx = active.findIndex((sc) => sc.id === s.id); + if (idx <= 0) return false; + const t = (s.source && s.source.type) || "empty"; + if (t === "empty") return false; + if (isGenSubclip(s)) return true; + if (t === "carry" || t === "mixed") return true; + if ((t === "image" || t === "generated_frame") && _anchorAvailable(s)) return false; + return true; + } + function assignMediaToScene(sceneId, mediaId) { const s = scene(sceneId); if (!s) return; - const t = (s.source?.type === "mixed") ? "mixed" : "image"; + const t = (s.source?.type === "mixed") ? "mixed" + : (_continuesChainRun(s) ? "mixed" : "image"); patchScene(sceneId, { source: { ...(s.source || {}), type: t, media_ref: mediaId } }); } diff --git a/samplers.py b/samplers.py index bfd5a8d9..8e2aed12 100644 --- a/samplers.py +++ b/samplers.py @@ -3080,7 +3080,7 @@ class FunPackLTXAVSceneChainSampler: return out if out is not None else chunk def _build_mixed_anchor_chunk(self, vae, anchor_meta, latent_template, previous, video_overlap): - """Mixed source: Img2Video anchor latent, then chain overlap from previous scene.""" + """Mixed source: Img2Video anchor latent only — prior scene overlap is not used.""" filename = (anchor_meta or {}).get("filename") strength = float((anchor_meta or {}).get("strength", 1.0)) image = self._load_image_tensor(filename) if filename else None @@ -3089,10 +3089,7 @@ class FunPackLTXAVSceneChainSampler: return self._clone_latent(latent_template) return self._build_continuation_chunk(latent_template, previous, video_overlap) base = self._clone_latent(latent_template) - i2v_chunk = self._apply_img2video_to_video_latent(vae, image, base, strength) - if previous is None: - return i2v_chunk - return self._build_continuation_chunk(i2v_chunk, previous, video_overlap) + return self._apply_img2video_to_video_latent(vae, image, base, strength) def _encode_image_guide_frame(self, filename, vae, ref_tensor): import os @@ -3389,10 +3386,8 @@ class FunPackLTXAVSceneChainSampler: "pixel_frame": max(0, boundary_pixel), "effect": effect if effect and transition_duration > 0 else None, }) - if video_overlap > 0: - run_mechanisms.append(f"latent_overlap({frame_overlap}px)") chunk = self._build_mixed_anchor_chunk( - vae, anchor_meta, latent_template, output, video_overlap, + vae, anchor_meta, latent_template, output, 0, ) else: # Record boundary before blending @@ -3480,7 +3475,8 @@ class FunPackLTXAVSceneChainSampler: if guide_tail > 0: sampled = self._crop_video_tail(sampled, guide_tail) scene_outputs.append(self._clone_latent(sampled)) - output = sampled if output is None else self._blend_latents(output, sampled, video_overlap) + blend_overlap = 0 if anchor_meta else video_overlap + output = sampled if output is None else self._blend_latents(output, sampled, blend_overlap) cumulative_latent_frames = self._tensor_frames(self._latent_tensors(output)[0]) scene_meta = self._scene_meta(scene_cond, scene_index) scene_runs.append({ diff --git a/tests/test_scene_chain_sampler.py b/tests/test_scene_chain_sampler.py index 8778acd4..b58ff30d 100644 --- a/tests/test_scene_chain_sampler.py +++ b/tests/test_scene_chain_sampler.py @@ -476,3 +476,41 @@ def test_overlap_diagnostics_report_latent_blend_zone(): assert parsed["scene_count"] == 2 assert "boundaries" in parsed assert "overlap_blend=2px" in status + + +def test_mixed_anchor_skips_frame_overlap(): + import json + + sample_calls.clear() + node = FunPackLTXAVSceneChainSampler() + node._load_image_tensor = lambda _fn: torch.ones(1, 8, 8, 3) + node._apply_img2video_to_video_latent = lambda _vae, _img, chunk, _strength: node._clone_latent(chunk) + + positive = [scene_cond(0), scene_cond(1)] + latent_template = {"samples": torch.zeros(1, 2, 5, 3, 3)} + anchors = json.dumps({"1": {"filename": "anchor.png", "strength": 1.0}}) + + latent, _images, status, scene_count, _report, boundaries_json = node.sample( + model=object(), + vae=FakeVAE(), + positive=positive, + negative=[], + sampler=object(), + sigmas=torch.tensor([1.0, 0.0]), + seed=80, + latent_template=latent_template, + num_frames_per_scene=5, + frame_overlap=2, + cfg=1.0, + max_scenes=2, + funpack_scene_anchors=anchors, + prompt={"n1": {"inputs": {}}}, + unique_id="test-node", + ) + + assert scene_count == 2 + assert latent["samples"].shape[2] == 10 + parsed = json.loads(boundaries_json) + mechs = parsed["scenes"][1]["mechanisms"] + assert "mixed_i2v_anchor" in mechs + assert not any("latent_overlap" in m for m in mechs)