Remove standalone scene transition nodes

This commit is contained in:
digital-garbage
2026-05-20 19:29:33 +03:00
parent 74b50174d7
commit e8f0b0f83f
10 changed files with 31 additions and 520 deletions
-10
View File
@@ -21,7 +21,6 @@ if __package__:
)
from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from .samplers import FunPackHybridEuler2SSampler, FunPackDistilledFlowSampler
from .context_transition import FunPackSceneCutWindows, FunPackSceneNoise
from .templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
else:
# Standalone tests may not have the full ComfyUI/CUDA runtime loaded.
@@ -60,11 +59,6 @@ else:
except Exception:
FunPackHybridEuler2SSampler = None
FunPackDistilledFlowSampler = None
try:
from context_transition import FunPackSceneCutWindows, FunPackSceneNoise
except Exception:
FunPackSceneCutWindows = None
FunPackSceneNoise = None
try:
from templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
except Exception:
@@ -90,8 +84,6 @@ NODE_CLASS_MAPPINGS = {
"FunPackSaveRefinementLatent": FunPackSaveRefinementLatent,
"FunPackHybridEuler2SSampler": FunPackHybridEuler2SSampler,
"FunPackDistilledFlowSampler": FunPackDistilledFlowSampler,
"FunPackSceneCutWindows": FunPackSceneCutWindows,
"FunPackSceneNoise": FunPackSceneNoise,
"FunPackApplyLoraWeights": FunPackApplyLoraWeights,
"FunPackLoraLoader": FunPackLoraLoader,
"FunPackRefinementKeyLoader": FunPackRefinementKeyLoader,
@@ -116,8 +108,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FunPackSaveRefinementLatent": "FunPack Save Refinement Latent",
"FunPackHybridEuler2SSampler": "FunPack Hybrid Euler 2S Sampler",
"FunPackDistilledFlowSampler": "FunPack Distilled Flow Sampler",
"FunPackSceneCutWindows": "FunPack Scene Cut Windows",
"FunPackSceneNoise": "FunPack Scene Noise",
"FunPackApplyLoraWeights": "FunPack Apply LoRA Weights",
"FunPackLoraLoader": "FunPack LoRA Loader",
"FunPackRefinementKeyLoader": "FunPack Refinement Key Loader",
+19 -82
View File
@@ -5841,7 +5841,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
"split_by_transitions": ("BOOLEAN", {
"default": False,
"label": "Split by Transitions",
"tooltip": "Detect transition words in the prompt (e.g. 'then', 'suddenly', 'cut to') and encode each scene segment as a separate conditioning entry. The character description (text before the first comma) is prepended to every segment. Designed to work with FunPack Context Transition Windows using split_conds_to_windows.",
"tooltip": "Detect transition words in the prompt and preview scene segments in the encoded prompts output. The refiner still returns one full-prompt conditioning entry.",
}),
"latent": ("LATENT", {
"tooltip": "Optional video latent for creativity masking. Takes priority over any saved latent for this key. Connect your i2v or previous KSampler output here.",
@@ -6108,82 +6108,21 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
return segments if len(segments) > 1 else [text]
def _v2_contrastive_cond(self, scene_cond, general_cond, contrast):
"""Push scene_cond further away from general_cond by the contrast factor.
Formula: general + contrast × (scene − general)
At contrast=1.0 → plain scene_cond (no change).
At contrast=2.0 → 2×scene − general (classic contrastive).
The character description is shared between scene_cond and general_cond,
so it cancels in the delta. The push focuses on what is unique to the scene.
Returns scene_cond unchanged if shapes mismatch or contrast is 1.0.
"""
if contrast == 1.0 or not isinstance(general_cond, torch.Tensor):
return scene_cond
try:
if scene_cond.shape != general_cond.shape:
return scene_cond
gen = general_cond.to(dtype=scene_cond.dtype, device=scene_cond.device)
return gen + contrast * (scene_cond - gen)
except Exception:
return scene_cond
def _v2_encode_per_window_conditionings(self, clip, segments, general_cond=None,
contrast=1.0, encode_cache=None):
"""Encode one conditioning entry per scene window.
segments[0] is split into character description + first-scene context.
Each subsequent segment is combined with the character description to form
that window's conditioning.
When general_cond (the full-prompt conditioning) and contrast > 1.0 are
provided, each window's conditioning is pushed further toward its unique
scene content and away from the shared baseline via:
window_i = general + contrast × (scene_i − general)
Returns (conditionings, window_texts) where conditionings is a list of
(tensor, meta) pairs and window_texts is the prompt strings per window.
Returns (None, []) on failure.
"""
if not segments or clip is None:
return None, []
def _v2_transition_scene_texts(self, segments):
if not segments:
return []
if len(segments) == 1:
cond, meta, _ = self._v2_encode_prompt(clip, segments[0], encode_cache=encode_cache)
if not isinstance(cond, torch.Tensor):
return None, []
return [(cond, meta)], [segments[0]]
return [segments[0]]
# The entire first segment (everything before the first transition word) is the anchor.
# Splitting at the first comma within it breaks prompts like
# "In this anime video, Character Name is a..." where the comma is inside the description.
# Use the whole first segment as the anchor. Splitting at the first comma
# breaks prompts like "In this anime video, Character Name is a...".
anchor = segments[0].strip()
conditionings = []
window_texts = []
cond0_raw, meta0, _ = self._v2_encode_prompt(clip, anchor, encode_cache=encode_cache)
if not isinstance(cond0_raw, torch.Tensor):
return None, []
cond0 = self._v2_contrastive_cond(cond0_raw, general_cond, contrast)
conditionings.append((cond0, meta0))
window_texts.append(anchor)
scene_texts = [anchor]
for seg in segments[1:]:
seg = seg.strip()
if not seg:
continue
combined = anchor + ", " + seg
cond_i_raw, meta_i, _ = self._v2_encode_prompt(clip, combined, encode_cache=encode_cache)
if isinstance(cond_i_raw, torch.Tensor):
cond_i = self._v2_contrastive_cond(cond_i_raw, general_cond, contrast)
conditionings.append((cond_i, meta_i))
else:
conditionings.append((cond0, meta0))
window_texts.append(combined)
if len(conditionings) <= 1:
return [(cond0, meta0)], [anchor]
return conditionings, window_texts
if seg:
scene_texts.append(anchor + ", " + seg)
return scene_texts
def _v2_conditioning_vector(self, conditioning):
if not isinstance(conditioning, torch.Tensor) or conditioning.dim() <= 1:
@@ -11221,22 +11160,20 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
# When split_by_transitions is on, detect scene segments and show them in
# encoded_prompts for transparency, but output a SINGLE full-prompt conditioning.
# Multi-entry conditioning without a working per-window routing mechanism just
# accumulates all scene conditionings onto every frame, which confuses the model.
# Multi-entry conditioning accumulates all scene conditionings onto every frame,
# which confuses the model.
# The transition words in the full prompt already guide the model's natural scene cuts.
output_conditioning = [(refined, meta)]
if split_by_transitions and clip is not None:
if split_by_transitions:
try:
segments = self._v2_split_prompt_by_transitions(prompt_to_encode)
if len(segments) > 1:
_, window_texts = self._v2_encode_per_window_conditionings(
clip, segments, encode_cache=encode_cache,
)
if window_texts:
scene_texts = self._v2_transition_scene_texts(segments)
if scene_texts:
status_suffix = f"\nTransition split: {len(segments)} scenes detected"
status = status + status_suffix + enhancement_status
window_lines = "\n".join(
f" Scene {i + 1}: {t}" for i, t in enumerate(window_texts)
scene_lines = "\n".join(
f" Scene {i + 1}: {t}" for i, t in enumerate(scene_texts)
)
split_encoded_prompts = (
self._v2_encoded_prompts_output(
@@ -11245,7 +11182,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
pre_advisor_prompt=pre_advisor_prompt,
advisor_suggested=advisor_suggested,
)
+ f"\n\nDetected scenes (for FunPack Scene Noise):\n{window_lines}"
+ f"\n\nDetected scenes:\n{scene_lines}"
)
return (
output_conditioning,
-220
View File
@@ -1,220 +0,0 @@
import logging
import torch
import comfy.ldm.modules.attention as _attn_mod
# ---------------------------------------------------------------------------
# Scene-cut attention masking
# ---------------------------------------------------------------------------
def _scene_split_self_attn(orig_fn, q, k, v, heads, num_scenes, cache, *args, **kwargs):
"""Run self-attention in per-scene chunks so tokens in different scenes never attend each other.
Scene 0's tokens are also available as key/value to all other scenes.
This preserves i2v guide-frame attention: the reference image lives in scene 0's
temporal region, and later scenes need to see it for visual consistency.
"""
seq_len = q.shape[1]
cache_key = (seq_len, num_scenes, q.device.type)
if cache_key not in cache:
tokens_per_scene = max(1, seq_len // num_scenes)
ids = torch.arange(seq_len, device=q.device)
cache[cache_key] = (ids // tokens_per_scene).clamp(max=num_scenes - 1)
scene_ids = cache[cache_key]
scene0_mask = (scene_ids == 0)
out = torch.zeros_like(q)
for scene_id in range(num_scenes):
query_mask = (scene_ids == scene_id)
if not query_mask.any():
continue
# Each scene queries its own tokens; keys/values come from own tokens + scene 0
kv_mask = query_mask | scene0_mask if scene_id > 0 else query_mask
out[:, query_mask, :] = orig_fn(
q[:, query_mask, :], k[:, kv_mask, :], v[:, kv_mask, :], heads, *args, **kwargs
)
return out
class FunPackSceneCutHandler:
"""Reads num_scenes from conditioning on each step; never activates context windows."""
def __init__(self):
self.num_scenes = 1
def should_use_context(self, model, conds, x_in, timestep, model_options):
try:
self.num_scenes = max((len(c) for c in conds if c), default=1)
except Exception:
self.num_scenes = 1
logging.info(
"FunPack scene cut: %s scene(s) detected from conditioning.",
self.num_scenes,
)
return False # full-pass denoising; masking applied inside each forward
class FunPackSceneCutWindows:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {
"tooltip": "The model to apply scene-boundary attention masking to.",
}),
},
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "apply"
CATEGORY = "FunPack/Sampling"
DESCRIPTION = (
"Enforces scene cuts by blocking temporal self-attention across scene boundaries. "
"All frames are denoised in one forward pass (full temporal attention preserved "
"within each scene). Works for both LTXV and LTXAV. "
"Connect after Refiner V2 or Studio with 'Split by Transitions' enabled. "
"Scene count is auto-detected from the number of conditioning entries."
)
def apply(self, model):
model = model.clone()
attn_mask_cache = {}
handler = FunPackSceneCutHandler()
model.model_options["context_handler"] = handler
old_wrapper = model.model_options.get("model_function_wrapper")
def _scene_wrapper(apply_fn, args, _old=old_wrapper):
num_scenes = handler.num_scenes
if num_scenes <= 1:
if _old is not None:
return _old(apply_fn, args)
return apply_fn(args["input"], args["timestep"], **args.get("c", {}))
orig_attn = _attn_mod.optimized_attention
orig_attn_masked = _attn_mod.optimized_attention_masked
def _patched(q, k, v, heads, *a, **kw):
if q.shape[1] == k.shape[1]:
return _scene_split_self_attn(orig_attn, q, k, v, heads, num_scenes, attn_mask_cache, *a, **kw)
return orig_attn(q, k, v, heads, *a, **kw)
def _patched_masked(q, k, v, heads, mask, *a, **kw):
if q.shape[1] == k.shape[1]:
return _scene_split_self_attn(orig_attn_masked, q, k, v, heads, num_scenes, attn_mask_cache, *a, **kw)
return orig_attn_masked(q, k, v, heads, mask, *a, **kw)
_attn_mod.optimized_attention = _patched
_attn_mod.optimized_attention_masked = _patched_masked
try:
if _old is not None:
result = _old(apply_fn, args)
else:
result = apply_fn(args["input"], args["timestep"], **args.get("c", {}))
finally:
_attn_mod.optimized_attention = orig_attn
_attn_mod.optimized_attention_masked = orig_attn_masked
return result
model.model_options["model_function_wrapper"] = _scene_wrapper
return (model,)
# ---------------------------------------------------------------------------
# Seeded temporal noise: reinforce scene cuts via distinct noise per region
# ---------------------------------------------------------------------------
class FunPackSceneNoise:
"""Generate initial noise with different seeds per scene temporal region.
For t2v: connect an empty latent to 'latent', output goes to
KSamplerAdvanced latent_image with add_noise=disable.
For i2v: connect your image latent to BOTH 'latent' AND 'image_latent'.
The scene noise is added on top of the image latent so the reference
image is preserved. Use with KSamplerAdvanced add_noise=disable.
Scene count is auto-detected from the 'positive' conditioning entries.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT", {"tooltip": "Defines shape/device/dtype. For i2v also connect your image latent to image_latent."}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {
"positive": ("CONDITIONING", {"tooltip": "Auto-detects scene count from conditioning entries."}),
"image_latent": ("LATENT", {"tooltip": "i2v only: image latent to blend scene noise onto. Scene noise is added on top so the reference image is preserved."}),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("noise",)
FUNCTION = "generate"
CATEGORY = "FunPack/Sampling"
def generate(self, latent, seed, positive=None, image_latent=None):
samples = latent["samples"]
num_scenes = max(1, len(positive)) if positive else 1
def _make_noise(shape, s):
rng = torch.Generator(device=samples.device)
rng.manual_seed(s)
return torch.randn(shape, generator=rng, dtype=samples.dtype, device=samples.device)
if num_scenes <= 1:
base_noise = _make_noise(samples.shape, seed)
if image_latent is not None:
img = image_latent["samples"].to(dtype=samples.dtype, device=samples.device)
if img.shape == base_noise.shape:
base_noise = img + base_noise
return ({"samples": base_noise},)
temporal_dim = None
for d in [2, 1, 0] + list(range(3, samples.dim())):
if 1 < int(samples.size(d)) <= 4096:
temporal_dim = d
break
if temporal_dim is None:
base_noise = _make_noise(samples.shape, seed)
if image_latent is not None:
img = image_latent["samples"].to(dtype=samples.dtype, device=samples.device)
if img.shape == base_noise.shape:
base_noise = img + base_noise
return ({"samples": base_noise},)
total_frames = int(samples.size(temporal_dim))
noise = torch.zeros_like(samples)
scene_size = total_frames // num_scenes
for i in range(num_scenes):
scene_seed = (seed + i * 31337) & 0xffffffffffffffff
start = i * scene_size
end = (i + 1) * scene_size if i < num_scenes - 1 else total_frames
slices = [slice(None)] * samples.dim()
slices[temporal_dim] = slice(start, end)
scene_shape = list(samples.shape)
scene_shape[temporal_dim] = end - start
noise[tuple(slices)] = _make_noise(scene_shape, scene_seed)
if image_latent is not None:
img = image_latent["samples"].to(dtype=samples.dtype, device=samples.device)
if img.shape == noise.shape:
noise = img + noise
logging.info(
"FunPack scene noise: %s scenes, temporal_dim=%s, total_frames=%s, i2v=%s.",
num_scenes, temporal_dim, total_frames, image_latent is not None,
)
return ({"samples": noise},)
-45
View File
@@ -1,45 +0,0 @@
# FunPack Context Transition Windows
`FunPack Context Transition Windows` is a model wrapper for forcing video context resets without changing sampler sigmas or adding latent noise. It borrows Comfy's manual context-window mechanism, then splits the window schedule at transition boundaries so the model stops seeing the entire previous segment when denoising the next one.
Use it before sampling:
```text
MODEL -> FunPack Context Transition Windows -> sampler/model input path
```
## Why This Exists
The sampler-level motion pulses are intentionally conservative: they add monotonic noise kicks, but they do not make the model forget earlier frames. This node targets the stronger behavior: “same scene, viewed differently” by reducing temporal context carryover across chosen parts of the clip.
It does not resample audio and does not add upward sigma jumps. Audio/video errors from Restart-style sigma replay should not be reintroduced by this node.
## Recommended LTX2.3 Starting Point
- `transition_mode`: `aggressive`
- `context_length`: smaller than the latent clip length, commonly `8` to `16`
- `context_overlap`: `2` to `4`
- `context_schedule`: `standard_static`
- `fuse_method`: `pyramid`
- `dim`: `2`
- `transition_start_pct`: `0.30`
- `transition_count`: `2`
- `transition_spacing_pct`: `0.22`
- `transition_strength`: `0.85` to `1.0`
- `cond_retain_index_list`: leave empty for transition testing
If `transition_start_pct` is `0.0`, the first boundary is placed immediately after latent frame `0`. That keeps the upstream image-to-video frame-1 anchor intact, but prevents later windows from treating frame `0` as normal continuous context.
## Behavior
- `off`: uses ordinary manual context windows with no transition boundaries.
- `balanced`: creates one context boundary and allows a small overlap bleed.
- `aggressive`: creates at least two context boundaries and blocks most cross-boundary context.
- `custom`: uses the values exactly as provided.
`transition_strength` controls isolation:
- `1.0`: hard segment split; windows do not cross transition boundaries.
- Lower values: allow a small number of overlap frames around the boundary, based on `context_overlap`.
For strong scene/viewpoint changes, use explicit prompt/refiner language such as orbit, dolly, push-in, pull-back, low angle, high angle, profile view, over-shoulder, or reveal. This node gives those concepts temporal room to form; the conditioning still needs to ask for them.
-2
View File
@@ -96,5 +96,3 @@ The early phase uses a second-order denoised extrapolation (Adams-Bashforth 2-st
Motion pulse state resets after any pulse fires because the pulse modifies the latent and invalidates the previous denoised estimate.
The outgoing `SIGMAS` remain monotonic. Motion pulses happen inside the sampler at selected denoise steps rather than by expanding the schedule.
If you need the model to stop carrying the previous segment's temporal context forward, add `FunPack Context Transition Windows`. It is the stronger transition tool and should be preferred for multi-view or "scene reset" testing.
@@ -3300,7 +3300,6 @@
"name": "VAE",
"type": "VAE",
"linkIds": [
13426,
13426
],
"localized_name": "VAE",
@@ -3342,7 +3341,6 @@
"name": "VAE_2",
"type": "VAE",
"linkIds": [
13428,
13428
],
"localized_name": "VAE_2",
@@ -3371,7 +3369,6 @@
"name": "CLIP",
"type": "CLIP",
"linkIds": [
13435,
13435
],
"localized_name": "CLIP",
@@ -3530,62 +3527,6 @@
false
]
},
{
"id": 5225,
"type": "FunPackContextTransitionWindows",
"pos": [
-3499.400882804922,
4203.790940060165
],
"size": [
346.26666870117185,
394
],
"flags": {},
"order": 8,
"mode": 4,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 14498
}
],
"outputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"links": [
14418
]
}
],
"properties": {
"cnr_id": "ComfyUI-FunPack",
"ver": "ace940fed3f7f2724f784b8d3165871a4e027281",
"Node name for S&R": "FunPackContextTransitionWindows",
"aux_id": "digital-garbage/ComfyUI-FunPack"
},
"widgets_values": [
"aggressive",
121,
73,
"standard_uniform",
"overlap-linear",
0,
0,
3,
0.33,
0,
1,
false,
true,
"",
false
]
},
{
"id": 5004,
"type": "VAELoaderKJ",
@@ -3853,7 +3794,7 @@
"name": "model",
"type": "MODEL",
"links": [
14498
14418
]
},
{
@@ -3970,19 +3911,11 @@
},
{
"id": 14418,
"origin_id": 5225,
"origin_id": 5204,
"origin_slot": 0,
"target_id": -20,
"target_slot": 4,
"type": "MODEL"
},
{
"id": 14498,
"origin_id": 5204,
"origin_slot": 0,
"target_id": 5225,
"target_slot": 0,
"type": "MODEL"
}
],
"extra": {}
@@ -5475,4 +5408,4 @@
"linkExtensions": []
},
"version": 0.4
}
}
+3 -69
View File
@@ -2938,7 +2938,6 @@
"name": "VAE",
"type": "VAE",
"linkIds": [
13426,
13426
],
"localized_name": "VAE",
@@ -2980,7 +2979,6 @@
"name": "VAE_2",
"type": "VAE",
"linkIds": [
13428,
13428
],
"localized_name": "VAE_2",
@@ -3009,7 +3007,6 @@
"name": "CLIP",
"type": "CLIP",
"linkIds": [
13435,
13435
],
"localized_name": "CLIP",
@@ -3361,7 +3358,7 @@
"name": "model",
"type": "MODEL",
"links": [
14498
14418
]
},
{
@@ -3464,61 +3461,6 @@
0,
1
]
},
{
"id": 5225,
"type": "FunPackContextTransitionWindows",
"pos": [
-3499.400882804922,
4203.790940060165
],
"size": [
346.26666870117185,
394
],
"flags": {},
"order": 8,
"mode": 4,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 14498
}
],
"outputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"links": [
14418
]
}
],
"properties": {
"cnr_id": "ComfyUI-FunPack",
"ver": "ace940fed3f7f2724f784b8d3165871a4e027281",
"Node name for S&R": "FunPackContextTransitionWindows"
},
"widgets_values": [
"balanced",
241,
73,
"standard_uniform",
"overlap-linear",
0,
0,
4,
0.2,
1,
1,
false,
true,
"",
false
]
}
],
"groups": [],
@@ -3605,19 +3547,11 @@
},
{
"id": 14418,
"origin_id": 5225,
"origin_id": 5204,
"origin_slot": 0,
"target_id": -20,
"target_slot": 4,
"type": "MODEL"
},
{
"id": 14498,
"origin_id": 5204,
"origin_slot": 0,
"target_id": 5225,
"target_slot": 0,
"type": "MODEL"
}
],
"extra": {}
@@ -5514,4 +5448,4 @@
]
},
"version": 0.4
}
}
-5
View File
@@ -18,7 +18,6 @@ try:
)
from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from .samplers import FunPackHybridEuler2SSampler, sample_funpack_hybrid_euler_2s
from .context_transition import FunPackContextTransitionWindows
from .templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
except ImportError:
from conditioning import (
@@ -37,7 +36,6 @@ except ImportError:
)
from model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from samplers import FunPackHybridEuler2SSampler, sample_funpack_hybrid_euler_2s
from context_transition import FunPackContextTransitionWindows
from templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
NODE_CLASS_MAPPINGS = {
@@ -52,7 +50,6 @@ except ImportError:
"FunPackLorebookEnhancer": FunPackLorebookEnhancer,
"FunPackVideoRefinerV2": FunPackVideoRefinerV2,
"FunPackHybridEuler2SSampler": FunPackHybridEuler2SSampler,
"FunPackContextTransitionWindows": FunPackContextTransitionWindows,
"FunPackApplyLoraWeights": FunPackApplyLoraWeights,
"FunPackLoraLoader": FunPackLoraLoader,
"FunPackRefinementKeyLoader": FunPackRefinementKeyLoader,
@@ -71,7 +68,6 @@ except ImportError:
"FunPackLorebookEnhancer": "FunPack Lorebook Enhancer",
"FunPackVideoRefinerV2": "FunPack Video Refiner V2",
"FunPackHybridEuler2SSampler": "FunPack Hybrid Euler 2S Sampler",
"FunPackContextTransitionWindows": "FunPack Context Transition Windows",
"FunPackApplyLoraWeights": "FunPack Apply LoRA Weights",
"FunPackLoraLoader": "FunPack LoRA Loader",
"FunPackRefinementKeyLoader": "FunPack Refinement Key Loader",
@@ -83,7 +79,6 @@ __all__ = [
"NODE_DISPLAY_NAME_MAPPINGS",
"FunPackVideoRefinerV2",
"FunPackHybridEuler2SSampler",
"FunPackContextTransitionWindows",
"FunPackPromptCombiner",
"FunPackLorebookEnhancer",
"FunPackPromptEnhancer",
+4 -14
View File
@@ -2276,7 +2276,7 @@ def test_split_by_transitions_shows_scenes_in_encoded_prompts(tmp_path):
split_by_transitions=True,
)
# Single conditioning entry (full prompt) - no per-window routing without context windows
# Single conditioning entry - split mode is a prompt preview only.
assert len(cond) == 1
assert "Transition split" in status
assert "Detected scenes" in encoded_prompts
@@ -2312,25 +2312,15 @@ def test_split_prompt_by_transitions_detects_known_phrases():
assert len(segments_none) == 1
def test_per_window_anchor_uses_full_first_segment():
"""Window 0 = full segment[0]; windows 1+ = segment[0] + scene content.
No splitting at the first comma - prevents breaking prompts like
'In this anime video, Character Name is ...'."""
def test_transition_scene_preview_uses_full_first_segment():
refiner = FunPackVideoRefinerV2()
class FakeClip:
def tokenize(self, text): return text
def encode_from_tokens_scheduled(self, t):
import torch
return [(torch.ones(1, 4, 3), {"pooled_output": torch.ones(1, 3)})]
segs = refiner._v2_split_prompt_by_transitions(
"In this anime video, Hiyuki is a mature woman, then she walks, suddenly she sits"
)
assert len(segs) == 3
conds, texts = refiner._v2_encode_per_window_conditionings(FakeClip(), segs)
assert len(conds) == 3
# anchor = full first segment, not just "In this anime video"
texts = refiner._v2_transition_scene_texts(segs)
assert len(texts) == 3
assert texts[0] == "In this anime video, Hiyuki is a mature woman"
assert texts[1].startswith("In this anime video, Hiyuki is a mature woman")
assert "then she walks" in texts[1]
+2 -3
View File
@@ -493,9 +493,8 @@ function openPanel(node) {
const splitToggle = toggleEl(!!settings.refiner.split_by_transitions, "Split prompt by transitions");
splitToggle.inp.addEventListener("change", () => { settings.refiner.split_by_transitions = splitToggle.inp.checked; });
body.append(el("div", "funpack-studio-hint",
"Detect transition words in the prompt (then, suddenly, cut to...) and encode each scene segment as a separate conditioning entry. " +
"The character description (text before the first comma) is prepended to every segment to keep the subject consistent. " +
"Use together with FunPack Context Transition Windows."));
"Detect transition words in the prompt and preview scene segments in the encoded prompts output. " +
"The refiner still returns one full-prompt conditioning entry."));
body.append(row("Split by transitions", splitToggle.wrap));
body.append(sectionTitle("Negative prompt"));